├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md └── workflows │ ├── cloc.yml │ ├── golangci-lint.yml │ ├── gorelease.yml │ └── test-unit.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── dev_test.go ├── doc.go ├── go.mod ├── go.sum ├── internal ├── json_schema.go └── operation_context.go ├── marker.go ├── openapi3 ├── doc.go ├── entities.go ├── entities_extra_test.go ├── entities_test.go ├── example_misc_test.go ├── example_security_test.go ├── example_test.go ├── helper.go ├── helper_test.go ├── jsonschema.go ├── reflect.go ├── reflect_deprecated.go ├── reflect_deprecated_test.go ├── reflect_go1.18_test.go ├── reflect_test.go ├── testdata │ ├── openai-openapi-fixed.json │ ├── openai-openapi.json │ ├── openapi.json │ ├── openapi_req.json │ ├── openapi_req2.json │ ├── openapi_req_array.json │ ├── req_schema.json │ ├── resp_schema.json │ ├── swgui │ │ ├── go.mod │ │ ├── go.sum │ │ └── swgui.go │ └── uploads.json ├── upload_test.go ├── walk_schema.go ├── walk_schema_test.go └── yaml.go ├── openapi31 ├── doc.go ├── entities.go ├── entities_extra_test.go ├── entities_test.go ├── example_misc_test.go ├── example_security_test.go ├── example_test.go ├── helper.go ├── helper_test.go ├── jsonschema.go ├── reflect.go ├── reflect_test.go ├── security_test.go ├── testdata │ ├── albums_api.yaml │ ├── openapi.json │ ├── openapi_req.json │ ├── openapi_req_array.json │ ├── req_schema.json │ ├── resp_schema.json │ └── uploads.json ├── upload_test.go ├── walk_schema.go ├── walk_schema_test.go └── yaml.go ├── operation.go ├── operation_test.go ├── reflector.go ├── resources ├── logo.png └── schema │ ├── openapi3.json │ ├── openapi31-config.json │ ├── openapi31-patch.json │ ├── openapi31-patched.json │ └── openapi31.json └── spec.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | If feasible/relevant, please provide a code snippet (inline or with Go playground) to reproduce the issue. 15 | 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Additional context** 21 | Add any other context about the problem here. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please use discussions https://github.com/swaggest/openapi-go/discussions/categories/ideas to share feature ideas. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Any question about features or usage 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please use discussions https://github.com/swaggest/openapi-go/discussions/categories/q-a to make your question more discoverable by other folks. 11 | -------------------------------------------------------------------------------- /.github/workflows/cloc.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: cloc 3 | on: 4 | pull_request: 5 | 6 | # Cancel the workflow in progress in newer build is about to start. 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | cloc: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | with: 18 | path: pr 19 | - name: Checkout base code 20 | uses: actions/checkout@v4 21 | with: 22 | ref: ${{ github.event.pull_request.base.sha }} 23 | path: base 24 | - name: Count lines of code 25 | id: loc 26 | run: | 27 | curl -sLO https://github.com/vearutop/sccdiff/releases/download/v1.0.3/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz 28 | sccdiff_hash=$(git hash-object ./sccdiff) 29 | [ "$sccdiff_hash" == "ae8a07b687bd3dba60861584efe724351aa7ff63" ] || (echo "::error::unexpected hash for sccdiff, possible tampering: $sccdiff_hash" && exit 1) 30 | OUTPUT=$(cd pr && ../sccdiff -basedir ../base) 31 | echo "${OUTPUT}" 32 | echo "diff<> $GITHUB_OUTPUT && echo "$OUTPUT" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 33 | 34 | - name: Comment lines of code 35 | continue-on-error: true 36 | uses: marocchino/sticky-pull-request-comment@v2 37 | with: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | header: LOC 40 | message: | 41 | ### Lines Of Code 42 | 43 | ${{ steps.loc.outputs.diff }} 44 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: lint 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | branches: 8 | - master 9 | - main 10 | pull_request: 11 | 12 | # Cancel the workflow in progress in newer build is about to start. 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | golangci: 19 | name: golangci-lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: stable 25 | - uses: actions/checkout@v4 26 | - name: golangci-lint 27 | uses: golangci/golangci-lint-action@v6.5.0 28 | with: 29 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 30 | version: v1.64.5 31 | 32 | # Optional: working directory, useful for monorepos 33 | # working-directory: somedir 34 | 35 | # Optional: golangci-lint command line arguments. 36 | # args: --issues-exit-code=0 37 | 38 | # Optional: show only new issues if it's a pull request. The default value is `false`. 39 | # only-new-issues: true 40 | 41 | # Optional: if set to true then the action will use pre-installed Go. 42 | # skip-go-installation: true 43 | 44 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 45 | # skip-pkg-cache: true 46 | 47 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 48 | # skip-build-cache: true -------------------------------------------------------------------------------- /.github/workflows/gorelease.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: gorelease 3 | on: 4 | pull_request: 5 | 6 | # Cancel the workflow in progress in newer build is about to start. 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 9 | cancel-in-progress: true 10 | 11 | env: 12 | GO_VERSION: stable 13 | jobs: 14 | gorelease: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Install Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ${{ env.GO_VERSION }} 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | - name: Gorelease cache 24 | uses: actions/cache@v4 25 | with: 26 | path: | 27 | ~/go/bin/gorelease 28 | key: ${{ runner.os }}-gorelease-generic 29 | - name: Gorelease 30 | id: gorelease 31 | run: | 32 | test -e ~/go/bin/gorelease || go install golang.org/x/exp/cmd/gorelease@latest 33 | OUTPUT=$(gorelease 2>&1 || exit 0) 34 | echo "${OUTPUT}" 35 | echo "report<> $GITHUB_OUTPUT && echo "$OUTPUT" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 36 | - name: Comment report 37 | continue-on-error: true 38 | uses: marocchino/sticky-pull-request-comment@v2 39 | with: 40 | header: gorelease 41 | message: | 42 | ### Go API Changes 43 | 44 |
45 |             ${{ steps.gorelease.outputs.report }}
46 |             
-------------------------------------------------------------------------------- /.github/workflows/test-unit.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: test-unit 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | # Cancel the workflow in progress in newer build is about to start. 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | env: 16 | GO111MODULE: "on" 17 | RUN_BASE_COVERAGE: "on" # Runs test for PR base in case base test coverage is missing. 18 | COV_GO_VERSION: stable # Version of Go to collect coverage 19 | TARGET_DELTA_COV: 90 # Target coverage of changed lines, in percents 20 | jobs: 21 | test: 22 | strategy: 23 | matrix: 24 | go-version: [ 1.16.x, stable, oldstable ] 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Install Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: ${{ matrix.go-version }} 31 | 32 | - name: Checkout code 33 | uses: actions/checkout@v4 34 | 35 | - name: Go cache 36 | uses: actions/cache@v4 37 | with: 38 | # In order: 39 | # * Module download cache 40 | # * Build cache (Linux) 41 | path: | 42 | ~/go/pkg/mod 43 | ~/.cache/go-build 44 | key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.sum') }} 45 | restore-keys: | 46 | ${{ runner.os }}-go-cache 47 | 48 | - name: Restore base test coverage 49 | id: base-coverage 50 | if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' 51 | uses: actions/cache@v4 52 | with: 53 | path: | 54 | unit-base.txt 55 | # Use base sha for PR or new commit hash for master/main push in test result key. 56 | key: ${{ runner.os }}-unit-test-coverage-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} 57 | 58 | - name: Run test for base code 59 | if: matrix.go-version == env.COV_GO_VERSION && env.RUN_BASE_COVERAGE == 'on' && steps.base-coverage.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' 60 | run: | 61 | git fetch origin master ${{ github.event.pull_request.base.sha }} 62 | HEAD=$(git rev-parse HEAD) 63 | git reset --hard ${{ github.event.pull_request.base.sha }} 64 | (make test-unit && go tool cover -func=./unit.coverprofile > unit-base.txt) || echo "No test-unit in base" 65 | git reset --hard $HEAD 66 | 67 | - name: Test 68 | id: test 69 | run: | 70 | make test-unit 71 | go tool cover -func=./unit.coverprofile > unit.txt 72 | TOTAL=$(grep 'total:' unit.txt) 73 | echo "${TOTAL}" 74 | echo "total=$TOTAL" >> $GITHUB_OUTPUT 75 | 76 | - name: Annotate missing test coverage 77 | id: annotate 78 | if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' 79 | run: | 80 | curl -sLO https://github.com/vearutop/gocovdiff/releases/download/v1.4.2/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz && rm linux_amd64.tar.gz 81 | gocovdiff_hash=$(git hash-object ./gocovdiff) 82 | [ "$gocovdiff_hash" == "c37862c73a677e5a9c069470287823ab5bbf0244" ] || (echo "::error::unexpected hash for gocovdiff, possible tampering: $gocovdiff_hash" && exit 1) 83 | git fetch origin master ${{ github.event.pull_request.base.sha }} 84 | REP=$(./gocovdiff -mod github.com/$GITHUB_REPOSITORY -cov unit.coverprofile -gha-annotations gha-unit.txt -delta-cov-file delta-cov-unit.txt -target-delta-cov ${TARGET_DELTA_COV}) 85 | echo "${REP}" 86 | cat gha-unit.txt 87 | DIFF=$(test -e unit-base.txt && ./gocovdiff -mod github.com/$GITHUB_REPOSITORY -func-cov unit.txt -func-base-cov unit-base.txt || echo "Missing base coverage file") 88 | TOTAL=$(cat delta-cov-unit.txt) 89 | echo "rep<> $GITHUB_OUTPUT && echo "$REP" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 90 | echo "diff<> $GITHUB_OUTPUT && echo "$DIFF" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 91 | echo "total<> $GITHUB_OUTPUT && echo "$TOTAL" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 92 | 93 | - name: Comment test coverage 94 | continue-on-error: true 95 | if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' 96 | uses: marocchino/sticky-pull-request-comment@v2 97 | with: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 99 | header: unit-test 100 | message: | 101 | ### Unit Test Coverage 102 | ${{ steps.test.outputs.total }} 103 | ${{ steps.annotate.outputs.total }} 104 |
Coverage of changed lines 105 | 106 | ${{ steps.annotate.outputs.rep }} 107 | 108 |
109 | 110 |
Coverage diff with base branch 111 | 112 | ${{ steps.annotate.outputs.diff }} 113 | 114 |
115 | 116 | - name: Store base coverage 117 | if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} 118 | run: cp unit.txt unit-base.txt 119 | 120 | - name: Upload code coverage 121 | if: matrix.go-version == env.COV_GO_VERSION 122 | uses: codecov/codecov-action@v5 123 | with: 124 | files: ./unit.coverprofile 125 | flags: unittests 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *_last_run.json 2 | /.idea 3 | /*.coverprofile 4 | /.vscode 5 | /bench-*.txt 6 | /vendor 7 | go.work 8 | go.work.sum 9 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml 2 | run: 3 | tests: true 4 | 5 | linters-settings: 6 | errcheck: 7 | check-type-assertions: true 8 | check-blank: true 9 | gocyclo: 10 | min-complexity: 20 11 | dupl: 12 | threshold: 100 13 | misspell: 14 | locale: US 15 | unparam: 16 | check-exported: true 17 | 18 | linters: 19 | enable-all: true 20 | disable: 21 | - nilnil 22 | - err113 23 | - funlen 24 | - gocyclo 25 | - cyclop 26 | - gocognit 27 | - musttag 28 | - intrange 29 | - copyloopvar 30 | - lll 31 | - gochecknoglobals 32 | - wrapcheck 33 | - paralleltest 34 | - forbidigo 35 | - forcetypeassert 36 | - varnamelen 37 | - tagliatelle 38 | - errname 39 | - ireturn 40 | - exhaustruct 41 | - nonamedreturns 42 | - testableexamples 43 | - dupword 44 | - depguard 45 | - tagalign 46 | - mnd 47 | - testifylint 48 | - recvcheck 49 | 50 | issues: 51 | exclude-use-default: false 52 | exclude-rules: 53 | - linters: 54 | - staticcheck 55 | text: "SA1019: strings.Title .+ deprecated" 56 | 57 | - linters: 58 | - mnd 59 | - goconst 60 | - noctx 61 | - funlen 62 | - dupl 63 | - unused 64 | - unparam 65 | path: "_test.go" 66 | - linters: 67 | - errcheck # Error checking omitted for brevity. 68 | - gosec 69 | path: "example_" 70 | - linters: 71 | - revive 72 | text: "unused-parameter: parameter" 73 | 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Viacheslav Poturaev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #GOLANGCI_LINT_VERSION := "v1.64.5" # Optional configuration to pinpoint golangci-lint version. 2 | 3 | # The head of Makefile determines location of dev-go to include standard targets. 4 | GO ?= go 5 | export GO111MODULE = on 6 | 7 | ifneq "$(GOFLAGS)" "" 8 | $(info GOFLAGS: ${GOFLAGS}) 9 | endif 10 | 11 | ifneq "$(wildcard ./vendor )" "" 12 | $(info Using vendor) 13 | modVendor = -mod=vendor 14 | ifeq (,$(findstring -mod,$(GOFLAGS))) 15 | export GOFLAGS := ${GOFLAGS} ${modVendor} 16 | endif 17 | ifneq "$(wildcard ./vendor/github.com/bool64/dev)" "" 18 | DEVGO_PATH := ./vendor/github.com/bool64/dev 19 | endif 20 | endif 21 | 22 | ifeq ($(DEVGO_PATH),) 23 | DEVGO_PATH := $(shell GO111MODULE=on $(GO) list ${modVendor} -f '{{.Dir}}' -m github.com/bool64/dev) 24 | ifeq ($(DEVGO_PATH),) 25 | $(info Module github.com/bool64/dev not found, downloading.) 26 | DEVGO_PATH := $(shell export GO111MODULE=on && $(GO) get github.com/bool64/dev && $(GO) list -f '{{.Dir}}' -m github.com/bool64/dev) 27 | endif 28 | endif 29 | 30 | JSON_CLI_VERSION := "v1.8.6" 31 | JSON_CLI_VERSION_31 := "v1.11.1" 32 | 33 | -include $(DEVGO_PATH)/makefiles/main.mk 34 | -include $(DEVGO_PATH)/makefiles/lint.mk 35 | -include $(DEVGO_PATH)/makefiles/test-unit.mk 36 | -include $(DEVGO_PATH)/makefiles/reset-ci.mk 37 | 38 | # Add your custom targets here. 39 | 40 | ## Run tests 41 | test: test-unit 42 | 43 | ## Generate entities from schema 44 | gen-3.0: 45 | @test -s $(GOPATH)/bin/json-cli-$(JSON_CLI_VERSION) || (curl -sSfL https://github.com/swaggest/json-cli/releases/download/$(JSON_CLI_VERSION)/json-cli -o $(GOPATH)/bin/json-cli-$(JSON_CLI_VERSION) && chmod +x $(GOPATH)/bin/json-cli-$(JSON_CLI_VERSION)) 46 | @cd resources/schema/ && $(GOPATH)/bin/json-cli-$(JSON_CLI_VERSION) gen-go openapi3.json --output ../../openapi3/entities.go --package-name openapi3 --with-tests --with-zero-values --validate-required --fluent-setters --root-name Spec 47 | @gofmt -w ./openapi3/entities.go ./openapi3/entities_test.go 48 | 49 | 50 | ## Generate entities from schema 51 | gen-3.1: 52 | @test -s $(GOPATH)/bin/json-cli-$(JSON_CLI_VERSION_31) || (curl -sSfL https://github.com/swaggest/json-cli/releases/download/$(JSON_CLI_VERSION_31)/json-cli -o $(GOPATH)/bin/json-cli-$(JSON_CLI_VERSION_31) && chmod +x $(GOPATH)/bin/json-cli-$(JSON_CLI_VERSION_31)) 53 | @cd resources/schema/ && $(GOPATH)/bin/json-cli-$(JSON_CLI_VERSION_31) gen-go openapi31-patched.json --config openapi31-config.json --output ../../openapi31/entities.go --package-name openapi31 --def-ptr '#/$$defs' --with-zero-values --validate-required --fluent-setters --root-name Spec 54 | @gofmt -w ./openapi31/entities.go 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI structures for Go 2 | 3 | 4 | 5 | This library provides Go structures to marshal/unmarshal and reflect [OpenAPI Schema](https://swagger.io/resources/open-api/) documents. 6 | 7 | For automated HTTP REST service framework built with this library please check [`github.com/swaggest/rest`](https://github.com/swaggest/rest). 8 | 9 | [![Build Status](https://github.com/swaggest/openapi-go/workflows/test/badge.svg)](https://github.com/swaggest/openapi-go/actions?query=branch%3Amaster+workflow%3Atest) 10 | [![Coverage Status](https://codecov.io/gh/swaggest/openapi-go/branch/master/graph/badge.svg)](https://codecov.io/gh/swaggest/openapi-go) 11 | [![GoDevDoc](https://img.shields.io/badge/dev-doc-00ADD8?logo=go)](https://pkg.go.dev/github.com/swaggest/openapi-go) 12 | [![time tracker](https://wakatime.com/badge/github/swaggest/openapi-go.svg)](https://wakatime.com/badge/github/swaggest/openapi-go) 13 | ![Code lines](https://sloc.xyz/github/swaggest/openapi-go/?category=code) 14 | ![Comments](https://sloc.xyz/github/swaggest/openapi-go/?category=comments) 15 | 16 | ## Features 17 | 18 | * Type safe mapping of OpenAPI 3 documents with Go structures generated from schema. 19 | * Type-based reflection of Go structures to OpenAPI 3.0 or 3.1 schema. 20 | * Schema control with field tags 21 | * `json` for request bodies and responses in JSON 22 | * `query`, `path` for parameters in URL 23 | * `header`, `cookie`, `formData`, `file` for other parameters 24 | * `form` acts as `query` and `formData` 25 | * `contentType` indicates body content type 26 | * [field tags](https://github.com/swaggest/jsonschema-go#field-tags) named after JSON Schema/OpenAPI 3 Schema constraints 27 | * `collectionFormat` to unpack slices from string 28 | * `csv` comma-separated values, 29 | * `ssv` space-separated values, 30 | * `pipes` pipe-separated values (`|`), 31 | * `multi` ampersand-separated values (`&`), 32 | * `json` additionally to slices unpacks maps and structs, 33 | * Flexible schema control with [`jsonschema-go`](https://github.com/swaggest/jsonschema-go#implementing-interfaces-on-a-type) 34 | 35 | ## Example 36 | 37 | [Other examples](https://pkg.go.dev/github.com/swaggest/openapi-go/openapi3#pkg-examples). 38 | 39 | ```go 40 | reflector := openapi3.Reflector{} 41 | reflector.Spec = &openapi3.Spec{Openapi: "3.0.3"} 42 | reflector.Spec.Info. 43 | WithTitle("Things API"). 44 | WithVersion("1.2.3"). 45 | WithDescription("Put something here") 46 | 47 | type req struct { 48 | ID string `path:"id" example:"XXX-XXXXX"` 49 | Locale string `query:"locale" pattern:"^[a-z]{2}-[A-Z]{2}$"` 50 | Title string `json:"string"` 51 | Amount uint `json:"amount"` 52 | Items []struct { 53 | Count uint `json:"count"` 54 | Name string `json:"name"` 55 | } `json:"items"` 56 | } 57 | 58 | type resp struct { 59 | ID string `json:"id" example:"XXX-XXXXX"` 60 | Amount uint `json:"amount"` 61 | Items []struct { 62 | Count uint `json:"count"` 63 | Name string `json:"name"` 64 | } `json:"items"` 65 | UpdatedAt time.Time `json:"updated_at"` 66 | } 67 | 68 | putOp, err := reflector.NewOperationContext(http.MethodPut, "/things/{id}") 69 | handleError(err) 70 | 71 | putOp.AddReqStructure(new(req)) 72 | putOp.AddRespStructure(new(resp), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusOK }) 73 | putOp.AddRespStructure(new([]resp), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusConflict }) 74 | 75 | reflector.AddOperation(putOp) 76 | 77 | getOp, err := reflector.NewOperationContext(http.MethodGet, "/things/{id}") 78 | handleError(err) 79 | 80 | getOp.AddReqStructure(new(req)) 81 | getOp.AddRespStructure(new(resp), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusOK }) 82 | 83 | reflector.AddOperation(getOp) 84 | 85 | schema, err := reflector.Spec.MarshalYAML() 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | fmt.Println(string(schema)) 91 | ``` 92 | 93 | Output: 94 | 95 | ```yaml 96 | openapi: 3.0.3 97 | info: 98 | description: Put something here 99 | title: Things API 100 | version: 1.2.3 101 | paths: 102 | /things/{id}: 103 | get: 104 | parameters: 105 | - in: query 106 | name: locale 107 | schema: 108 | pattern: ^[a-z]{2}-[A-Z]{2}$ 109 | type: string 110 | - in: path 111 | name: id 112 | required: true 113 | schema: 114 | example: XXX-XXXXX 115 | type: string 116 | responses: 117 | "200": 118 | content: 119 | application/json: 120 | schema: 121 | $ref: '#/components/schemas/Resp' 122 | description: OK 123 | put: 124 | parameters: 125 | - in: query 126 | name: locale 127 | schema: 128 | pattern: ^[a-z]{2}-[A-Z]{2}$ 129 | type: string 130 | - in: path 131 | name: id 132 | required: true 133 | schema: 134 | example: XXX-XXXXX 135 | type: string 136 | requestBody: 137 | content: 138 | application/json: 139 | schema: 140 | $ref: '#/components/schemas/Req' 141 | responses: 142 | "200": 143 | content: 144 | application/json: 145 | schema: 146 | $ref: '#/components/schemas/Resp' 147 | description: OK 148 | "409": 149 | content: 150 | application/json: 151 | schema: 152 | items: 153 | $ref: '#/components/schemas/Resp' 154 | type: array 155 | description: Conflict 156 | components: 157 | schemas: 158 | Req: 159 | properties: 160 | amount: 161 | minimum: 0 162 | type: integer 163 | items: 164 | items: 165 | properties: 166 | count: 167 | minimum: 0 168 | type: integer 169 | name: 170 | type: string 171 | type: object 172 | nullable: true 173 | type: array 174 | string: 175 | type: string 176 | type: object 177 | Resp: 178 | properties: 179 | amount: 180 | minimum: 0 181 | type: integer 182 | id: 183 | example: XXX-XXXXX 184 | type: string 185 | items: 186 | items: 187 | properties: 188 | count: 189 | minimum: 0 190 | type: integer 191 | name: 192 | type: string 193 | type: object 194 | nullable: true 195 | type: array 196 | updated_at: 197 | format: date-time 198 | type: string 199 | type: object 200 | ``` 201 | -------------------------------------------------------------------------------- /dev_test.go: -------------------------------------------------------------------------------- 1 | package openapi_test 2 | 3 | import _ "github.com/bool64/dev" // Include CI/Dev scripts to project. 4 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package openapi provides tools and mappings for OpenAPI specs. 2 | package openapi 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/swaggest/openapi-go 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/bool64/dev v0.2.39 7 | github.com/stretchr/testify v1.8.2 8 | github.com/swaggest/assertjson v1.9.0 9 | github.com/swaggest/jsonschema-go v0.3.74 10 | github.com/swaggest/refl v1.3.1 11 | gopkg.in/yaml.v2 v2.4.0 12 | ) 13 | 14 | require ( 15 | github.com/bool64/shared v0.1.5 // indirect 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/fsnotify/fsnotify v1.4.9 // indirect 18 | github.com/iancoleman/orderedmap v0.3.0 // indirect 19 | github.com/pmezard/go-difflib v1.0.0 // indirect 20 | github.com/sergi/go-diff v1.3.1 // indirect 21 | github.com/yudai/gojsondiff v1.0.0 // indirect 22 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect 23 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bool64/dev v0.2.39 h1:kP8DnMGlWXhGYJEZE/J0l/gVBdbuhoPGL+MJG4QbofE= 2 | github.com/bool64/dev v0.2.39/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= 3 | github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= 4 | github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 9 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 10 | github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= 11 | github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= 12 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 13 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 14 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 15 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 16 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 17 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= 18 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 19 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 20 | github.com/onsi/ginkgo v1.15.2 h1:l77YT15o814C2qVL47NOyjV/6RbaP7kKdrvZnxQ3Org= 21 | github.com/onsi/gomega v1.11.0 h1:+CqWgvj0OZycCaqclBD1pxKHAU+tOkHmQIWvDHq2aug= 22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 25 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 28 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 29 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 30 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 31 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 32 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 33 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 34 | github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= 35 | github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= 36 | github.com/swaggest/jsonschema-go v0.3.74 h1:hkAZBK3RxNWU013kPqj0Q/GHGzYCCm9WcUTnfg2yPp0= 37 | github.com/swaggest/jsonschema-go v0.3.74/go.mod h1:qp+Ym2DIXHlHzch3HKz50gPf2wJhKOrAB/VYqLS2oJU= 38 | github.com/swaggest/refl v1.3.1 h1:XGplEkYftR7p9cz1lsiwXMM2yzmOymTE9vneVVpaOh4= 39 | github.com/swaggest/refl v1.3.1/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= 40 | github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= 41 | github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= 42 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= 43 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= 44 | github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= 45 | golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= 46 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 47 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 48 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 51 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 53 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 54 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 55 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 56 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 57 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 58 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 59 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 60 | -------------------------------------------------------------------------------- /internal/json_schema.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "mime/multipart" 8 | "net/http" 9 | "reflect" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/swaggest/jsonschema-go" 14 | "github.com/swaggest/openapi-go" 15 | "github.com/swaggest/refl" 16 | ) 17 | 18 | const ( 19 | tagJSON = "json" 20 | tagFormData = "formData" 21 | tagForm = "form" 22 | tagHeader = "header" 23 | 24 | componentsSchemas = "#/components/schemas/" 25 | ) 26 | 27 | var defNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9.\-_]+`) 28 | 29 | func sanitizeDefName(rc *jsonschema.ReflectContext) { 30 | jsonschema.InterceptDefName(func(_ reflect.Type, defaultDefName string) string { 31 | return defNameSanitizer.ReplaceAllString(defaultDefName, "") 32 | })(rc) 33 | } 34 | 35 | // ReflectRequestBody reflects JSON schema of request body. 36 | func ReflectRequestBody( 37 | is31 bool, // True if OpenAPI 3.1 38 | r *jsonschema.Reflector, 39 | cu openapi.ContentUnit, 40 | httpMethod string, 41 | mapping map[string]string, 42 | tag string, 43 | additionalTags []string, 44 | reflOptions ...func(rc *jsonschema.ReflectContext), 45 | ) (schema *jsonschema.Schema, hasFileUpload bool, err error) { 46 | input := cu.Structure 47 | 48 | httpMethod = strings.ToUpper(httpMethod) 49 | _, forceRequestBody := input.(openapi.RequestBodyEnforcer) 50 | _, forceJSONRequestBody := input.(openapi.RequestJSONBodyEnforcer) 51 | 52 | // GET, HEAD, DELETE and TRACE requests should not have body. 53 | switch httpMethod { 54 | case http.MethodGet, http.MethodHead, http.MethodDelete, http.MethodTrace: 55 | if !forceRequestBody { 56 | return nil, false, nil 57 | } 58 | } 59 | 60 | hasTaggedFields := refl.HasTaggedFields(input, tag) 61 | for _, t := range additionalTags { 62 | if hasTaggedFields { 63 | break 64 | } 65 | 66 | hasTaggedFields = refl.HasTaggedFields(input, t) 67 | } 68 | 69 | hasJSONSchemaStruct := false 70 | 71 | refl.WalkFieldsRecursively(reflect.ValueOf(input), func(v reflect.Value, _ reflect.StructField, _ []reflect.StructField) { 72 | if v.Type() == reflect.TypeOf(jsonschema.Struct{}) { 73 | hasJSONSchemaStruct = true 74 | } 75 | }) 76 | 77 | // Form data can not have map or array as body. 78 | if !hasTaggedFields && len(mapping) == 0 && tag != tagJSON { 79 | return nil, false, nil 80 | } 81 | 82 | // If `formData` is defined on a request body `json` is ignored. 83 | if tag == tagJSON && 84 | (refl.HasTaggedFields(input, tagFormData) || refl.HasTaggedFields(input, tagForm)) && 85 | !forceJSONRequestBody && !hasJSONSchemaStruct { 86 | return nil, false, nil 87 | } 88 | 89 | // Checking for default options that allow tag-less JSON. 90 | isProcessWithoutTags := false 91 | 92 | _, err = r.Reflect("", func(rc *jsonschema.ReflectContext) { 93 | isProcessWithoutTags = rc.ProcessWithoutTags 94 | }) 95 | if err != nil { 96 | return nil, false, fmt.Errorf("BUG: %w", err) 97 | } 98 | 99 | // JSON can be a map or array without field tags. 100 | if !hasTaggedFields && !hasJSONSchemaStruct && len(mapping) == 0 && !refl.IsSliceOrMap(input) && 101 | refl.FindEmbeddedSliceOrMap(input) == nil && !isProcessWithoutTags { 102 | return nil, false, nil 103 | } 104 | 105 | definitionPrefix := "" 106 | 107 | if tag != tagJSON { 108 | definitionPrefix += strings.Title(tag) 109 | } 110 | 111 | reflOptions = append(reflOptions, 112 | jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string { 113 | if tag != tagJSON { 114 | v := reflect.New(t).Interface() 115 | 116 | if refl.HasTaggedFields(v, tag) { 117 | return definitionPrefix + defaultDefName 118 | } 119 | 120 | for _, at := range additionalTags { 121 | if refl.HasTaggedFields(v, at) { 122 | return definitionPrefix + defaultDefName 123 | } 124 | } 125 | } 126 | 127 | return defaultDefName 128 | }), 129 | jsonschema.RootRef, 130 | jsonschema.PropertyNameMapping(mapping), 131 | jsonschema.PropertyNameTag(tag, additionalTags...), 132 | sanitizeDefName, 133 | jsonschema.InterceptNullability(func(params jsonschema.InterceptNullabilityParams) { 134 | if params.NullAdded { 135 | if params.Schema.ReflectType == nil { 136 | return 137 | } 138 | 139 | vv := reflect.Zero(params.Schema.ReflectType).Interface() 140 | 141 | foundFiles := false 142 | if _, ok := vv.([]multipart.File); ok { 143 | foundFiles = true 144 | } 145 | 146 | if _, ok := vv.([]*multipart.FileHeader); ok { 147 | foundFiles = true 148 | } 149 | 150 | if foundFiles { 151 | params.Schema.RemoveType(jsonschema.Null) 152 | } 153 | } 154 | }), 155 | jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (stop bool, err error) { 156 | vv := params.Value.Interface() 157 | 158 | foundFile := false 159 | if _, ok := vv.(*multipart.File); ok { 160 | foundFile = true 161 | } 162 | 163 | if _, ok := vv.(*multipart.FileHeader); ok { 164 | foundFile = true 165 | } 166 | 167 | if foundFile { 168 | params.Schema.AddType(jsonschema.String) 169 | params.Schema.RemoveType(jsonschema.Null) 170 | params.Schema.WithFormat("binary") 171 | 172 | if is31 { 173 | params.Schema.WithExtraPropertiesItem("contentMediaType", "application/octet-stream") 174 | } 175 | 176 | hasFileUpload = true 177 | 178 | return true, nil 179 | } 180 | 181 | return false, nil 182 | }), 183 | ) 184 | 185 | sch, err := r.Reflect(input, reflOptions...) 186 | if err != nil { 187 | return nil, false, err 188 | } 189 | 190 | return &sch, hasFileUpload, nil 191 | } 192 | 193 | // ReflectJSONResponse reflects JSON schema of response. 194 | func ReflectJSONResponse( 195 | r *jsonschema.Reflector, 196 | output interface{}, 197 | reflOptions ...func(rc *jsonschema.ReflectContext), 198 | ) (schema *jsonschema.Schema, err error) { 199 | if output == nil { 200 | return nil, nil 201 | } 202 | 203 | // Check if output structure exposes meaningful schema. 204 | if hasJSONBody, err := hasJSONBody(r, output); err == nil && !hasJSONBody { 205 | return nil, nil 206 | } 207 | 208 | reflOptions = append(reflOptions, 209 | jsonschema.RootRef, 210 | sanitizeDefName, 211 | ) 212 | 213 | sch, err := r.Reflect(output, reflOptions...) 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | return &sch, nil 219 | } 220 | 221 | func hasJSONBody(r *jsonschema.Reflector, output interface{}) (bool, error) { 222 | schema, err := r.Reflect(output, sanitizeDefName) 223 | if err != nil { 224 | return false, err 225 | } 226 | 227 | // Remove non-constraining fields to prepare for marshaling. 228 | schema.Title = nil 229 | schema.Description = nil 230 | schema.Comment = nil 231 | schema.ExtraProperties = nil 232 | schema.ID = nil 233 | schema.Examples = nil 234 | 235 | j, err := json.Marshal(schema) 236 | if err != nil { 237 | return false, err 238 | } 239 | 240 | if !bytes.Equal([]byte("{}"), j) && !bytes.Equal([]byte(`{"type":"object"}`), j) { 241 | return true, nil 242 | } 243 | 244 | return false, nil 245 | } 246 | 247 | // ReflectResponseHeader reflects response headers from content unit. 248 | func ReflectResponseHeader( 249 | r *jsonschema.Reflector, 250 | oc openapi.OperationContext, 251 | cu openapi.ContentUnit, 252 | interceptProp jsonschema.InterceptPropFunc, 253 | ) (jsonschema.Schema, error) { 254 | output := cu.Structure 255 | mapping := cu.FieldMapping(openapi.InHeader) 256 | 257 | if output == nil { 258 | return jsonschema.Schema{}, nil 259 | } 260 | 261 | return r.Reflect(output, 262 | func(rc *jsonschema.ReflectContext) { 263 | rc.ProcessWithoutTags = false 264 | }, 265 | openapi.WithOperationCtx(oc, true, openapi.InHeader), 266 | jsonschema.InlineRefs, 267 | jsonschema.PropertyNameMapping(mapping), 268 | jsonschema.PropertyNameTag(tagHeader), 269 | sanitizeDefName, 270 | jsonschema.InterceptProp(interceptProp), 271 | ) 272 | } 273 | 274 | // ReflectParametersIn reflects JSON schema of request parameters. 275 | func ReflectParametersIn( 276 | r *jsonschema.Reflector, 277 | oc openapi.OperationContext, 278 | c openapi.ContentUnit, 279 | in openapi.In, 280 | collectDefinitions func(name string, schema jsonschema.Schema), 281 | interceptProp jsonschema.InterceptPropFunc, 282 | additionalTags ...string, 283 | ) (jsonschema.Schema, error) { 284 | input := c.Structure 285 | propertyMapping := c.FieldMapping(in) 286 | 287 | if refl.IsSliceOrMap(input) { 288 | return jsonschema.Schema{}, nil 289 | } 290 | 291 | return r.Reflect(input, 292 | func(rc *jsonschema.ReflectContext) { 293 | rc.ProcessWithoutTags = false 294 | }, 295 | openapi.WithOperationCtx(oc, false, in), 296 | jsonschema.DefinitionsPrefix(componentsSchemas), 297 | jsonschema.CollectDefinitions(collectDefinitions), 298 | jsonschema.PropertyNameMapping(propertyMapping), 299 | jsonschema.PropertyNameTag(string(in), additionalTags...), 300 | func(rc *jsonschema.ReflectContext) { 301 | rc.UnnamedFieldWithTag = true 302 | }, 303 | sanitizeDefName, 304 | jsonschema.SkipEmbeddedMapsSlices, 305 | jsonschema.InterceptProp(interceptProp), 306 | ) 307 | } 308 | -------------------------------------------------------------------------------- /internal/operation_context.go: -------------------------------------------------------------------------------- 1 | // Package internal keeps reusable internal code. 2 | package internal 3 | 4 | import "github.com/swaggest/openapi-go" 5 | 6 | // NewOperationContext creates OperationContext. 7 | func NewOperationContext(method, pathPattern string) *OperationContext { 8 | return &OperationContext{ 9 | method: method, 10 | pathPattern: pathPattern, 11 | } 12 | } 13 | 14 | // OperationContext implements openapi.OperationContext. 15 | type OperationContext struct { 16 | method string 17 | pathPattern string 18 | req []openapi.ContentUnit 19 | resp []openapi.ContentUnit 20 | 21 | isProcessingResponse bool 22 | processingIn openapi.In 23 | } 24 | 25 | // Method returns HTTP method of an operation. 26 | func (o *OperationContext) Method() string { 27 | return o.method 28 | } 29 | 30 | // PathPattern returns operation HTTP URL path pattern. 31 | func (o *OperationContext) PathPattern() string { 32 | return o.pathPattern 33 | } 34 | 35 | // Request returns list of operation request content schemas. 36 | func (o *OperationContext) Request() []openapi.ContentUnit { 37 | return o.req 38 | } 39 | 40 | // Response returns list of operation response content schemas. 41 | func (o *OperationContext) Response() []openapi.ContentUnit { 42 | return o.resp 43 | } 44 | 45 | // SetIsProcessingResponse sets current processing state. 46 | func (o *OperationContext) SetIsProcessingResponse(is bool) { 47 | o.isProcessingResponse = is 48 | } 49 | 50 | // IsProcessingResponse indicates if response is being processed. 51 | func (o *OperationContext) IsProcessingResponse() bool { 52 | return o.isProcessingResponse 53 | } 54 | 55 | // SetProcessingIn sets current content location being processed. 56 | func (o *OperationContext) SetProcessingIn(in openapi.In) { 57 | o.processingIn = in 58 | } 59 | 60 | // ProcessingIn return which content location is being processed now. 61 | func (o *OperationContext) ProcessingIn() openapi.In { 62 | return o.processingIn 63 | } 64 | 65 | // SetMethod sets HTTP method of an operation. 66 | func (o *OperationContext) SetMethod(method string) { 67 | o.method = method 68 | } 69 | 70 | // SetPathPattern sets URL path pattern of an operation. 71 | func (o *OperationContext) SetPathPattern(pattern string) { 72 | o.pathPattern = pattern 73 | } 74 | 75 | // AddReqStructure adds request content schema. 76 | func (o *OperationContext) AddReqStructure(s interface{}, options ...openapi.ContentOption) { 77 | c := openapi.ContentUnit{} 78 | 79 | if cp, ok := s.(openapi.ContentUnitPreparer); ok { 80 | cp.SetupContentUnit(&c) 81 | } else { 82 | c.Structure = s 83 | } 84 | 85 | for _, o := range options { 86 | o(&c) 87 | } 88 | 89 | o.req = append(o.req, c) 90 | } 91 | 92 | // AddRespStructure adds response content schema. 93 | func (o *OperationContext) AddRespStructure(s interface{}, options ...openapi.ContentOption) { 94 | c := openapi.ContentUnit{} 95 | 96 | if cp, ok := s.(openapi.ContentUnitPreparer); ok { 97 | cp.SetupContentUnit(&c) 98 | } else { 99 | c.Structure = s 100 | } 101 | 102 | for _, o := range options { 103 | o(&c) 104 | } 105 | 106 | o.resp = append(o.resp, c) 107 | } 108 | -------------------------------------------------------------------------------- /marker.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | // RequestBodyEnforcer enables request body for GET and HEAD methods. 4 | // 5 | // Should be implemented on input structure, function body can be empty. 6 | // Forcing request body is not recommended and should only be used for backwards compatibility. 7 | type RequestBodyEnforcer interface { 8 | ForceRequestBody() 9 | } 10 | 11 | // RequestJSONBodyEnforcer enables JSON request body for structures with `formData` tags. 12 | // 13 | // Should be implemented on input structure, function body can be empty. 14 | type RequestJSONBodyEnforcer interface { 15 | ForceJSONRequestBody() 16 | } 17 | -------------------------------------------------------------------------------- /openapi3/doc.go: -------------------------------------------------------------------------------- 1 | // Package openapi3 provides entities and helpers to manage OpenAPI 3.0.x schema. 2 | package openapi3 3 | -------------------------------------------------------------------------------- /openapi3/entities_extra_test.go: -------------------------------------------------------------------------------- 1 | package openapi3_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/swaggest/openapi-go/openapi3" 8 | ) 9 | 10 | func TestSpec_MarshalYAML(t *testing.T) { 11 | var s openapi3.Spec 12 | 13 | spec := `openapi: 3.0.3 14 | info: 15 | description: description 16 | license: 17 | name: Apache-2.0 18 | url: https://www.apache.org/licenses/LICENSE-2.0.html 19 | title: title 20 | version: 2.0.0 21 | servers: 22 | - url: /v2 23 | paths: 24 | /user: 25 | put: 26 | summary: updates the user by id 27 | operationId: UpdateUser 28 | requestBody: 29 | content: 30 | application/json: 31 | schema: 32 | type: string 33 | description: Updated user object 34 | required: true 35 | responses: 36 | "404": 37 | description: User not found 38 | components: 39 | securitySchemes: 40 | api_key: 41 | in: header 42 | name: x-api-key 43 | type: apiKey 44 | bearer_auth: 45 | type: http 46 | scheme: bearer 47 | bearerFormat: JWT` 48 | 49 | require.NoError(t, s.UnmarshalYAML([]byte(spec))) 50 | } 51 | 52 | func TestSpec_MarshalYAML_2(t *testing.T) { 53 | var s openapi3.Spec 54 | 55 | spec := `openapi: 3.0.0 56 | info: 57 | title: MyProject 58 | description: "My Project Description" 59 | version: v1.0.0 60 | # 1) Define the security scheme type (HTTP bearer) 61 | components: 62 | securitySchemes: 63 | bearerAuth: # arbitrary name for the security scheme 64 | type: http 65 | scheme: bearer 66 | bearerFormat: JWT # optional, arbitrary value for documentation purposes 67 | # 2) Apply the security globally to all operations 68 | security: 69 | - bearerAuth: [] # use the same name as above 70 | paths: 71 | ` 72 | 73 | require.NoError(t, s.UnmarshalYAML([]byte(spec))) 74 | } 75 | 76 | func TestSpec_MarshalYAML_3(t *testing.T) { 77 | var s openapi3.Spec 78 | 79 | spec := `openapi: 3.0.3 80 | info: 81 | title: MyProject 82 | description: "My Project Description" 83 | version: v1.0.0 84 | components: 85 | securitySchemes: 86 | basicAuth: # <-- arbitrary name for the security scheme 87 | type: http 88 | scheme: basic 89 | security: 90 | - basicAuth: [] # <-- use the same name here 91 | paths: 92 | ` 93 | 94 | require.NoError(t, s.UnmarshalYAML([]byte(spec))) 95 | } 96 | -------------------------------------------------------------------------------- /openapi3/example_misc_test.go: -------------------------------------------------------------------------------- 1 | package openapi3_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "reflect" 7 | 8 | "github.com/swaggest/assertjson" 9 | "github.com/swaggest/jsonschema-go" 10 | "github.com/swaggest/openapi-go/openapi3" 11 | ) 12 | 13 | func ExampleReflector_options() { 14 | r := openapi3.Reflector{} 15 | 16 | // Reflector embeds jsonschema.Reflector and it is possible to configure optional behavior. 17 | r.Reflector.DefaultOptions = append(r.Reflector.DefaultOptions, 18 | jsonschema.InterceptNullability(func(params jsonschema.InterceptNullabilityParams) { 19 | // Removing nullability from non-pointer slices (regardless of omitempty). 20 | if params.Type.Kind() != reflect.Ptr && params.Schema.HasType(jsonschema.Null) && params.Schema.HasType(jsonschema.Array) { 21 | *params.Schema.Type = jsonschema.Array.Type() 22 | } 23 | })) 24 | 25 | type req struct { 26 | Foo []int `json:"foo"` 27 | } 28 | 29 | oc, _ := r.NewOperationContext(http.MethodPost, "/foo") 30 | oc.AddReqStructure(new(req)) 31 | 32 | _ = r.AddOperation(oc) 33 | 34 | j, _ := assertjson.MarshalIndentCompact(r.Spec, "", " ", 120) 35 | 36 | fmt.Println(string(j)) 37 | 38 | // Output: 39 | // { 40 | // "openapi":"3.0.3","info":{"title":"","version":""}, 41 | // "paths":{ 42 | // "/foo":{ 43 | // "post":{ 44 | // "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Openapi3TestReq"}}}}, 45 | // "responses":{"204":{"description":"No Content"}} 46 | // } 47 | // } 48 | // }, 49 | // "components":{"schemas":{"Openapi3TestReq":{"type":"object","properties":{"foo":{"type":"array","items":{"type":"integer"}}}}}} 50 | // } 51 | } 52 | -------------------------------------------------------------------------------- /openapi3/example_security_test.go: -------------------------------------------------------------------------------- 1 | package openapi3_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/swaggest/openapi-go" 9 | "github.com/swaggest/openapi-go/openapi3" 10 | ) 11 | 12 | func ExampleSpec_SetHTTPBasicSecurity() { 13 | reflector := openapi3.Reflector{} 14 | securityName := "admin" 15 | 16 | // Declare security scheme. 17 | reflector.SpecEns().SetHTTPBasicSecurity(securityName, "Admin Access") 18 | 19 | oc, _ := reflector.NewOperationContext(http.MethodGet, "/secure") 20 | oc.AddRespStructure(struct { 21 | Secret string `json:"secret"` 22 | }{}) 23 | 24 | // Add security requirement to operation. 25 | oc.AddSecurity(securityName) 26 | 27 | // Describe unauthorized response. 28 | oc.AddRespStructure(struct { 29 | Error string `json:"error"` 30 | }{}, func(cu *openapi.ContentUnit) { 31 | cu.HTTPStatus = http.StatusUnauthorized 32 | }) 33 | 34 | // Add operation to schema. 35 | _ = reflector.AddOperation(oc) 36 | 37 | schema, err := reflector.Spec.MarshalYAML() 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | fmt.Println(string(schema)) 43 | 44 | // Output: 45 | // openapi: 3.0.3 46 | // info: 47 | // title: "" 48 | // version: "" 49 | // paths: 50 | // /secure: 51 | // get: 52 | // responses: 53 | // "200": 54 | // content: 55 | // application/json: 56 | // schema: 57 | // properties: 58 | // secret: 59 | // type: string 60 | // type: object 61 | // description: OK 62 | // "401": 63 | // content: 64 | // application/json: 65 | // schema: 66 | // properties: 67 | // error: 68 | // type: string 69 | // type: object 70 | // description: Unauthorized 71 | // security: 72 | // - admin: [] 73 | // components: 74 | // securitySchemes: 75 | // admin: 76 | // description: Admin Access 77 | // scheme: basic 78 | // type: http 79 | } 80 | 81 | func ExampleSpec_SetAPIKeySecurity() { 82 | reflector := openapi3.Reflector{} 83 | securityName := "api_key" 84 | 85 | // Declare security scheme. 86 | reflector.SpecEns().SetAPIKeySecurity(securityName, "Authorization", openapi.InHeader, "API Access") 87 | 88 | oc, _ := reflector.NewOperationContext(http.MethodGet, "/secure") 89 | oc.AddRespStructure(struct { 90 | Secret string `json:"secret"` 91 | }{}) 92 | 93 | // Add security requirement to operation. 94 | oc.AddSecurity(securityName) 95 | 96 | // Describe unauthorized response. 97 | oc.AddRespStructure(struct { 98 | Error string `json:"error"` 99 | }{}, func(cu *openapi.ContentUnit) { 100 | cu.HTTPStatus = http.StatusUnauthorized 101 | }) 102 | 103 | // Add operation to schema. 104 | _ = reflector.AddOperation(oc) 105 | 106 | schema, err := reflector.Spec.MarshalYAML() 107 | if err != nil { 108 | log.Fatal(err) 109 | } 110 | 111 | fmt.Println(string(schema)) 112 | 113 | // Output: 114 | // openapi: 3.0.3 115 | // info: 116 | // title: "" 117 | // version: "" 118 | // paths: 119 | // /secure: 120 | // get: 121 | // responses: 122 | // "200": 123 | // content: 124 | // application/json: 125 | // schema: 126 | // properties: 127 | // secret: 128 | // type: string 129 | // type: object 130 | // description: OK 131 | // "401": 132 | // content: 133 | // application/json: 134 | // schema: 135 | // properties: 136 | // error: 137 | // type: string 138 | // type: object 139 | // description: Unauthorized 140 | // security: 141 | // - api_key: [] 142 | // components: 143 | // securitySchemes: 144 | // api_key: 145 | // description: API Access 146 | // in: header 147 | // name: Authorization 148 | // type: apiKey 149 | } 150 | 151 | func ExampleSpec_SetHTTPBearerTokenSecurity() { 152 | reflector := openapi3.Reflector{} 153 | securityName := "bearer_token" 154 | 155 | // Declare security scheme. 156 | reflector.SpecEns().SetHTTPBearerTokenSecurity(securityName, "JWT", "Admin Access") 157 | 158 | oc, _ := reflector.NewOperationContext(http.MethodGet, "/secure") 159 | oc.AddRespStructure(struct { 160 | Secret string `json:"secret"` 161 | }{}) 162 | 163 | // Add security requirement to operation. 164 | oc.AddSecurity(securityName) 165 | 166 | // Describe unauthorized response. 167 | oc.AddRespStructure(struct { 168 | Error string `json:"error"` 169 | }{}, func(cu *openapi.ContentUnit) { 170 | cu.HTTPStatus = http.StatusUnauthorized 171 | }) 172 | 173 | // Add operation to schema. 174 | _ = reflector.AddOperation(oc) 175 | 176 | schema, err := reflector.Spec.MarshalYAML() 177 | if err != nil { 178 | log.Fatal(err) 179 | } 180 | 181 | fmt.Println(string(schema)) 182 | 183 | // Output: 184 | // openapi: 3.0.3 185 | // info: 186 | // title: "" 187 | // version: "" 188 | // paths: 189 | // /secure: 190 | // get: 191 | // responses: 192 | // "200": 193 | // content: 194 | // application/json: 195 | // schema: 196 | // properties: 197 | // secret: 198 | // type: string 199 | // type: object 200 | // description: OK 201 | // "401": 202 | // content: 203 | // application/json: 204 | // schema: 205 | // properties: 206 | // error: 207 | // type: string 208 | // type: object 209 | // description: Unauthorized 210 | // security: 211 | // - bearer_token: [] 212 | // components: 213 | // securitySchemes: 214 | // bearer_token: 215 | // bearerFormat: JWT 216 | // description: Admin Access 217 | // scheme: bearer 218 | // type: http 219 | } 220 | -------------------------------------------------------------------------------- /openapi3/example_test.go: -------------------------------------------------------------------------------- 1 | package openapi3_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/swaggest/openapi-go" 10 | "github.com/swaggest/openapi-go/openapi3" 11 | ) 12 | 13 | func handleError(err error) { 14 | if err != nil { 15 | log.Fatal(err) 16 | } 17 | } 18 | 19 | func ExampleSpec_UnmarshalJSON() { 20 | yml := []byte(`{ 21 | "openapi": "3.0.0", 22 | "info": { 23 | "version": "1.0.0", 24 | "title": "Swagger Petstore", 25 | "license": { 26 | "name": "MIT" 27 | } 28 | }, 29 | "servers": [ 30 | { 31 | "url": "http://petstore.swagger.io/v1" 32 | } 33 | ], 34 | "paths": { 35 | "/pets": { 36 | "get": { 37 | "summary": "List all pets", 38 | "operationId": "listPets", 39 | "tags": [ 40 | "pets" 41 | ], 42 | "parameters": [ 43 | { 44 | "name": "limit", 45 | "in": "query", 46 | "description": "How many items to return at one time (max 100)", 47 | "required": false, 48 | "schema": { 49 | "type": "integer", 50 | "format": "int32" 51 | } 52 | } 53 | ], 54 | "responses": { 55 | "200": { 56 | "description": "A paged array of pets", 57 | "headers": { 58 | "x-next": { 59 | "description": "A link to the next page of responses", 60 | "schema": { 61 | "type": "string" 62 | } 63 | } 64 | }, 65 | "content": { 66 | "application/json": { 67 | "schema": { 68 | "$ref": "#/components/schemas/Pets" 69 | } 70 | } 71 | } 72 | }, 73 | "default": { 74 | "description": "unexpected error", 75 | "content": { 76 | "application/json": { 77 | "schema": { 78 | "$ref": "#/components/schemas/Error" 79 | } 80 | } 81 | } 82 | } 83 | } 84 | }, 85 | "post": { 86 | "summary": "Create a pet", 87 | "operationId": "createPets", 88 | "tags": [ 89 | "pets" 90 | ], 91 | "responses": { 92 | "201": { 93 | "description": "Null response" 94 | }, 95 | "default": { 96 | "description": "unexpected error", 97 | "content": { 98 | "application/json": { 99 | "schema": { 100 | "$ref": "#/components/schemas/Error" 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | }, 108 | "/pets/{petId}": { 109 | "get": { 110 | "summary": "Info for a specific pet", 111 | "operationId": "showPetById", 112 | "tags": [ 113 | "pets" 114 | ], 115 | "parameters": [ 116 | { 117 | "name": "petId", 118 | "in": "path", 119 | "required": true, 120 | "description": "The id of the pet to retrieve", 121 | "schema": { 122 | "type": "string" 123 | } 124 | } 125 | ], 126 | "responses": { 127 | "200": { 128 | "description": "Expected response to a valid request", 129 | "content": { 130 | "application/json": { 131 | "schema": { 132 | "$ref": "#/components/schemas/Pet" 133 | } 134 | } 135 | } 136 | }, 137 | "default": { 138 | "description": "unexpected error", 139 | "content": { 140 | "application/json": { 141 | "schema": { 142 | "$ref": "#/components/schemas/Error" 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | }, 151 | "components": { 152 | "schemas": { 153 | "Pet": { 154 | "type": "object", 155 | "required": [ 156 | "id", 157 | "name" 158 | ], 159 | "properties": { 160 | "id": { 161 | "type": "integer", 162 | "format": "int64" 163 | }, 164 | "name": { 165 | "type": "string" 166 | }, 167 | "tag": { 168 | "type": "string" 169 | } 170 | } 171 | }, 172 | "Pets": { 173 | "type": "array", 174 | "items": { 175 | "$ref": "#/components/schemas/Pet" 176 | } 177 | }, 178 | "Error": { 179 | "type": "object", 180 | "required": [ 181 | "code", 182 | "message" 183 | ], 184 | "properties": { 185 | "code": { 186 | "x-foo": "bar", 187 | "type": "integer", 188 | "format": "int32" 189 | }, 190 | "message": { 191 | "type": "string" 192 | } 193 | } 194 | } 195 | } 196 | } 197 | }`) 198 | 199 | var s openapi3.Spec 200 | 201 | if err := s.UnmarshalYAML(yml); err != nil { 202 | log.Fatal(err) 203 | } 204 | 205 | fmt.Println(s.Info.Title) 206 | fmt.Println(s.Components.Schemas.MapOfSchemaOrRefValues["Error"].Schema.Properties["code"].Schema.MapOfAnything["x-foo"]) 207 | 208 | // Output: 209 | // Swagger Petstore 210 | // bar 211 | } 212 | 213 | func ExampleSpec_UnmarshalYAML() { 214 | yml := []byte(` 215 | openapi: "3.0.0" 216 | info: 217 | version: 1.0.0 218 | title: Swagger Petstore 219 | license: 220 | name: MIT 221 | servers: 222 | - url: http://petstore.swagger.io/v1 223 | paths: 224 | /pets: 225 | get: 226 | summary: List all pets 227 | operationId: listPets 228 | tags: 229 | - pets 230 | parameters: 231 | - name: limit 232 | in: query 233 | description: How many items to return at one time (max 100) 234 | required: false 235 | schema: 236 | type: integer 237 | format: int32 238 | responses: 239 | '200': 240 | description: A paged array of pets 241 | headers: 242 | x-next: 243 | description: A link to the next page of responses 244 | schema: 245 | type: string 246 | content: 247 | application/json: 248 | schema: 249 | $ref: "#/components/schemas/Pets" 250 | default: 251 | description: unexpected error 252 | content: 253 | application/json: 254 | schema: 255 | $ref: "#/components/schemas/Error" 256 | post: 257 | summary: Create a pet 258 | operationId: createPets 259 | tags: 260 | - pets 261 | responses: 262 | '201': 263 | description: Null response 264 | default: 265 | description: unexpected error 266 | content: 267 | application/json: 268 | schema: 269 | $ref: "#/components/schemas/Error" 270 | /pets/{petId}: 271 | get: 272 | summary: Info for a specific pet 273 | operationId: showPetById 274 | tags: 275 | - pets 276 | parameters: 277 | - name: petId 278 | in: path 279 | required: true 280 | description: The id of the pet to retrieve 281 | schema: 282 | type: string 283 | responses: 284 | '200': 285 | description: Expected response to a valid request 286 | content: 287 | application/json: 288 | schema: 289 | $ref: "#/components/schemas/Pet" 290 | default: 291 | description: unexpected error 292 | content: 293 | application/json: 294 | schema: 295 | $ref: "#/components/schemas/Error" 296 | components: 297 | schemas: 298 | Pet: 299 | type: object 300 | required: 301 | - id 302 | - name 303 | properties: 304 | id: 305 | type: integer 306 | format: int64 307 | name: 308 | type: string 309 | tag: 310 | type: string 311 | Pets: 312 | type: array 313 | items: 314 | $ref: "#/components/schemas/Pet" 315 | Error: 316 | type: object 317 | required: 318 | - code 319 | - message 320 | properties: 321 | code: 322 | x-foo: bar 323 | type: integer 324 | format: int32 325 | message: 326 | type: string 327 | `) 328 | 329 | var s openapi3.Spec 330 | 331 | if err := s.UnmarshalYAML(yml); err != nil { 332 | log.Fatal(err) 333 | } 334 | 335 | fmt.Println(s.Info.Title) 336 | fmt.Println(s.Components.Schemas.MapOfSchemaOrRefValues["Error"].Schema.Properties["code"].Schema.MapOfAnything["x-foo"]) 337 | 338 | // Output: 339 | // Swagger Petstore 340 | // bar 341 | } 342 | 343 | func ExampleReflector_AddOperation() { 344 | reflector := openapi3.NewReflector() 345 | reflector.Spec = &openapi3.Spec{Openapi: "3.0.3"} 346 | reflector.Spec.Info. 347 | WithTitle("Things API"). 348 | WithVersion("1.2.3"). 349 | WithDescription("Put something here") 350 | 351 | type req struct { 352 | ID string `path:"id" example:"XXX-XXXXX"` 353 | Locale string `query:"locale" pattern:"^[a-z]{2}-[A-Z]{2}$"` 354 | Title string `json:"string"` 355 | Amount uint `json:"amount"` 356 | Items []struct { 357 | Count uint `json:"count"` 358 | Name string `json:"name"` 359 | } `json:"items,omitempty"` 360 | } 361 | 362 | type resp struct { 363 | ID string `json:"id" example:"XXX-XXXXX"` 364 | Amount uint `json:"amount"` 365 | Items []struct { 366 | Count uint `json:"count"` 367 | Name string `json:"name"` 368 | } `json:"items,omitempty"` 369 | UpdatedAt time.Time `json:"updated_at"` 370 | } 371 | 372 | putOp, _ := reflector.NewOperationContext(http.MethodPut, "/things/{id}") 373 | 374 | putOp.AddReqStructure(new(req)) 375 | putOp.AddRespStructure(new(resp)) 376 | putOp.AddRespStructure(new([]resp), openapi.WithHTTPStatus(http.StatusConflict)) 377 | handleError(reflector.AddOperation(putOp)) 378 | 379 | getOp, _ := reflector.NewOperationContext(http.MethodGet, "/things/{id}") 380 | getOp.AddReqStructure(new(req)) 381 | getOp.AddRespStructure(new(resp)) 382 | handleError(reflector.AddOperation(getOp)) 383 | 384 | schema, err := reflector.Spec.MarshalYAML() 385 | if err != nil { 386 | log.Fatal(err) 387 | } 388 | 389 | fmt.Println(string(schema)) 390 | 391 | // Output: 392 | // openapi: 3.0.3 393 | // info: 394 | // description: Put something here 395 | // title: Things API 396 | // version: 1.2.3 397 | // paths: 398 | // /things/{id}: 399 | // get: 400 | // parameters: 401 | // - in: query 402 | // name: locale 403 | // schema: 404 | // pattern: ^[a-z]{2}-[A-Z]{2}$ 405 | // type: string 406 | // - in: path 407 | // name: id 408 | // required: true 409 | // schema: 410 | // example: XXX-XXXXX 411 | // type: string 412 | // responses: 413 | // "200": 414 | // content: 415 | // application/json: 416 | // schema: 417 | // $ref: '#/components/schemas/Openapi3TestResp' 418 | // description: OK 419 | // put: 420 | // parameters: 421 | // - in: query 422 | // name: locale 423 | // schema: 424 | // pattern: ^[a-z]{2}-[A-Z]{2}$ 425 | // type: string 426 | // - in: path 427 | // name: id 428 | // required: true 429 | // schema: 430 | // example: XXX-XXXXX 431 | // type: string 432 | // requestBody: 433 | // content: 434 | // application/json: 435 | // schema: 436 | // $ref: '#/components/schemas/Openapi3TestReq' 437 | // responses: 438 | // "200": 439 | // content: 440 | // application/json: 441 | // schema: 442 | // $ref: '#/components/schemas/Openapi3TestResp' 443 | // description: OK 444 | // "409": 445 | // content: 446 | // application/json: 447 | // schema: 448 | // items: 449 | // $ref: '#/components/schemas/Openapi3TestResp' 450 | // type: array 451 | // description: Conflict 452 | // components: 453 | // schemas: 454 | // Openapi3TestReq: 455 | // properties: 456 | // amount: 457 | // minimum: 0 458 | // type: integer 459 | // items: 460 | // items: 461 | // properties: 462 | // count: 463 | // minimum: 0 464 | // type: integer 465 | // name: 466 | // type: string 467 | // type: object 468 | // type: array 469 | // string: 470 | // type: string 471 | // type: object 472 | // Openapi3TestResp: 473 | // properties: 474 | // amount: 475 | // minimum: 0 476 | // type: integer 477 | // id: 478 | // example: XXX-XXXXX 479 | // type: string 480 | // items: 481 | // items: 482 | // properties: 483 | // count: 484 | // minimum: 0 485 | // type: integer 486 | // name: 487 | // type: string 488 | // type: object 489 | // type: array 490 | // updated_at: 491 | // format: date-time 492 | // type: string 493 | // type: object 494 | } 495 | 496 | func ExampleReflector_AddOperation_queryObject() { 497 | reflector := openapi3.NewReflector() 498 | reflector.Spec.Info. 499 | WithTitle("Things API"). 500 | WithVersion("1.2.3"). 501 | WithDescription("Put something here") 502 | 503 | type jsonFilter struct { 504 | Foo string `json:"foo"` 505 | Bar int `json:"bar"` 506 | Deeper struct { 507 | Val string `json:"val"` 508 | } `json:"deeper"` 509 | } 510 | 511 | type deepObjectFilter struct { 512 | Baz bool `query:"baz"` 513 | Quux float64 `query:"quux"` 514 | Deeper struct { 515 | Val string `query:"val"` 516 | } `query:"deeper"` 517 | } 518 | 519 | type req struct { 520 | ID string `path:"id" example:"XXX-XXXXX"` 521 | Locale string `query:"locale" pattern:"^[a-z]{2}-[A-Z]{2}$"` 522 | // Object values can be serialized in JSON (with json field tags in the value struct). 523 | JSONFilter jsonFilter `query:"json_filter"` 524 | // Or as deepObject (with same field tag as parent, .e.g query). 525 | DeepObjectFilter deepObjectFilter `query:"deep_object_filter"` 526 | } 527 | 528 | getOp, _ := reflector.NewOperationContext(http.MethodGet, "/things/{id}") 529 | 530 | getOp.AddReqStructure(new(req)) 531 | _ = reflector.AddOperation(getOp) 532 | 533 | schema, err := reflector.Spec.MarshalYAML() 534 | if err != nil { 535 | log.Fatal(err) 536 | } 537 | 538 | fmt.Println(string(schema)) 539 | 540 | // Output: 541 | // openapi: 3.0.3 542 | // info: 543 | // description: Put something here 544 | // title: Things API 545 | // version: 1.2.3 546 | // paths: 547 | // /things/{id}: 548 | // get: 549 | // parameters: 550 | // - in: query 551 | // name: locale 552 | // schema: 553 | // pattern: ^[a-z]{2}-[A-Z]{2}$ 554 | // type: string 555 | // - content: 556 | // application/json: 557 | // schema: 558 | // $ref: '#/components/schemas/Openapi3TestJsonFilter' 559 | // in: query 560 | // name: json_filter 561 | // - explode: true 562 | // in: query 563 | // name: deep_object_filter 564 | // schema: 565 | // $ref: '#/components/schemas/Openapi3TestDeepObjectFilter' 566 | // style: deepObject 567 | // - in: path 568 | // name: id 569 | // required: true 570 | // schema: 571 | // example: XXX-XXXXX 572 | // type: string 573 | // responses: 574 | // "204": 575 | // description: No Content 576 | // components: 577 | // schemas: 578 | // Openapi3TestDeepObjectFilter: 579 | // properties: 580 | // baz: 581 | // type: boolean 582 | // deeper: 583 | // properties: 584 | // val: 585 | // type: string 586 | // type: object 587 | // quux: 588 | // type: number 589 | // type: object 590 | // Openapi3TestJsonFilter: 591 | // properties: 592 | // bar: 593 | // type: integer 594 | // deeper: 595 | // properties: 596 | // val: 597 | // type: string 598 | // type: object 599 | // foo: 600 | // type: string 601 | // type: object 602 | } 603 | -------------------------------------------------------------------------------- /openapi3/helper.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/swaggest/openapi-go" 11 | ) 12 | 13 | // ToParameterOrRef exposes Parameter in general form. 14 | func (p Parameter) ToParameterOrRef() ParameterOrRef { 15 | return ParameterOrRef{ 16 | Parameter: &p, 17 | } 18 | } 19 | 20 | // WithOperation sets Operation to PathItem. 21 | // 22 | // Deprecated: use Spec.AddOperation. 23 | func (p *PathItem) WithOperation(method string, operation Operation) *PathItem { 24 | return p.WithMapOfOperationValuesItem(strings.ToLower(method), operation) 25 | } 26 | 27 | func (o *Operation) validatePathParams(pathParams map[string]bool) error { 28 | paramIndex := make(map[string]bool, len(o.Parameters)) 29 | 30 | var errs []string 31 | 32 | for _, p := range o.Parameters { 33 | if p.Parameter == nil { 34 | continue 35 | } 36 | 37 | if found := paramIndex[p.Parameter.Name+string(p.Parameter.In)]; found { 38 | errs = append(errs, "duplicate parameter in "+string(p.Parameter.In)+": "+p.Parameter.Name) 39 | 40 | continue 41 | } 42 | 43 | if found := pathParams[p.Parameter.Name]; !found && p.Parameter.In == ParameterInPath { 44 | errs = append(errs, "missing path parameter placeholder in url: "+p.Parameter.Name) 45 | 46 | continue 47 | } 48 | 49 | paramIndex[p.Parameter.Name+string(p.Parameter.In)] = true 50 | } 51 | 52 | for pathParam := range pathParams { 53 | if !paramIndex[pathParam+string(ParameterInPath)] { 54 | errs = append(errs, "undefined path parameter: "+pathParam) 55 | } 56 | } 57 | 58 | if len(errs) > 0 { 59 | return errors.New(strings.Join(errs, ", ")) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // SetupOperation creates operation if it is not present and applies setup functions. 66 | func (s *Spec) SetupOperation(method, path string, setup ...func(*Operation) error) error { 67 | method, path, pathParams, err := openapi.SanitizeMethodPath(method, path) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | pathItem := s.Paths.MapOfPathItemValues[path] 73 | operation := pathItem.MapOfOperationValues[method] 74 | 75 | for _, f := range setup { 76 | if err := f(&operation); err != nil { 77 | return err 78 | } 79 | } 80 | 81 | pathParamsMap := make(map[string]bool, len(pathParams)) 82 | for _, p := range pathParams { 83 | pathParamsMap[p] = true 84 | } 85 | 86 | if err := operation.validatePathParams(pathParamsMap); err != nil { 87 | return err 88 | } 89 | 90 | pathItem.WithMapOfOperationValuesItem(method, operation) 91 | 92 | s.Paths.WithMapOfPathItemValuesItem(path, pathItem) 93 | 94 | return nil 95 | } 96 | 97 | // AddOperation validates and sets operation by path and method. 98 | // 99 | // It will fail if operation with method and path already exists. 100 | func (s *Spec) AddOperation(method, path string, operation Operation) error { 101 | method = strings.ToLower(method) 102 | pathItem := s.Paths.MapOfPathItemValues[path] 103 | 104 | if _, found := pathItem.MapOfOperationValues[method]; found { 105 | return fmt.Errorf("operation already exists: %s %s", method, path) 106 | } 107 | 108 | // Add "No Content" response if there are no responses configured. 109 | if len(operation.Responses.MapOfResponseOrRefValues) == 0 && operation.Responses.Default == nil { 110 | operation.Responses.WithMapOfResponseOrRefValuesItem(strconv.Itoa(http.StatusNoContent), ResponseOrRef{ 111 | Response: &Response{ 112 | Description: http.StatusText(http.StatusNoContent), 113 | }, 114 | }) 115 | } 116 | 117 | return s.SetupOperation(method, path, func(op *Operation) error { 118 | *op = operation 119 | 120 | return nil 121 | }) 122 | } 123 | 124 | // UnknownParamIsForbidden indicates forbidden unknown parameters. 125 | func (o Operation) UnknownParamIsForbidden(in ParameterIn) bool { 126 | f, ok := o.MapOfAnything[xForbidUnknown+string(in)].(bool) 127 | 128 | return f && ok 129 | } 130 | 131 | var _ openapi.SpecSchema = &Spec{} 132 | 133 | // Title returns service title. 134 | func (s *Spec) Title() string { 135 | return s.Info.Title 136 | } 137 | 138 | // SetTitle describes the service. 139 | func (s *Spec) SetTitle(t string) { 140 | s.Info.Title = t 141 | } 142 | 143 | // Description returns service description. 144 | func (s *Spec) Description() string { 145 | if s.Info.Description != nil { 146 | return *s.Info.Description 147 | } 148 | 149 | return "" 150 | } 151 | 152 | // SetDescription describes the service. 153 | func (s *Spec) SetDescription(d string) { 154 | s.Info.WithDescription(d) 155 | } 156 | 157 | // Version returns service version. 158 | func (s *Spec) Version() string { 159 | return s.Info.Version 160 | } 161 | 162 | // SetVersion describes the service. 163 | func (s *Spec) SetVersion(v string) { 164 | s.Info.Version = v 165 | } 166 | 167 | // SetHTTPBasicSecurity sets security definition. 168 | func (s *Spec) SetHTTPBasicSecurity(securityName string, description string) { 169 | s.ComponentsEns().SecuritySchemesEns().WithMapOfSecuritySchemeOrRefValuesItem( 170 | securityName, 171 | SecuritySchemeOrRef{ 172 | SecurityScheme: &SecurityScheme{ 173 | HTTPSecurityScheme: (&HTTPSecurityScheme{}).WithScheme("basic").WithDescription(description), 174 | }, 175 | }, 176 | ) 177 | } 178 | 179 | // SetAPIKeySecurity sets security definition. 180 | func (s *Spec) SetAPIKeySecurity(securityName string, fieldName string, fieldIn openapi.In, description string) { 181 | s.ComponentsEns().SecuritySchemesEns().WithMapOfSecuritySchemeOrRefValuesItem( 182 | securityName, 183 | SecuritySchemeOrRef{ 184 | SecurityScheme: &SecurityScheme{ 185 | APIKeySecurityScheme: (&APIKeySecurityScheme{}). 186 | WithName(fieldName). 187 | WithIn(APIKeySecuritySchemeIn(fieldIn)). 188 | WithDescription(description), 189 | }, 190 | }, 191 | ) 192 | } 193 | 194 | // SetHTTPBearerTokenSecurity sets security definition. 195 | func (s *Spec) SetHTTPBearerTokenSecurity(securityName string, format string, description string) { 196 | ss := &SecurityScheme{ 197 | HTTPSecurityScheme: (&HTTPSecurityScheme{}). 198 | WithScheme("bearer"). 199 | WithDescription(description), 200 | } 201 | 202 | if format != "" { 203 | ss.HTTPSecurityScheme.WithBearerFormat(format) 204 | } 205 | 206 | s.ComponentsEns().SecuritySchemesEns().WithMapOfSecuritySchemeOrRefValuesItem( 207 | securityName, 208 | SecuritySchemeOrRef{ 209 | SecurityScheme: ss, 210 | }, 211 | ) 212 | } 213 | 214 | // SetReference sets a reference and discards existing content. 215 | func (r *ResponseOrRef) SetReference(ref string) { 216 | r.ResponseReferenceEns().Ref = ref 217 | r.Response = nil 218 | } 219 | 220 | // SetReference sets a reference and discards existing content. 221 | func (r *RequestBodyOrRef) SetReference(ref string) { 222 | r.RequestBodyReferenceEns().Ref = ref 223 | r.RequestBody = nil 224 | } 225 | -------------------------------------------------------------------------------- /openapi3/helper_test.go: -------------------------------------------------------------------------------- 1 | package openapi3_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/swaggest/assertjson" 9 | "github.com/swaggest/openapi-go/openapi3" 10 | ) 11 | 12 | func TestSpec_SetOperation(t *testing.T) { 13 | s := openapi3.Spec{} 14 | op := openapi3.Operation{} 15 | 16 | op.WithParameters( 17 | openapi3.Parameter{In: openapi3.ParameterInPath, Name: "foo"}.ToParameterOrRef(), 18 | ) 19 | 20 | require.EqualError(t, s.AddOperation("bar", "/", op), 21 | "unexpected http method: bar") 22 | 23 | require.EqualError(t, s.AddOperation(http.MethodGet, "/", op), 24 | "missing path parameter placeholder in url: foo") 25 | 26 | require.EqualError(t, s.AddOperation(http.MethodGet, "/{bar}", op), 27 | "missing path parameter placeholder in url: foo, undefined path parameter: bar") 28 | 29 | require.NoError(t, s.AddOperation(http.MethodGet, "/{foo}", op)) 30 | 31 | require.EqualError(t, s.AddOperation(http.MethodGet, "/{foo}", op), 32 | "operation already exists: get /{foo}") 33 | 34 | op.WithParameters( 35 | openapi3.Parameter{In: openapi3.ParameterInPath, Name: "foo"}.ToParameterOrRef(), 36 | openapi3.Parameter{In: openapi3.ParameterInPath, Name: "foo"}.ToParameterOrRef(), 37 | openapi3.Parameter{In: openapi3.ParameterInQuery, Name: "bar"}.ToParameterOrRef(), 38 | openapi3.Parameter{In: openapi3.ParameterInQuery, Name: "bar"}.ToParameterOrRef(), 39 | ) 40 | 41 | require.EqualError(t, s.AddOperation(http.MethodGet, "/another/{foo}", op), 42 | "duplicate parameter in path: foo, duplicate parameter in query: bar") 43 | } 44 | 45 | func TestSpec_SetupOperation_pathRegex(t *testing.T) { 46 | s := openapi3.Spec{} 47 | 48 | for _, tc := range []struct { 49 | path string 50 | params []string 51 | }{ 52 | {`/{month}-{day}-{year}`, []string{"month", "day", "year"}}, 53 | {`/{month}/{day}/{year}`, []string{"month", "day", "year"}}, 54 | {`/{month:[\d]+}-{day:[\d]+}-{year:[\d]+}`, []string{"month", "day", "year"}}, 55 | {`/{articleSlug:[a-z-]+}`, []string{"articleSlug"}}, 56 | {"/articles/{rid:^[0-9]{5,6}}", []string{"rid"}}, 57 | {"/articles/{rid:^[0-9]{5,6}}/{zid:^[0-9]{5,6}}", []string{"rid", "zid"}}, 58 | {"/articles/{zid:^0[0-9]+}", []string{"zid"}}, 59 | {"/articles/{name:^@[a-z]+}/posts", []string{"name"}}, 60 | {"/articles/{op:^[0-9]+}/run", []string{"op"}}, 61 | {"/users/{userID:[^/]+}", []string{"userID"}}, 62 | {"/users/{userID:[^/]+}/books/{bookID:.+}", []string{"userID", "bookID"}}, 63 | } { 64 | t.Run(tc.path, func(t *testing.T) { 65 | require.NoError(t, s.SetupOperation(http.MethodGet, tc.path, 66 | func(operation *openapi3.Operation) error { 67 | var pp []openapi3.ParameterOrRef 68 | 69 | for _, p := range tc.params { 70 | pp = append(pp, openapi3.Parameter{In: openapi3.ParameterInPath, Name: p}.ToParameterOrRef()) 71 | } 72 | 73 | operation.WithParameters(pp...) 74 | 75 | return nil 76 | }, 77 | )) 78 | }) 79 | } 80 | } 81 | 82 | func TestSpec_SetupOperation_uncleanPath(t *testing.T) { 83 | s := openapi3.Spec{} 84 | f := func(operation *openapi3.Operation) error { 85 | operation.WithParameters(openapi3.Parameter{In: openapi3.ParameterInPath, Name: "userID"}.ToParameterOrRef()) 86 | 87 | return nil 88 | } 89 | 90 | require.NoError(t, s.SetupOperation(http.MethodGet, "/users/{userID:[^/]+}", f)) 91 | require.NoError(t, s.SetupOperation(http.MethodPost, "/users/{userID:[^/]+}", f)) 92 | 93 | assertjson.EqualMarshal(t, []byte(`{ 94 | "openapi":"","info":{"title":"","version":""}, 95 | "paths":{ 96 | "/users/{userID}":{ 97 | "get":{"parameters":[{"name":"userID","in":"path"}],"responses":{}}, 98 | "post":{"parameters":[{"name":"userID","in":"path"}],"responses":{}} 99 | } 100 | } 101 | }`), s) 102 | } 103 | -------------------------------------------------------------------------------- /openapi3/jsonschema.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/swaggest/jsonschema-go" 7 | ) 8 | 9 | type toJSONSchemaContext struct { 10 | refsProcessed map[string]jsonschema.SchemaOrBool 11 | refsCount map[string]int 12 | spec *Spec 13 | } 14 | 15 | // ToJSONSchema converts OpenAPI Schema to JSON Schema. 16 | // 17 | // Local references are resolved against `#/components/schemas` in spec. 18 | func (s *SchemaOrRef) ToJSONSchema(spec *Spec) jsonschema.SchemaOrBool { 19 | ctx := toJSONSchemaContext{ 20 | refsProcessed: map[string]jsonschema.SchemaOrBool{}, 21 | refsCount: map[string]int{}, 22 | spec: spec, 23 | } 24 | js := s.toJSONSchema(ctx) 25 | 26 | // Inline root reference without recursions. 27 | if s.SchemaReference != nil { 28 | dstName := strings.TrimPrefix(s.SchemaReference.Ref, componentsSchemas) 29 | if ctx.refsCount[dstName] == 1 { 30 | js = ctx.refsProcessed[dstName] 31 | delete(ctx.refsProcessed, dstName) 32 | } 33 | } 34 | 35 | if len(ctx.refsProcessed) > 0 { 36 | js.TypeObjectEns().WithExtraPropertiesItem( 37 | "components", 38 | map[string]interface{}{"schemas": ctx.refsProcessed}, 39 | ) 40 | } 41 | 42 | return js 43 | } 44 | 45 | func (s *SchemaOrRef) toJSONSchema(ctx toJSONSchemaContext) jsonschema.SchemaOrBool { 46 | js := jsonschema.SchemaOrBool{} 47 | jso := js.TypeObjectEns() 48 | 49 | if s.SchemaReference != nil { 50 | jso.WithRef(s.SchemaReference.Ref) 51 | 52 | if strings.HasPrefix(s.SchemaReference.Ref, componentsSchemas) { 53 | dstName := strings.TrimPrefix(s.SchemaReference.Ref, componentsSchemas) 54 | 55 | if _, alreadyProcessed := ctx.refsProcessed[dstName]; !alreadyProcessed { 56 | ctx.refsProcessed[dstName] = jsonschema.SchemaOrBool{} 57 | 58 | dst := ctx.spec.Components.Schemas.MapOfSchemaOrRefValues[dstName] 59 | ctx.refsProcessed[dstName] = dst.toJSONSchema(ctx) 60 | } 61 | 62 | ctx.refsCount[dstName]++ 63 | } 64 | 65 | return js 66 | } 67 | 68 | if s.Schema == nil { 69 | return js 70 | } 71 | 72 | ss := s.Schema 73 | 74 | jso.ReflectType = ss.ReflectType 75 | jso.Description = ss.Description 76 | jso.Title = ss.Title 77 | 78 | if ss.Type != nil { 79 | jso.AddType(jsonschema.SimpleType(*ss.Type)) 80 | } 81 | 82 | if ss.Nullable != nil && *ss.Nullable { 83 | jso.AddType(jsonschema.Null) 84 | } 85 | 86 | jso.MultipleOf = ss.MultipleOf 87 | if ss.ExclusiveMaximum != nil && *ss.ExclusiveMaximum { 88 | jso.ExclusiveMaximum = ss.Maximum 89 | } else { 90 | jso.Maximum = ss.Maximum 91 | } 92 | 93 | if ss.ExclusiveMinimum != nil && *ss.ExclusiveMinimum { 94 | jso.ExclusiveMinimum = ss.Minimum 95 | } else { 96 | jso.Minimum = ss.Minimum 97 | } 98 | 99 | jso.MaxLength = ss.MaxLength 100 | 101 | if ss.MinLength != nil { 102 | jso.MinLength = *ss.MinLength 103 | } 104 | 105 | jso.Pattern = ss.Pattern 106 | jso.MaxItems = ss.MaxItems 107 | 108 | if ss.MinItems != nil { 109 | jso.MinItems = *ss.MinItems 110 | } 111 | 112 | jso.UniqueItems = ss.UniqueItems 113 | jso.MaxProperties = ss.MaxProperties 114 | 115 | if ss.MinProperties != nil { 116 | jso.MinProperties = *ss.MinProperties 117 | } 118 | 119 | jso.Required = ss.Required 120 | jso.Enum = ss.Enum 121 | 122 | if ss.Not != nil { 123 | jso.WithNot(ss.Not.toJSONSchema(ctx)) 124 | } 125 | 126 | if len(ss.AllOf) != 0 { 127 | for _, allOf := range ss.AllOf { 128 | jso.AllOf = append(jso.AllOf, allOf.toJSONSchema(ctx)) 129 | } 130 | } 131 | 132 | if len(ss.OneOf) != 0 { 133 | for _, oneOf := range ss.OneOf { 134 | jso.OneOf = append(jso.OneOf, oneOf.toJSONSchema(ctx)) 135 | } 136 | } 137 | 138 | if len(ss.AnyOf) != 0 { 139 | for _, anyOf := range ss.AnyOf { 140 | jso.AnyOf = append(jso.AnyOf, anyOf.toJSONSchema(ctx)) 141 | } 142 | } 143 | 144 | if ss.Items != nil { 145 | jso.ItemsEns().WithSchemaOrBool(ss.Items.toJSONSchema(ctx)) 146 | } 147 | 148 | if ss.Properties != nil { 149 | for propName, propSchema := range ss.Properties { 150 | jso.WithPropertiesItem(propName, propSchema.toJSONSchema(ctx)) 151 | } 152 | } 153 | 154 | if ss.AdditionalProperties != nil { 155 | if ss.AdditionalProperties.Bool != nil { 156 | jso.AdditionalProperties = &jsonschema.SchemaOrBool{ 157 | TypeBoolean: ss.AdditionalProperties.Bool, 158 | } 159 | } else if ss.AdditionalProperties.SchemaOrRef != nil { 160 | jso.WithAdditionalProperties(ss.AdditionalProperties.SchemaOrRef.toJSONSchema(ctx)) 161 | } 162 | } 163 | 164 | jso.Format = ss.Format 165 | jso.Default = ss.Default 166 | jso.ReadOnly = ss.ReadOnly 167 | jso.WriteOnly = ss.WriteOnly 168 | jso.Deprecated = ss.Deprecated 169 | 170 | if ss.Example != nil { 171 | jso.WithExamples(*ss.Example) 172 | } 173 | 174 | for k, v := range ss.MapOfAnything { 175 | jso.WithExtraPropertiesItem(k, v) 176 | } 177 | 178 | return js 179 | } 180 | 181 | // FromJSONSchema loads OpenAPI Schema from JSON Schema. 182 | func (s *SchemaOrRef) FromJSONSchema(schema jsonschema.SchemaOrBool) { 183 | if schema.TypeBoolean != nil { 184 | s.fromBool(*schema.TypeBoolean) 185 | 186 | return 187 | } 188 | 189 | js := schema.TypeObject 190 | if js.Ref != nil { 191 | if js.Deprecated != nil && *js.Deprecated { 192 | s.Schema = (&Schema{}).WithAllOf( 193 | SchemaOrRef{ 194 | Schema: (&Schema{}).WithDeprecated(true), 195 | }, 196 | SchemaOrRef{ 197 | SchemaReference: &SchemaReference{Ref: *js.Ref}, 198 | }, 199 | ) 200 | 201 | return 202 | } 203 | 204 | s.SchemaReference = &SchemaReference{Ref: *js.Ref} 205 | 206 | return 207 | } 208 | 209 | if s.Schema == nil { 210 | s.Schema = &Schema{} 211 | } 212 | 213 | s.Schema.ReflectType = js.ReflectType 214 | os := s.Schema 215 | 216 | if js.Not != nil { 217 | os.Not = &SchemaOrRef{} 218 | os.Not.FromJSONSchema(*js.Not) 219 | } 220 | 221 | fromSchemaArray(&os.OneOf, js.OneOf) 222 | fromSchemaArray(&os.AnyOf, js.AnyOf) 223 | fromSchemaArray(&os.AllOf, js.AllOf) 224 | 225 | os.Title = js.Title 226 | os.Description = js.Description 227 | os.Required = js.Required 228 | os.Default = js.Default 229 | os.Enum = js.Enum 230 | 231 | if len(js.Examples) > 0 { 232 | os.Example = &js.Examples[0] 233 | } 234 | 235 | os.Deprecated = js.Deprecated 236 | 237 | if js.Type != nil { 238 | if js.Type.SimpleTypes != nil { 239 | checkNullable(*js.Type.SimpleTypes, os) 240 | } else if len(js.Type.SliceOfSimpleTypeValues) > 0 { 241 | for _, t := range js.Type.SliceOfSimpleTypeValues { 242 | checkNullable(t, os) 243 | } 244 | } 245 | } 246 | 247 | if js.AdditionalProperties != nil { 248 | os.AdditionalProperties = &SchemaAdditionalProperties{} 249 | if js.AdditionalProperties.TypeBoolean != nil { 250 | os.AdditionalProperties.Bool = js.AdditionalProperties.TypeBoolean 251 | } else { 252 | ap := SchemaOrRef{} 253 | ap.FromJSONSchema(*js.AdditionalProperties) 254 | os.AdditionalProperties.SchemaOrRef = &ap 255 | } 256 | } 257 | 258 | if js.Items != nil && js.Items.SchemaOrBool != nil { 259 | os.Items = &SchemaOrRef{} 260 | os.Items.FromJSONSchema(*js.Items.SchemaOrBool) 261 | } 262 | 263 | if js.ExclusiveMaximum != nil { 264 | os.WithExclusiveMaximum(true) 265 | os.Maximum = js.Maximum 266 | } else if js.Maximum != nil { 267 | os.Maximum = js.Maximum 268 | } 269 | 270 | if js.ExclusiveMinimum != nil { 271 | os.WithExclusiveMinimum(true) 272 | os.Minimum = js.Minimum 273 | } else if js.Minimum != nil { 274 | os.Minimum = js.Minimum 275 | } 276 | 277 | os.Format = js.Format 278 | 279 | if js.MinItems != 0 { 280 | os.MinItems = &js.MinItems 281 | } 282 | 283 | os.MaxItems = js.MaxItems 284 | 285 | if js.MinLength != 0 { 286 | os.MinLength = &js.MinLength 287 | } 288 | 289 | os.MaxLength = js.MaxLength 290 | 291 | if js.MinProperties != 0 { 292 | os.MinProperties = &js.MinProperties 293 | } 294 | 295 | os.MaxProperties = js.MaxProperties 296 | 297 | os.MultipleOf = js.MultipleOf 298 | 299 | os.Pattern = js.Pattern 300 | 301 | if len(js.Properties) > 0 { 302 | os.Properties = make(map[string]SchemaOrRef, len(js.Properties)) 303 | 304 | for name, jsp := range js.Properties { 305 | osp := SchemaOrRef{} 306 | osp.FromJSONSchema(jsp) 307 | os.Properties[name] = osp 308 | } 309 | } 310 | 311 | os.ReadOnly = js.ReadOnly 312 | os.WriteOnly = js.WriteOnly 313 | os.UniqueItems = js.UniqueItems 314 | 315 | for name, val := range js.ExtraProperties { 316 | if strings.HasPrefix(name, "x-") { 317 | if os.MapOfAnything == nil { 318 | os.MapOfAnything = map[string]interface{}{ 319 | name: val, 320 | } 321 | } else { 322 | os.MapOfAnything[name] = val 323 | } 324 | } 325 | } 326 | } 327 | 328 | func checkNullable(t jsonschema.SimpleType, os *Schema) { 329 | if t == jsonschema.Null { 330 | os.WithNullable(true) 331 | } else { 332 | os.WithType(SchemaType(t)) 333 | } 334 | } 335 | 336 | func fromSchemaArray(os *[]SchemaOrRef, js []jsonschema.SchemaOrBool) { 337 | if len(js) == 0 { 338 | return 339 | } 340 | 341 | osa := make([]SchemaOrRef, len(js)) 342 | 343 | for i, jso := range js { 344 | oso := SchemaOrRef{} 345 | oso.FromJSONSchema(jso) 346 | osa[i] = oso 347 | } 348 | 349 | *os = osa 350 | } 351 | 352 | func (s *SchemaOrRef) fromBool(val bool) { 353 | if s.Schema == nil { 354 | s.Schema = &Schema{} 355 | } 356 | 357 | if val { 358 | return 359 | } 360 | 361 | s.Schema.Not = &SchemaOrRef{ 362 | Schema: &Schema{}, 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /openapi3/reflect_deprecated.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "github.com/swaggest/jsonschema-go" 5 | "github.com/swaggest/openapi-go" 6 | ) 7 | 8 | // SetupRequest sets up operation parameters. 9 | // 10 | // Deprecated: instrument openapi.OperationContext and use AddOperation. 11 | func (r *Reflector) SetupRequest(c OperationContext) error { 12 | return r.setupRequest(c.Operation, toOpCtx(c)) 13 | } 14 | 15 | // SetRequest sets up operation parameters. 16 | // 17 | // Deprecated: instrument openapi.OperationContext and use AddOperation. 18 | func (r *Reflector) SetRequest(o *Operation, input interface{}, httpMethod string) error { 19 | return r.SetupRequest(OperationContext{ 20 | Operation: o, 21 | Input: input, 22 | HTTPMethod: httpMethod, 23 | }) 24 | } 25 | 26 | // RequestBodyEnforcer enables request body for GET and HEAD methods. 27 | // 28 | // Should be implemented on input structure, function body can be empty. 29 | // Forcing request body is not recommended and should only be used for backwards compatibility. 30 | // 31 | // Deprecated: use openapi.RequestBodyEnforcer. 32 | type RequestBodyEnforcer interface { 33 | ForceRequestBody() 34 | } 35 | 36 | // RequestJSONBodyEnforcer enables JSON request body for structures with `formData` tags. 37 | // 38 | // Should be implemented on input structure, function body can be empty. 39 | // 40 | // Deprecated: use openapi.RequestJSONBodyEnforcer. 41 | type RequestJSONBodyEnforcer interface { 42 | ForceJSONRequestBody() 43 | } 44 | 45 | // SetStringResponse sets unstructured response. 46 | // 47 | // Deprecated: use AddOperation with openapi.OperationContext AddRespStructure. 48 | func (r *Reflector) SetStringResponse(o *Operation, httpStatus int, contentType string) error { 49 | return r.SetupResponse(OperationContext{ 50 | Operation: o, 51 | HTTPStatus: httpStatus, 52 | RespContentType: contentType, 53 | }) 54 | } 55 | 56 | // SetJSONResponse sets up operation JSON response. 57 | // 58 | // Deprecated: use AddOperation with openapi.OperationContext AddRespStructure. 59 | func (r *Reflector) SetJSONResponse(o *Operation, output interface{}, httpStatus int) error { 60 | return r.SetupResponse(OperationContext{ 61 | Operation: o, 62 | Output: output, 63 | HTTPStatus: httpStatus, 64 | }) 65 | } 66 | 67 | // SetupResponse sets up operation response. 68 | // 69 | // Deprecated: use AddOperation with openapi.OperationContext AddRespStructure. 70 | func (r *Reflector) SetupResponse(oc OperationContext) error { 71 | return r.setupResponse(oc.Operation, toOpCtx(oc)) 72 | } 73 | 74 | // OperationCtx retrieves operation context from reflect context. 75 | // 76 | // Deprecated: use openapi.OperationCtx. 77 | func OperationCtx(rc *jsonschema.ReflectContext) (OperationContext, bool) { 78 | if oc, ok := openapi.OperationCtx(rc); ok { 79 | return fromOpCtx(oc), true 80 | } 81 | 82 | return OperationContext{}, false 83 | } 84 | 85 | // OperationContext describes operation. 86 | // 87 | // Deprecated: use Reflector.NewOperationContext, exported access might be revoked in the future. 88 | type OperationContext struct { 89 | Operation *Operation 90 | Input interface{} 91 | HTTPMethod string 92 | 93 | ReqQueryMapping map[string]string 94 | ReqPathMapping map[string]string 95 | ReqCookieMapping map[string]string 96 | ReqHeaderMapping map[string]string 97 | ReqFormDataMapping map[string]string 98 | 99 | Output interface{} 100 | HTTPStatus int 101 | RespContentType string 102 | RespHeaderMapping map[string]string 103 | 104 | ProcessingResponse bool 105 | ProcessingIn string 106 | } 107 | -------------------------------------------------------------------------------- /openapi3/reflect_go1.18_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package openapi3_test 5 | 6 | import ( 7 | "net/http" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | "github.com/swaggest/assertjson" 13 | "github.com/swaggest/openapi-go" 14 | "github.com/swaggest/openapi-go/openapi3" 15 | ) 16 | 17 | func Test_Foo(t *testing.T) { 18 | reflector := openapi3.Reflector{} 19 | reflector.Spec = &openapi3.Spec{Openapi: "3.0.3"} 20 | reflector.Spec.Info. 21 | WithTitle("Things API"). 22 | WithVersion("1.2.3"). 23 | WithDescription("Put something here") 24 | 25 | type req[T any] struct { 26 | ID string `path:"id" example:"XXX-XXXXX"` 27 | Locale string `query:"locale" pattern:"^[a-z]{2}-[A-Z]{2}$"` 28 | Title string `json:"string"` 29 | Amount uint `json:"amount"` 30 | Items []struct { 31 | Count uint `json:"count"` 32 | Name string `json:"name"` 33 | } `json:"items"` 34 | } 35 | 36 | type resp[T any] struct { 37 | ID string `json:"id" example:"XXX-XXXXX"` 38 | Amount uint `json:"amount"` 39 | Items []struct { 40 | Count uint `json:"count"` 41 | Name string `json:"name"` 42 | } `json:"items"` 43 | UpdatedAt time.Time `json:"updated_at"` 44 | } 45 | 46 | putOC, err := reflector.NewOperationContext(http.MethodPut, "/things/{id}") 47 | require.NoError(t, err) 48 | putOC.AddReqStructure(new(req[time.Time])) 49 | putOC.AddRespStructure(new(resp[time.Time]), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusOK }) 50 | putOC.AddRespStructure(new([]resp[time.Time]), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusConflict }) 51 | require.NoError(t, reflector.AddOperation(putOC)) 52 | 53 | getOC, err := reflector.NewOperationContext(http.MethodGet, "/things/{id}") 54 | require.NoError(t, err) 55 | getOC.AddReqStructure(new(req[time.Time])) 56 | getOC.AddRespStructure(new(resp[time.Time]), func(cu *openapi.ContentUnit) { cu.HTTPStatus = http.StatusOK }) 57 | require.NoError(t, reflector.AddOperation(getOC)) 58 | 59 | assertjson.EqMarshal(t, `{ 60 | "openapi":"3.0.3", 61 | "info":{"title":"Things API","description":"Put something here","version":"1.2.3"}, 62 | "paths":{ 63 | "/things/{id}":{ 64 | "get":{ 65 | "parameters":[ 66 | { 67 | "name":"locale","in":"query", 68 | "schema":{"pattern":"^[a-z]{2}-[A-Z]{2}$","type":"string"} 69 | }, 70 | { 71 | "name":"id","in":"path","required":true, 72 | "schema":{"type":"string","example":"XXX-XXXXX"} 73 | } 74 | ], 75 | "responses":{ 76 | "200":{ 77 | "description":"OK", 78 | "content":{ 79 | "application/json":{ 80 | "schema":{"$ref":"#/components/schemas/Openapi3TestRespTimeTime"} 81 | } 82 | } 83 | } 84 | } 85 | }, 86 | "put":{ 87 | "parameters":[ 88 | { 89 | "name":"locale","in":"query", 90 | "schema":{"pattern":"^[a-z]{2}-[A-Z]{2}$","type":"string"} 91 | }, 92 | { 93 | "name":"id","in":"path","required":true, 94 | "schema":{"type":"string","example":"XXX-XXXXX"} 95 | } 96 | ], 97 | "requestBody":{ 98 | "content":{ 99 | "application/json":{"schema":{"$ref":"#/components/schemas/Openapi3TestReqTimeTime"}} 100 | } 101 | }, 102 | "responses":{ 103 | "200":{ 104 | "description":"OK", 105 | "content":{ 106 | "application/json":{ 107 | "schema":{"$ref":"#/components/schemas/Openapi3TestRespTimeTime"} 108 | } 109 | } 110 | }, 111 | "409":{ 112 | "description":"Conflict", 113 | "content":{ 114 | "application/json":{ 115 | "schema":{ 116 | "type":"array", 117 | "items":{"$ref":"#/components/schemas/Openapi3TestRespTimeTime"} 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } 124 | } 125 | }, 126 | "components":{ 127 | "schemas":{ 128 | "Openapi3TestReqTimeTime":{ 129 | "type":"object", 130 | "properties":{ 131 | "amount":{"minimum":0,"type":"integer"}, 132 | "items":{ 133 | "type":"array", 134 | "items":{ 135 | "type":"object", 136 | "properties":{ 137 | "count":{"minimum":0,"type":"integer"},"name":{"type":"string"} 138 | } 139 | }, 140 | "nullable":true 141 | }, 142 | "string":{"type":"string"} 143 | } 144 | }, 145 | "Openapi3TestRespTimeTime":{ 146 | "type":"object", 147 | "properties":{ 148 | "amount":{"minimum":0,"type":"integer"}, 149 | "id":{"type":"string","example":"XXX-XXXXX"}, 150 | "items":{ 151 | "type":"array", 152 | "items":{ 153 | "type":"object", 154 | "properties":{ 155 | "count":{"minimum":0,"type":"integer"},"name":{"type":"string"} 156 | } 157 | }, 158 | "nullable":true 159 | }, 160 | "updated_at":{"type":"string","format":"date-time"} 161 | } 162 | } 163 | } 164 | } 165 | }`, reflector.Spec) 166 | } 167 | -------------------------------------------------------------------------------- /openapi3/testdata/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.0.3","info":{"title":"SampleAPI","version":"1.2.3"}, 3 | "paths":{ 4 | "/somewhere/{in_path}":{ 5 | "summary":"Path Summary","description":"Path Description", 6 | "get":{ 7 | "parameters":[ 8 | { 9 | "name":"in_query1","in":"query","description":"Query parameter.","required":true, 10 | "schema":{"type":"integer","description":"Query parameter."} 11 | }, 12 | { 13 | "name":"in_query3","in":"query","description":"Query parameter.","required":true, 14 | "schema":{"type":"integer","description":"Query parameter."} 15 | }, 16 | {"name":"in_path","in":"path","required":true,"schema":{"type":"integer"}}, 17 | {"name":"in_cookie","in":"cookie","deprecated":true,"schema":{"type":"string","deprecated":true}}, 18 | {"name":"in_header","in":"header","schema":{"type":"number"}} 19 | ], 20 | "responses":{ 21 | "200":{ 22 | "description":"This is a sample response.", 23 | "headers":{ 24 | "X-Header-Field":{ 25 | "style":"simple","description":"Sample header response.", 26 | "schema":{"type":"string","description":"Sample header response."} 27 | } 28 | }, 29 | "content":{"application/json":{"schema":{"$ref":"#/components/schemas/Openapi3TestResp"}}} 30 | } 31 | } 32 | }, 33 | "post":{ 34 | "parameters":[ 35 | { 36 | "name":"in_query1","in":"query","description":"Query parameter.","required":true, 37 | "schema":{"type":"integer","description":"Query parameter."} 38 | }, 39 | { 40 | "name":"in_query2","in":"query","description":"Query parameter.","required":true, 41 | "schema":{"type":"integer","description":"Query parameter."} 42 | }, 43 | { 44 | "name":"in_query3","in":"query","description":"Query parameter.","required":true, 45 | "schema":{"type":"integer","description":"Query parameter."} 46 | }, 47 | {"name":"array_csv","in":"query","explode":false,"schema":{"type":"array","items":{"type":"string"}}}, 48 | { 49 | "name":"array_swg2_csv","in":"query","style":"form","explode":false, 50 | "schema":{"type":"array","items":{"type":"string"}} 51 | }, 52 | { 53 | "name":"array_swg2_ssv","in":"query","style":"spaceDelimited","explode":false, 54 | "schema":{"type":"array","items":{"type":"string"}} 55 | }, 56 | { 57 | "name":"array_swg2_pipes","in":"query","style":"pipeDelimited","explode":false, 58 | "schema":{"type":"array","items":{"type":"string"}} 59 | }, 60 | {"name":"in_path","in":"path","required":true,"schema":{"type":"integer"}}, 61 | {"name":"in_cookie","in":"cookie","deprecated":true,"schema":{"type":"string","deprecated":true}}, 62 | {"name":"in_header","in":"header","schema":{"type":"number"}}, 63 | {"name":"uuid","in":"header","schema":{"$ref":"#/components/schemas/Openapi3TestUUID"}} 64 | ], 65 | "requestBody":{ 66 | "content":{ 67 | "application/json":{"schema":{"$ref":"#/components/schemas/Openapi3TestReq"}}, 68 | "multipart/form-data":{"schema":{"$ref":"#/components/schemas/FormDataOpenapi3TestReq"}} 69 | } 70 | }, 71 | "responses":{ 72 | "200":{ 73 | "description":"This is a sample response.", 74 | "headers":{ 75 | "X-Header-Field":{ 76 | "style":"simple","description":"Sample header response.", 77 | "schema":{"type":"string","description":"Sample header response."} 78 | } 79 | }, 80 | "content":{"application/json":{"schema":{"$ref":"#/components/schemas/Openapi3TestResp"}}} 81 | }, 82 | "409":{ 83 | "description":"Conflict", 84 | "content":{ 85 | "application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Openapi3TestResp"}}}, 86 | "text/html":{"schema":{"type":"string"}} 87 | } 88 | } 89 | } 90 | } 91 | } 92 | }, 93 | "components":{ 94 | "schemas":{ 95 | "FormDataOpenapi3TestReq":{ 96 | "type":"object", 97 | "properties":{ 98 | "in_form1":{"type":"string"},"in_form2":{"type":"string"},"upload1":{"$ref":"#/components/schemas/MultipartFile"}, 99 | "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"} 100 | } 101 | }, 102 | "MultipartFile":{"type":"string","format":"binary"}, 103 | "MultipartFileHeader":{"type":"string","format":"binary"}, 104 | "Openapi3TestReq":{"type":"object","properties":{"in_body1":{"type":"integer"},"in_body2":{"type":"string"}}}, 105 | "Openapi3TestResp":{ 106 | "title":"Sample Response","type":"object", 107 | "properties":{ 108 | "arrayOfAnything":{"type":"array","items":{}},"field1":{"type":"integer"},"field2":{"type":"string"}, 109 | "info":{ 110 | "required":["foo"],"type":"object", 111 | "properties":{"bar":{"type":"number","description":"This is Bar."},"foo":{"pattern":"\\d+","type":"string","default":"baz"}} 112 | }, 113 | "map":{"type":"object","additionalProperties":{"type":"integer"}}, 114 | "mapOfAnything":{"type":"object","additionalProperties":{}},"nullableWhatever":{}, 115 | "parent":{"$ref":"#/components/schemas/Openapi3TestResp"}, 116 | "recursiveArray":{"type":"array","items":{"$ref":"#/components/schemas/Openapi3TestResp"}}, 117 | "recursiveStructArray":{"type":"array","items":{"$ref":"#/components/schemas/Openapi3TestResp"}}, 118 | "uuid":{"$ref":"#/components/schemas/Openapi3TestUUID"},"whatever":{} 119 | }, 120 | "description":"This is a sample response.","x-foo":"bar" 121 | }, 122 | "Openapi3TestUUID":{"type":"array","items":{"minimum":0,"type":"integer"},"nullable":true} 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /openapi3/testdata/openapi_req.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.0.3","info":{"title":"SampleAPI","description":"This a sample API description.","version":"1.2.3"}, 3 | "paths":{ 4 | "/somewhere/{in_path}":{ 5 | "get":{ 6 | "parameters":[ 7 | { 8 | "name":"in_query1","in":"query","description":"Query parameter.","required":true, 9 | "schema":{"type":"integer","description":"Query parameter."} 10 | }, 11 | { 12 | "name":"in_query3","in":"query","description":"Query parameter.","required":true, 13 | "schema":{"type":"integer","description":"Query parameter."} 14 | }, 15 | {"name":"in_path","in":"path","required":true,"schema":{"type":"integer"}}, 16 | {"name":"in_cookie","in":"cookie","deprecated":true,"schema":{"type":"string","deprecated":true}}, 17 | {"name":"in_header","in":"header","schema":{"type":"number"}} 18 | ], 19 | "requestBody":{"description":"Request body in CSV format.","content":{"text/csv":{"schema":{"type":"string"}}}}, 20 | "responses":{"204":{"description":"No Content"}} 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /openapi3/testdata/openapi_req2.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.0.3","info":{"title":"SampleAPI","description":"This a sample API description.","version":"1.2.3"}, 3 | "paths":{ 4 | "/somewhere/{in_path}":{ 5 | "get":{ 6 | "parameters":[ 7 | { 8 | "name":"in_query1","in":"query","description":"Query parameter.","required":true, 9 | "schema":{"type":"integer","description":"Query parameter."} 10 | }, 11 | { 12 | "name":"in_query3","in":"query","description":"Query parameter.","required":true, 13 | "schema":{"type":"integer","description":"Query parameter."} 14 | }, 15 | {"name":"in_path","in":"path","required":true,"schema":{"type":"integer"}}, 16 | {"name":"in_cookie","in":"cookie","deprecated":true,"schema":{"type":"string","deprecated":true}}, 17 | {"name":"in_header","in":"header","schema":{"type":"number"}} 18 | ], 19 | "responses":{"204":{"description":"No Content"}} 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /openapi3/testdata/openapi_req_array.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.0.3","info":{"title":"SampleAPI","version":"1.2.3"}, 3 | "paths":{ 4 | "/somewhere":{ 5 | "post":{ 6 | "requestBody":{ 7 | "content":{ 8 | "application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Openapi3TestGetReq"},"nullable":true}} 9 | } 10 | }, 11 | "responses":{"204":{"description":"No Content"}} 12 | } 13 | } 14 | }, 15 | "components":{ 16 | "schemas":{ 17 | "Openapi3TestGetReq":{ 18 | "required":["q1","q3"],"type":"object", 19 | "properties":{ 20 | "c":{"type":"string","deprecated":true},"h":{"type":"number"},"p":{"type":"integer"}, 21 | "q1":{"type":"integer","description":"Query parameter."},"q3":{"type":"integer","description":"Query parameter."} 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /openapi3/testdata/req_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties":{ 3 | "in_form1":{"type":"string"},"in_form2":{"type":"string"}, 4 | "upload1":{"$ref":"#/components/schemas/MultipartFile"}, 5 | "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"} 6 | }, 7 | "type":"object", 8 | "components":{ 9 | "schemas":{ 10 | "MultipartFile":{"type":"string","format":"binary"}, 11 | "MultipartFileHeader":{"type":"string","format":"binary"} 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /openapi3/testdata/resp_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref":"#/components/schemas/Openapi3TestResp", 3 | "components":{ 4 | "schemas":{ 5 | "Openapi3TestResp":{ 6 | "title":"Sample Response","description":"This is a sample response.", 7 | "properties":{ 8 | "arrayOfAnything":{"items":{},"type":"array"},"field1":{"type":"integer"},"field2":{"type":"string"}, 9 | "info":{ 10 | "required":["foo"], 11 | "properties":{"bar":{"description":"This is Bar.","type":"number"},"foo":{"default":"baz","pattern":"\\d+","type":"string"}}, 12 | "type":"object" 13 | }, 14 | "map":{"additionalProperties":{"type":"integer"},"type":"object"}, 15 | "mapOfAnything":{"additionalProperties":{},"type":"object"},"nullableWhatever":{}, 16 | "parent":{"$ref":"#/components/schemas/Openapi3TestResp"}, 17 | "recursiveArray":{"items":{"$ref":"#/components/schemas/Openapi3TestResp"},"type":"array"}, 18 | "recursiveStructArray":{"items":{"$ref":"#/components/schemas/Openapi3TestResp"},"type":"array"}, 19 | "uuid":{"$ref":"#/components/schemas/Openapi3TestUUID"},"whatever":{} 20 | }, 21 | "type":"object","x-foo":"bar" 22 | }, 23 | "Openapi3TestUUID":{"items":{"minimum":0,"type":"integer"},"type":["array","null"]} 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /openapi3/testdata/swgui/go.mod: -------------------------------------------------------------------------------- 1 | module swgui 2 | 3 | go 1.19 4 | 5 | require github.com/swaggest/swgui v1.6.4 6 | 7 | require ( 8 | github.com/shurcooL/httpgzip v0.0.0-20190720172056-320755c1c1b0 // indirect 9 | golang.org/x/net v0.8.0 // indirect 10 | golang.org/x/text v0.8.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /openapi3/testdata/swgui/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bool64/dev v0.2.29 h1:x+syGyh+0eWtOzQ1ItvLzOGIWyNWnyjXpHIcpF2HvL4= 2 | github.com/shurcooL/httpgzip v0.0.0-20190720172056-320755c1c1b0 h1:mj/nMDAwTBiaCqMEs4cYCqF7pO6Np7vhy1D1wcQGz+E= 3 | github.com/shurcooL/httpgzip v0.0.0-20190720172056-320755c1c1b0/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= 4 | github.com/swaggest/swgui v1.6.4 h1:9G7HTUMOAu/Y9YF2wDvoq9GXFlV4BH5y8BSOl4MWfl8= 5 | github.com/swaggest/swgui v1.6.4/go.mod h1:xsfGb4NtnBspBXKXtlPdVrvoqzCIZ338Aj3tHNikz2Q= 6 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 7 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 8 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 9 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 10 | golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= 11 | -------------------------------------------------------------------------------- /openapi3/testdata/swgui/swgui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | swgui "github.com/swaggest/swgui/v5" 8 | ) 9 | 10 | func main() { 11 | urlToSchema := "/openapi.json" 12 | filePathToSchema := "../openapi.json" 13 | 14 | swh := swgui.NewHandler("Foo", urlToSchema, "/") 15 | hh := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 16 | if r.URL.Path == urlToSchema { 17 | http.ServeFile(rw, r, filePathToSchema) 18 | } 19 | 20 | swh.ServeHTTP(rw, r) 21 | }) 22 | 23 | log.Println("Starting Swagger UI server at http://localhost:8082/") 24 | _ = http.ListenAndServe("localhost:8082", hh) 25 | } 26 | -------------------------------------------------------------------------------- /openapi3/testdata/uploads.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.0.3","info":{"title":"","version":""}, 3 | "paths":{ 4 | "/upload":{ 5 | "post":{ 6 | "requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/FormDataOpenapi3TestReq"}}}}, 7 | "responses":{"204":{"description":"No Content"}} 8 | } 9 | } 10 | }, 11 | "components":{ 12 | "schemas":{ 13 | "FormDataOpenapi3TestReq":{ 14 | "type":"object", 15 | "properties":{ 16 | "upload1":{"$ref":"#/components/schemas/MultipartFile"}, 17 | "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"}, 18 | "uploads3":{"type":"array","items":{"$ref":"#/components/schemas/MultipartFile"}}, 19 | "uploads4":{"type":"array","items":{"$ref":"#/components/schemas/MultipartFileHeader"}} 20 | } 21 | }, 22 | "MultipartFile":{"type":"string","format":"binary"},"MultipartFileHeader":{"type":"string","format":"binary"} 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /openapi3/upload_test.go: -------------------------------------------------------------------------------- 1 | package openapi3_test 2 | 3 | import ( 4 | "mime/multipart" 5 | "net/http" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "github.com/swaggest/assertjson" 11 | "github.com/swaggest/openapi-go/openapi3" 12 | ) 13 | 14 | func TestNewReflector_uploads(t *testing.T) { 15 | r := openapi3.NewReflector() 16 | 17 | oc, err := r.NewOperationContext(http.MethodPost, "/upload") 18 | require.NoError(t, err) 19 | 20 | type req struct { 21 | Upload1 multipart.File `formData:"upload1"` 22 | Upload2 *multipart.FileHeader `formData:"upload2"` 23 | Uploads3 []multipart.File `formData:"uploads3"` 24 | Uploads4 []*multipart.FileHeader `formData:"uploads4"` 25 | } 26 | 27 | oc.AddReqStructure(req{}) 28 | 29 | require.NoError(t, r.AddOperation(oc)) 30 | 31 | schema, err := assertjson.MarshalIndentCompact(r.SpecSchema(), "", " ", 120) 32 | require.NoError(t, err) 33 | 34 | require.NoError(t, os.WriteFile("testdata/uploads_last_run.json", schema, 0o600)) 35 | 36 | expected, err := os.ReadFile("testdata/uploads.json") 37 | require.NoError(t, err) 38 | 39 | assertjson.Equal(t, expected, schema) 40 | } 41 | -------------------------------------------------------------------------------- /openapi3/walk_schema.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/swaggest/jsonschema-go" 8 | "github.com/swaggest/openapi-go" 9 | "github.com/swaggest/openapi-go/internal" 10 | ) 11 | 12 | // WalkResponseJSONSchemas provides JSON schemas for response structure. 13 | func (r *Reflector) WalkResponseJSONSchemas(cu openapi.ContentUnit, cb openapi.JSONSchemaCallback, done func(oc openapi.OperationContext)) error { 14 | oc := operationContext{ 15 | OperationContext: internal.NewOperationContext(http.MethodGet, "/"), 16 | op: &Operation{}, 17 | } 18 | 19 | oc.AddRespStructure(nil, func(c *openapi.ContentUnit) { 20 | *c = cu 21 | }) 22 | 23 | defer func() { 24 | if done != nil { 25 | done(oc) 26 | } 27 | }() 28 | 29 | op := oc.op 30 | 31 | if err := r.setupResponse(op, oc); err != nil { 32 | return err 33 | } 34 | 35 | var resp *Response 36 | 37 | for _, r := range op.Responses.MapOfResponseOrRefValues { 38 | resp = r.Response 39 | } 40 | 41 | if err := r.provideHeaderSchemas(resp, cb); err != nil { 42 | return err 43 | } 44 | 45 | for _, cont := range resp.Content { 46 | if cont.Schema == nil { 47 | continue 48 | } 49 | 50 | schema := cont.Schema.ToJSONSchema(r.Spec) 51 | 52 | if err := cb(openapi.InBody, "body", &schema, false); err != nil { 53 | return fmt.Errorf("response body schema: %w", err) 54 | } 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (r *Reflector) provideHeaderSchemas(resp *Response, cb openapi.JSONSchemaCallback) error { 61 | for name, h := range resp.Headers { 62 | if h.Header.Schema == nil { 63 | continue 64 | } 65 | 66 | hh := h.Header 67 | schema := hh.Schema.ToJSONSchema(r.Spec) 68 | 69 | required := false 70 | if hh.Required != nil && *hh.Required { 71 | required = true 72 | } 73 | 74 | name = http.CanonicalHeaderKey(name) 75 | 76 | if err := cb(openapi.InHeader, name, &schema, required); err != nil { 77 | return fmt.Errorf("response header schema (%s): %w", name, err) 78 | } 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // WalkRequestJSONSchemas iterates over request parameters of a ContentUnit and call user function for param schemas. 85 | func (r *Reflector) WalkRequestJSONSchemas( 86 | method string, 87 | cu openapi.ContentUnit, 88 | cb openapi.JSONSchemaCallback, 89 | done func(oc openapi.OperationContext), 90 | ) error { 91 | oc := operationContext{ 92 | OperationContext: internal.NewOperationContext(method, "/"), 93 | op: &Operation{}, 94 | } 95 | 96 | oc.AddReqStructure(nil, func(c *openapi.ContentUnit) { 97 | *c = cu 98 | }) 99 | 100 | defer func() { 101 | if done != nil { 102 | done(oc) 103 | } 104 | }() 105 | 106 | op := oc.op 107 | 108 | if err := r.setupRequest(op, oc); err != nil { 109 | return err 110 | } 111 | 112 | err := r.provideParametersJSONSchemas(op, cb) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | if op.RequestBody == nil || op.RequestBody.RequestBody == nil { 118 | return nil 119 | } 120 | 121 | for ct, content := range op.RequestBody.RequestBody.Content { 122 | schema := content.Schema.ToJSONSchema(r.Spec) 123 | 124 | if ct == mimeJSON { 125 | err = cb(openapi.InBody, "body", &schema, false) 126 | if err != nil { 127 | return fmt.Errorf("request body schema: %w", err) 128 | } 129 | } 130 | 131 | if ct == mimeFormUrlencoded { 132 | if err = provideFormDataSchemas(schema, cb); err != nil { 133 | return err 134 | } 135 | } 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func provideFormDataSchemas(schema jsonschema.SchemaOrBool, cb openapi.JSONSchemaCallback) error { 142 | for name, propertySchema := range schema.TypeObject.Properties { 143 | propertySchema := propertySchema 144 | 145 | if propertySchema.TypeObject != nil && len(schema.TypeObject.ExtraProperties) > 0 { 146 | cp := *propertySchema.TypeObject 147 | propertySchema.TypeObject = &cp 148 | propertySchema.TypeObject.ExtraProperties = schema.TypeObject.ExtraProperties 149 | } 150 | 151 | isRequired := false 152 | 153 | for _, req := range schema.TypeObject.Required { 154 | if req == name { 155 | isRequired = true 156 | 157 | break 158 | } 159 | } 160 | 161 | err := cb(openapi.InFormData, name, &propertySchema, isRequired) 162 | if err != nil { 163 | return fmt.Errorf("request body schema: %w", err) 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func (r *Reflector) provideParametersJSONSchemas(op *Operation, cb openapi.JSONSchemaCallback) error { 171 | for _, p := range op.Parameters { 172 | pp := p.Parameter 173 | 174 | required := false 175 | if pp.Required != nil && *pp.Required { 176 | required = true 177 | } 178 | 179 | sc := paramSchema(pp) 180 | 181 | if sc == nil { 182 | if err := cb(openapi.In(pp.In), pp.Name, nil, required); err != nil { 183 | return fmt.Errorf("schema for parameter (%s, %s): %w", pp.In, pp.Name, err) 184 | } 185 | 186 | continue 187 | } 188 | 189 | schema := sc.ToJSONSchema(r.Spec) 190 | 191 | if err := cb(openapi.In(pp.In), pp.Name, &schema, required); err != nil { 192 | return fmt.Errorf("schema for parameter (%s, %s): %w", pp.In, pp.Name, err) 193 | } 194 | } 195 | 196 | return nil 197 | } 198 | 199 | func paramSchema(p *Parameter) *SchemaOrRef { 200 | sc := p.Schema 201 | 202 | if sc == nil { 203 | if jsc, ok := p.Content[mimeJSON]; ok { 204 | sc = jsc.Schema 205 | } 206 | } 207 | 208 | return sc 209 | } 210 | -------------------------------------------------------------------------------- /openapi3/walk_schema_test.go: -------------------------------------------------------------------------------- 1 | package openapi3_test 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/swaggest/assertjson" 11 | jsonschema "github.com/swaggest/jsonschema-go" 12 | "github.com/swaggest/openapi-go" 13 | "github.com/swaggest/openapi-go/openapi3" 14 | ) 15 | 16 | func TestReflector_WalkRequestJSONSchemas(t *testing.T) { 17 | r := openapi3.NewReflector() 18 | 19 | type Embed struct { 20 | Query1 int `query:"query1" minimum:"3"` 21 | Query2 string `query:"query2" minLength:"2"` 22 | Query3 bool `query:"query3" description:"Trivial schema."` 23 | } 24 | 25 | type DeeplyEmbedded struct { 26 | Embed 27 | } 28 | 29 | type req struct { 30 | *DeeplyEmbedded 31 | 32 | Path1 int `path:"path1" minimum:"3"` 33 | Path2 string `path:"path2" minLength:"2"` 34 | Path3 bool `path:"path3" description:"Trivial schema."` 35 | 36 | Header1 int `header:"header1" minimum:"3"` 37 | Header2 string `header:"header2" minLength:"2"` 38 | Header3 bool `header:"header3" description:"Trivial schema."` 39 | 40 | Cookie1 int `cookie:"cookie1" minimum:"3"` 41 | Cookie2 string `cookie:"cookie2" minLength:"2"` 42 | Cookie3 bool `cookie:"cookie3" description:"Trivial schema."` 43 | 44 | Form1 int `form:"form1" minimum:"3"` 45 | Form2 string `form:"form2" minLength:"2"` 46 | Form3 bool `form:"form3" description:"Trivial schema."` 47 | } 48 | 49 | cu := openapi.ContentUnit{} 50 | cu.Structure = req{} 51 | 52 | schemas := map[string]*jsonschema.SchemaOrBool{} 53 | doneCalled := 0 54 | 55 | require.NoError(t, r.WalkRequestJSONSchemas(http.MethodPost, cu, 56 | func(in openapi.In, paramName string, schema *jsonschema.SchemaOrBool, required bool) error { 57 | schemas[string(in)+"-"+paramName+"-"+strconv.FormatBool(required)+"-"+ 58 | strconv.FormatBool(schema.IsTrivial(r.ResolveJSONSchemaRef))] = schema 59 | 60 | return nil 61 | }, 62 | func(_ openapi.OperationContext) { 63 | doneCalled++ 64 | }, 65 | )) 66 | 67 | assert.Equal(t, 1, doneCalled) 68 | assertjson.EqMarshal(t, `{ 69 | "cookie-cookie1-false-false":{"minimum":3,"type":"integer"}, 70 | "cookie-cookie2-false-false":{"minLength":2,"type":"string"}, 71 | "cookie-cookie3-false-true":{"description":"Trivial schema.","type":"boolean"}, 72 | "formData-form1-false-false":{"minimum":3,"type":"integer"}, 73 | "formData-form2-false-false":{"minLength":2,"type":"string"}, 74 | "formData-form3-false-true":{"description":"Trivial schema.","type":"boolean"}, 75 | "header-header1-false-false":{"minimum":3,"type":"integer"}, 76 | "header-header2-false-false":{"minLength":2,"type":"string"}, 77 | "header-header3-false-true":{"description":"Trivial schema.","type":"boolean"}, 78 | "path-path1-true-false":{"minimum":3,"type":"integer"}, 79 | "path-path2-true-false":{"minLength":2,"type":"string"}, 80 | "path-path3-true-true":{"description":"Trivial schema.","type":"boolean"}, 81 | "query-form1-false-false":{"minimum":3,"type":"integer"}, 82 | "query-form2-false-false":{"minLength":2,"type":"string"}, 83 | "query-form3-false-true":{"description":"Trivial schema.","type":"boolean"}, 84 | "query-query1-false-false":{"minimum":3,"type":"integer"}, 85 | "query-query2-false-false":{"minLength":2,"type":"string"}, 86 | "query-query3-false-true":{"description":"Trivial schema.","type":"boolean"} 87 | }`, schemas) 88 | 89 | assertjson.EqMarshal(t, `{ 90 | "openapi":"3.0.3","info":{"title":"","version":""},"paths":{}, 91 | "components":{ 92 | "schemas":{ 93 | "FormDataOpenapi3TestReq":{ 94 | "type":"object", 95 | "properties":{ 96 | "form1":{"minimum":3,"type":"integer"}, 97 | "form2":{"minLength":2,"type":"string"}, 98 | "form3":{"type":"boolean","description":"Trivial schema."} 99 | } 100 | } 101 | } 102 | } 103 | }`, r.Spec) 104 | } 105 | 106 | func TestReflector_WalkRequestJSONSchemas_jsonBody(t *testing.T) { 107 | r := openapi3.NewReflector() 108 | 109 | type Embed struct { 110 | Query1 int `query:"query1" minimum:"3"` 111 | Query2 string `query:"query2" minLength:"2"` 112 | Query3 bool `query:"query3" description:"Trivial schema."` 113 | 114 | Foo int `json:"foo" minimum:"6"` 115 | } 116 | 117 | type DeeplyEmbedded struct { 118 | Embed 119 | } 120 | 121 | type req struct { 122 | *DeeplyEmbedded 123 | 124 | Path1 int `path:"path1" minimum:"3"` 125 | Path2 string `path:"path2" minLength:"2"` 126 | Path3 bool `path:"path3" description:"Trivial schema."` 127 | 128 | Header1 int `header:"header1" minimum:"3"` 129 | Header2 string `header:"header2" minLength:"2"` 130 | Header3 bool `header:"header3" description:"Trivial schema."` 131 | 132 | Cookie1 int `cookie:"cookie1" minimum:"3"` 133 | Cookie2 string `cookie:"cookie2" minLength:"2"` 134 | Cookie3 bool `cookie:"cookie3" description:"Trivial schema."` 135 | 136 | Bar []string `json:"bar" minItems:"15"` 137 | } 138 | 139 | cu := openapi.ContentUnit{} 140 | cu.Structure = req{} 141 | 142 | schemas := map[string]*jsonschema.SchemaOrBool{} 143 | doneCalled := 0 144 | 145 | require.NoError(t, r.WalkRequestJSONSchemas(http.MethodPost, cu, 146 | func(in openapi.In, paramName string, schema *jsonschema.SchemaOrBool, required bool) error { 147 | schemas[string(in)+"-"+paramName+"-"+strconv.FormatBool(required)+"-"+ 148 | strconv.FormatBool(schema.IsTrivial(r.ResolveJSONSchemaRef))] = schema 149 | 150 | return nil 151 | }, 152 | func(_ openapi.OperationContext) { 153 | doneCalled++ 154 | }, 155 | )) 156 | 157 | assert.Equal(t, 1, doneCalled) 158 | assertjson.EqMarshal(t, `{ 159 | "body-body-false-false":{ 160 | "properties":{ 161 | "bar":{"items":{"type":"string"},"minItems":15,"type":["array","null"]}, 162 | "foo":{"minimum":6,"type":"integer"} 163 | }, 164 | "type":"object" 165 | }, 166 | "cookie-cookie1-false-false":{"minimum":3,"type":"integer"}, 167 | "cookie-cookie2-false-false":{"minLength":2,"type":"string"}, 168 | "cookie-cookie3-false-true":{"description":"Trivial schema.","type":"boolean"}, 169 | "header-header1-false-false":{"minimum":3,"type":"integer"}, 170 | "header-header2-false-false":{"minLength":2,"type":"string"}, 171 | "header-header3-false-true":{"description":"Trivial schema.","type":"boolean"}, 172 | "path-path1-true-false":{"minimum":3,"type":"integer"}, 173 | "path-path2-true-false":{"minLength":2,"type":"string"}, 174 | "path-path3-true-true":{"description":"Trivial schema.","type":"boolean"}, 175 | "query-query1-false-false":{"minimum":3,"type":"integer"}, 176 | "query-query2-false-false":{"minLength":2,"type":"string"}, 177 | "query-query3-false-true":{"description":"Trivial schema.","type":"boolean"} 178 | }`, schemas) 179 | 180 | assertjson.EqMarshal(t, `{ 181 | "openapi":"3.0.3","info":{"title":"","version":""},"paths":{}, 182 | "components":{ 183 | "schemas":{ 184 | "Openapi3TestReq":{ 185 | "type":"object", 186 | "properties":{ 187 | "bar":{ 188 | "minItems":15,"type":"array","items":{"type":"string"}, 189 | "nullable":true 190 | }, 191 | "foo":{"minimum":6,"type":"integer"} 192 | } 193 | } 194 | } 195 | } 196 | }`, r.Spec) 197 | } 198 | 199 | func TestReflector_WalkResponseJSONSchemas(t *testing.T) { 200 | r := openapi3.NewReflector() 201 | 202 | type Embed struct { 203 | Header1 int `header:"header1" minimum:"3"` 204 | Header2 string `header:"header2" minLength:"2"` 205 | Header3 bool `header:"header3" description:"Trivial schema."` 206 | 207 | Foo int `json:"foo" minimum:"6"` 208 | } 209 | 210 | type DeeplyEmbedded struct { 211 | Embed 212 | } 213 | 214 | type req struct { 215 | *DeeplyEmbedded 216 | 217 | Header4 int `header:"header4" minimum:"3"` 218 | Header5 string `header:"header5" minLength:"2"` 219 | Header6 bool `header:"header6" description:"Trivial schema."` 220 | 221 | Bar []string `json:"bar" minItems:"15"` 222 | } 223 | 224 | cu := openapi.ContentUnit{} 225 | cu.Structure = req{} 226 | 227 | schemas := map[string]*jsonschema.SchemaOrBool{} 228 | doneCalled := 0 229 | 230 | require.NoError(t, r.WalkResponseJSONSchemas(cu, 231 | func(in openapi.In, paramName string, schema *jsonschema.SchemaOrBool, required bool) error { 232 | schemas[string(in)+"-"+paramName+"-"+strconv.FormatBool(required)+"-"+ 233 | strconv.FormatBool(schema.IsTrivial(r.ResolveJSONSchemaRef))] = schema 234 | 235 | return nil 236 | }, 237 | func(_ openapi.OperationContext) { 238 | doneCalled++ 239 | }, 240 | )) 241 | 242 | assert.Equal(t, 1, doneCalled) 243 | assertjson.EqMarshal(t, `{ 244 | "body-body-false-false":{ 245 | "properties":{ 246 | "bar":{"items":{"type":"string"},"minItems":15,"type":["array","null"]}, 247 | "foo":{"minimum":6,"type":"integer"} 248 | }, 249 | "type":"object" 250 | }, 251 | "header-Header1-false-false":{"minimum":3,"type":"integer"}, 252 | "header-Header2-false-false":{"minLength":2,"type":"string"}, 253 | "header-Header3-false-true":{"description":"Trivial schema.","type":"boolean"}, 254 | "header-Header4-false-false":{"minimum":3,"type":"integer"}, 255 | "header-Header5-false-false":{"minLength":2,"type":"string"}, 256 | "header-Header6-false-true":{"description":"Trivial schema.","type":"boolean"} 257 | }`, schemas) 258 | 259 | assertjson.EqMarshal(t, `{ 260 | "openapi":"3.0.3","info":{"title":"","version":""},"paths":{}, 261 | "components":{ 262 | "schemas":{ 263 | "Openapi3TestReq":{ 264 | "type":"object", 265 | "properties":{ 266 | "bar":{ 267 | "minItems":15,"type":"array","items":{"type":"string"}, 268 | "nullable":true 269 | }, 270 | "foo":{"minimum":6,"type":"integer"} 271 | } 272 | } 273 | } 274 | } 275 | }`, r.Spec) 276 | } 277 | -------------------------------------------------------------------------------- /openapi3/yaml.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | // UnmarshalYAML reads from YAML bytes. 13 | func (s *Spec) UnmarshalYAML(data []byte) error { 14 | var v interface{} 15 | 16 | err := yaml.Unmarshal(data, &v) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | v = convertMapI2MapS(v) 22 | 23 | data, err = json.Marshal(v) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return s.UnmarshalJSON(data) 29 | } 30 | 31 | // MarshalYAML produces YAML bytes. 32 | func (s *Spec) MarshalYAML() ([]byte, error) { 33 | jsonData, err := s.MarshalJSON() 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | var v orderedMap 39 | 40 | err = json.Unmarshal(jsonData, &v) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return yaml.Marshal(yaml.MapSlice(v)) 46 | } 47 | 48 | type orderedMap []yaml.MapItem 49 | 50 | func (om *orderedMap) UnmarshalJSON(data []byte) error { 51 | var mapData map[string]interface{} 52 | 53 | err := json.Unmarshal(data, &mapData) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | keys, err := objectKeys(data) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | for _, key := range keys { 64 | *om = append(*om, yaml.MapItem{ 65 | Key: key, 66 | Value: mapData[key], 67 | }) 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func objectKeys(b []byte) ([]string, error) { 74 | d := json.NewDecoder(bytes.NewReader(b)) 75 | 76 | t, err := d.Token() 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | if t != json.Delim('{') { 82 | return nil, errors.New("expected start of object") 83 | } 84 | 85 | var keys []string 86 | 87 | for { 88 | t, err := d.Token() 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if t == json.Delim('}') { 94 | return keys, nil 95 | } 96 | 97 | if s, ok := t.(string); ok { 98 | keys = append(keys, s) 99 | } 100 | 101 | if err := skipValue(d); err != nil { 102 | return nil, err 103 | } 104 | } 105 | } 106 | 107 | var errUnterminated = errors.New("unterminated array or object") 108 | 109 | func skipValue(d *json.Decoder) error { 110 | t, err := d.Token() 111 | if err != nil { 112 | return err 113 | } 114 | 115 | switch t { 116 | case json.Delim('['), json.Delim('{'): 117 | for { 118 | if err := skipValue(d); err != nil { 119 | if errors.Is(err, errUnterminated) { 120 | break 121 | } 122 | 123 | return err 124 | } 125 | } 126 | case json.Delim(']'), json.Delim('}'): 127 | return errUnterminated 128 | } 129 | 130 | return nil 131 | } 132 | 133 | // convertMapI2MapS walks the given dynamic object recursively, and 134 | // converts maps with interface{} key type to maps with string key type. 135 | // This function comes handy if you want to marshal a dynamic object into 136 | // JSON where maps with interface{} key type are not allowed. 137 | // 138 | // Recursion is implemented into values of the following types: 139 | // 140 | // -map[interface{}]interface{} 141 | // -map[string]interface{} 142 | // -[]interface{} 143 | // 144 | // When converting map[interface{}]interface{} to map[string]interface{}, 145 | // fmt.Sprint() with default formatting is used to convert the key to a string key. 146 | // 147 | // See github.com/icza/dyno. 148 | func convertMapI2MapS(v interface{}) interface{} { 149 | switch x := v.(type) { 150 | case map[interface{}]interface{}: 151 | m := map[string]interface{}{} 152 | 153 | for k, v2 := range x { 154 | switch k2 := k.(type) { 155 | case string: // Fast check if it's already a string 156 | m[k2] = convertMapI2MapS(v2) 157 | default: 158 | m[fmt.Sprint(k)] = convertMapI2MapS(v2) 159 | } 160 | } 161 | 162 | v = m 163 | 164 | case []interface{}: 165 | for i, v2 := range x { 166 | x[i] = convertMapI2MapS(v2) 167 | } 168 | 169 | case map[string]interface{}: 170 | for k, v2 := range x { 171 | x[k] = convertMapI2MapS(v2) 172 | } 173 | } 174 | 175 | return v 176 | } 177 | -------------------------------------------------------------------------------- /openapi31/doc.go: -------------------------------------------------------------------------------- 1 | // Package openapi31 provides entities and helpers to manage OpenAPI 3.1.x schema. 2 | package openapi31 3 | -------------------------------------------------------------------------------- /openapi31/entities_extra_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/swaggest/openapi-go/openapi31" 8 | ) 9 | 10 | func TestSpec_MarshalYAML(t *testing.T) { 11 | var s openapi31.Spec 12 | 13 | spec := `openapi: 3.1.0 14 | info: 15 | description: description 16 | license: 17 | name: Apache-2.0 18 | url: https://www.apache.org/licenses/LICENSE-2.0.html 19 | title: title 20 | version: 2.0.0 21 | servers: 22 | - url: /v2 23 | paths: 24 | /user: 25 | put: 26 | summary: updates the user by id 27 | operationId: UpdateUser 28 | requestBody: 29 | content: 30 | application/json: 31 | schema: 32 | type: string 33 | description: Updated user object 34 | required: true 35 | responses: 36 | "404": 37 | description: User not found 38 | components: 39 | securitySchemes: 40 | api_key: 41 | in: header 42 | name: x-api-key 43 | type: apiKey 44 | bearer_auth: 45 | type: http 46 | scheme: bearer 47 | bearerFormat: JWT` 48 | 49 | require.NoError(t, s.UnmarshalYAML([]byte(spec))) 50 | } 51 | 52 | func TestSpec_MarshalYAML_2(t *testing.T) { 53 | var s openapi31.Spec 54 | 55 | spec := `openapi: 3.1.0 56 | info: 57 | title: MyProject 58 | description: "My Project Description" 59 | version: v1.0.0 60 | # 1) Define the security scheme type (HTTP bearer) 61 | components: 62 | securitySchemes: 63 | bearerAuth: # arbitrary name for the security scheme 64 | type: http 65 | scheme: bearer 66 | bearerFormat: JWT # optional, arbitrary value for documentation purposes 67 | # 2) Apply the security globally to all operations 68 | security: 69 | - bearerAuth: [] # use the same name as above 70 | paths: 71 | ` 72 | 73 | require.NoError(t, s.UnmarshalYAML([]byte(spec))) 74 | } 75 | 76 | func TestSpec_MarshalYAML_3(t *testing.T) { 77 | var s openapi31.Spec 78 | 79 | spec := `openapi: 3.1.0 80 | info: 81 | title: MyProject 82 | description: "My Project Description" 83 | version: v1.0.0 84 | components: 85 | securitySchemes: 86 | basicAuth: # <-- arbitrary name for the security scheme 87 | type: http 88 | scheme: basic 89 | security: 90 | - basicAuth: [] # <-- use the same name here 91 | paths: 92 | ` 93 | 94 | require.NoError(t, s.UnmarshalYAML([]byte(spec))) 95 | } 96 | -------------------------------------------------------------------------------- /openapi31/entities_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/swaggest/openapi-go/openapi31" 9 | ) 10 | 11 | func TestSpec_UnmarshalYAML(t *testing.T) { 12 | bytes, err := os.ReadFile("testdata/albums_api.yaml") 13 | require.NoError(t, err) 14 | 15 | refl := openapi31.NewReflector() 16 | require.NoError(t, refl.Spec.UnmarshalYAML(bytes)) 17 | } 18 | 19 | func TestSpec_UnmarshalYAML_refsInResponseHeaders(t *testing.T) { 20 | var s openapi31.Spec 21 | 22 | spec := `openapi: 3.1.0 23 | info: 24 | description: description 25 | license: 26 | name: Apache-2.0 27 | url: https://www.apache.org/licenses/LICENSE-2.0.html 28 | title: title 29 | version: 2.0.0 30 | servers: 31 | - url: /v2 32 | paths: 33 | /user: 34 | put: 35 | summary: updates the user by id 36 | operationId: UpdateUser 37 | requestBody: 38 | content: 39 | application/json: 40 | schema: 41 | type: string 42 | description: Updated user object 43 | required: true 44 | responses: 45 | "404": 46 | description: User not found 47 | headers: 48 | Cache-Control: 49 | $ref: '#/components/headers/CacheControl' 50 | Authorisation: 51 | schema: 52 | type: string 53 | Custom: 54 | content: 55 | "text/plain": 56 | schema: 57 | type: string 58 | 59 | components: 60 | headers: 61 | CacheControl: 62 | schema: 63 | type: string 64 | ` 65 | 66 | require.NoError(t, s.UnmarshalYAML([]byte(spec))) 67 | } 68 | -------------------------------------------------------------------------------- /openapi31/example_misc_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "reflect" 7 | 8 | "github.com/swaggest/assertjson" 9 | "github.com/swaggest/jsonschema-go" 10 | "github.com/swaggest/openapi-go/openapi31" 11 | ) 12 | 13 | func ExampleReflector_options() { 14 | r := openapi31.Reflector{} 15 | 16 | // Reflector embeds jsonschema.Reflector and it is possible to configure optional behavior. 17 | r.Reflector.DefaultOptions = append(r.Reflector.DefaultOptions, 18 | jsonschema.InterceptNullability(func(params jsonschema.InterceptNullabilityParams) { 19 | // Removing nullability from non-pointer slices (regardless of omitempty). 20 | if params.Type.Kind() != reflect.Ptr && params.Schema.HasType(jsonschema.Null) && params.Schema.HasType(jsonschema.Array) { 21 | *params.Schema.Type = jsonschema.Array.Type() 22 | } 23 | })) 24 | 25 | type req struct { 26 | Foo []int `json:"foo"` 27 | } 28 | 29 | oc, _ := r.NewOperationContext(http.MethodPost, "/foo") 30 | oc.AddReqStructure(new(req)) 31 | 32 | _ = r.AddOperation(oc) 33 | 34 | j, _ := assertjson.MarshalIndentCompact(r.Spec, "", " ", 120) 35 | 36 | fmt.Println(string(j)) 37 | 38 | // Output: 39 | // { 40 | // "openapi":"3.1.0","info":{"title":"","version":""}, 41 | // "paths":{ 42 | // "/foo":{ 43 | // "post":{ 44 | // "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Openapi31TestReq"}}}}, 45 | // "responses":{"204":{"description":"No Content"}} 46 | // } 47 | // } 48 | // }, 49 | // "components":{"schemas":{"Openapi31TestReq":{"properties":{"foo":{"items":{"type":"integer"},"type":"array"}},"type":"object"}}} 50 | // } 51 | } 52 | -------------------------------------------------------------------------------- /openapi31/example_security_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/swaggest/openapi-go" 9 | "github.com/swaggest/openapi-go/openapi31" 10 | ) 11 | 12 | func ExampleSpec_SetHTTPBasicSecurity() { 13 | reflector := openapi31.Reflector{} 14 | securityName := "admin" 15 | 16 | // Declare security scheme. 17 | reflector.SpecEns().SetHTTPBasicSecurity(securityName, "Admin Access") 18 | 19 | oc, _ := reflector.NewOperationContext(http.MethodGet, "/secure") 20 | oc.AddRespStructure(struct { 21 | Secret string `json:"secret"` 22 | }{}) 23 | 24 | // Add security requirement to operation. 25 | oc.AddSecurity(securityName) 26 | 27 | // Describe unauthorized response. 28 | oc.AddRespStructure(struct { 29 | Error string `json:"error"` 30 | }{}, func(cu *openapi.ContentUnit) { 31 | cu.HTTPStatus = http.StatusUnauthorized 32 | }) 33 | 34 | // Add operation to schema. 35 | _ = reflector.AddOperation(oc) 36 | 37 | schema, err := reflector.Spec.MarshalYAML() 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | fmt.Println(string(schema)) 43 | 44 | // Output: 45 | // openapi: 3.1.0 46 | // info: 47 | // title: "" 48 | // version: "" 49 | // paths: 50 | // /secure: 51 | // get: 52 | // responses: 53 | // "200": 54 | // content: 55 | // application/json: 56 | // schema: 57 | // properties: 58 | // secret: 59 | // type: string 60 | // type: object 61 | // description: OK 62 | // "401": 63 | // content: 64 | // application/json: 65 | // schema: 66 | // properties: 67 | // error: 68 | // type: string 69 | // type: object 70 | // description: Unauthorized 71 | // security: 72 | // - admin: [] 73 | // components: 74 | // securitySchemes: 75 | // admin: 76 | // description: Admin Access 77 | // scheme: basic 78 | // type: http 79 | } 80 | 81 | func ExampleSpec_SetAPIKeySecurity() { 82 | reflector := openapi31.Reflector{} 83 | securityName := "api_key" 84 | 85 | // Declare security scheme. 86 | reflector.SpecEns().SetAPIKeySecurity(securityName, "Authorization", 87 | openapi.InHeader, "API Access") 88 | 89 | oc, _ := reflector.NewOperationContext(http.MethodGet, "/secure") 90 | oc.AddRespStructure(struct { 91 | Secret string `json:"secret"` 92 | }{}) 93 | 94 | // Add security requirement to operation. 95 | oc.AddSecurity(securityName) 96 | 97 | // Describe unauthorized response. 98 | oc.AddRespStructure(struct { 99 | Error string `json:"error"` 100 | }{}, func(cu *openapi.ContentUnit) { 101 | cu.HTTPStatus = http.StatusUnauthorized 102 | }) 103 | 104 | // Add operation to schema. 105 | _ = reflector.AddOperation(oc) 106 | 107 | schema, err := reflector.Spec.MarshalYAML() 108 | if err != nil { 109 | log.Fatal(err) 110 | } 111 | 112 | fmt.Println(string(schema)) 113 | 114 | // Output: 115 | // openapi: 3.1.0 116 | // info: 117 | // title: "" 118 | // version: "" 119 | // paths: 120 | // /secure: 121 | // get: 122 | // responses: 123 | // "200": 124 | // content: 125 | // application/json: 126 | // schema: 127 | // properties: 128 | // secret: 129 | // type: string 130 | // type: object 131 | // description: OK 132 | // "401": 133 | // content: 134 | // application/json: 135 | // schema: 136 | // properties: 137 | // error: 138 | // type: string 139 | // type: object 140 | // description: Unauthorized 141 | // security: 142 | // - api_key: [] 143 | // components: 144 | // securitySchemes: 145 | // api_key: 146 | // description: API Access 147 | // in: header 148 | // name: Authorization 149 | // type: apiKey 150 | } 151 | 152 | func ExampleSpec_SetHTTPBearerTokenSecurity() { 153 | reflector := openapi31.Reflector{} 154 | securityName := "bearer_token" 155 | 156 | // Declare security scheme. 157 | reflector.SpecEns().SetHTTPBearerTokenSecurity(securityName, "JWT", "Admin Access") 158 | 159 | oc, _ := reflector.NewOperationContext(http.MethodGet, "/secure") 160 | oc.AddRespStructure(struct { 161 | Secret string `json:"secret"` 162 | }{}) 163 | 164 | // Add security requirement to operation. 165 | oc.AddSecurity(securityName) 166 | 167 | // Describe unauthorized response. 168 | oc.AddRespStructure(struct { 169 | Error string `json:"error"` 170 | }{}, func(cu *openapi.ContentUnit) { 171 | cu.HTTPStatus = http.StatusUnauthorized 172 | }) 173 | 174 | // Add operation to schema. 175 | _ = reflector.AddOperation(oc) 176 | 177 | schema, err := reflector.Spec.MarshalYAML() 178 | if err != nil { 179 | log.Fatal(err) 180 | } 181 | 182 | fmt.Println(string(schema)) 183 | 184 | // Output: 185 | // openapi: 3.1.0 186 | // info: 187 | // title: "" 188 | // version: "" 189 | // paths: 190 | // /secure: 191 | // get: 192 | // responses: 193 | // "200": 194 | // content: 195 | // application/json: 196 | // schema: 197 | // properties: 198 | // secret: 199 | // type: string 200 | // type: object 201 | // description: OK 202 | // "401": 203 | // content: 204 | // application/json: 205 | // schema: 206 | // properties: 207 | // error: 208 | // type: string 209 | // type: object 210 | // description: Unauthorized 211 | // security: 212 | // - bearer_token: [] 213 | // components: 214 | // securitySchemes: 215 | // bearer_token: 216 | // bearerFormat: JWT 217 | // description: Admin Access 218 | // scheme: bearer 219 | // type: http 220 | } 221 | -------------------------------------------------------------------------------- /openapi31/example_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/swaggest/openapi-go" 10 | "github.com/swaggest/openapi-go/openapi31" 11 | ) 12 | 13 | func handleError(err error) { 14 | if err != nil { 15 | log.Fatal(err) 16 | } 17 | } 18 | 19 | func ExampleReflector_AddOperation() { 20 | reflector := openapi31.NewReflector() 21 | reflector.Spec.Info. 22 | WithTitle("Things API"). 23 | WithVersion("1.2.3"). 24 | WithDescription("Put something here") 25 | 26 | type req struct { 27 | ID string `path:"id" example:"XXX-XXXXX"` 28 | Locale string `query:"locale" pattern:"^[a-z]{2}-[A-Z]{2}$"` 29 | Title string `json:"string"` 30 | Amount uint `json:"amount"` 31 | Items []struct { 32 | Count uint `json:"count"` 33 | Name string `json:"name"` 34 | } `json:"items,omitempty"` 35 | } 36 | 37 | type resp struct { 38 | ID string `json:"id" example:"XXX-XXXXX"` 39 | Amount uint `json:"amount"` 40 | Items []struct { 41 | Count uint `json:"count"` 42 | Name string `json:"name"` 43 | } `json:"items,omitempty"` 44 | UpdatedAt time.Time `json:"updated_at"` 45 | } 46 | 47 | putOp, _ := reflector.NewOperationContext(http.MethodPut, "/things/{id}") 48 | 49 | putOp.AddReqStructure(new(req)) 50 | putOp.AddRespStructure(new(resp)) 51 | putOp.AddRespStructure(new([]resp), openapi.WithHTTPStatus(http.StatusConflict)) 52 | handleError(reflector.AddOperation(putOp)) 53 | 54 | getOp, _ := reflector.NewOperationContext(http.MethodGet, "/things/{id}") 55 | getOp.AddReqStructure(new(req)) 56 | getOp.AddRespStructure(new(resp)) 57 | handleError(reflector.AddOperation(getOp)) 58 | 59 | schema, err := reflector.Spec.MarshalYAML() 60 | if err != nil { 61 | log.Fatal(err) 62 | } 63 | 64 | fmt.Println(string(schema)) 65 | 66 | // Output: 67 | // openapi: 3.1.0 68 | // info: 69 | // description: Put something here 70 | // title: Things API 71 | // version: 1.2.3 72 | // paths: 73 | // /things/{id}: 74 | // get: 75 | // parameters: 76 | // - in: query 77 | // name: locale 78 | // schema: 79 | // pattern: ^[a-z]{2}-[A-Z]{2}$ 80 | // type: string 81 | // - in: path 82 | // name: id 83 | // required: true 84 | // schema: 85 | // examples: 86 | // - XXX-XXXXX 87 | // type: string 88 | // responses: 89 | // "200": 90 | // content: 91 | // application/json: 92 | // schema: 93 | // $ref: '#/components/schemas/Openapi31TestResp' 94 | // description: OK 95 | // put: 96 | // parameters: 97 | // - in: query 98 | // name: locale 99 | // schema: 100 | // pattern: ^[a-z]{2}-[A-Z]{2}$ 101 | // type: string 102 | // - in: path 103 | // name: id 104 | // required: true 105 | // schema: 106 | // examples: 107 | // - XXX-XXXXX 108 | // type: string 109 | // requestBody: 110 | // content: 111 | // application/json: 112 | // schema: 113 | // $ref: '#/components/schemas/Openapi31TestReq' 114 | // responses: 115 | // "200": 116 | // content: 117 | // application/json: 118 | // schema: 119 | // $ref: '#/components/schemas/Openapi31TestResp' 120 | // description: OK 121 | // "409": 122 | // content: 123 | // application/json: 124 | // schema: 125 | // items: 126 | // $ref: '#/components/schemas/Openapi31TestResp' 127 | // type: 128 | // - "null" 129 | // - array 130 | // description: Conflict 131 | // components: 132 | // schemas: 133 | // Openapi31TestReq: 134 | // properties: 135 | // amount: 136 | // minimum: 0 137 | // type: integer 138 | // items: 139 | // items: 140 | // properties: 141 | // count: 142 | // minimum: 0 143 | // type: integer 144 | // name: 145 | // type: string 146 | // type: object 147 | // type: array 148 | // string: 149 | // type: string 150 | // type: object 151 | // Openapi31TestResp: 152 | // properties: 153 | // amount: 154 | // minimum: 0 155 | // type: integer 156 | // id: 157 | // examples: 158 | // - XXX-XXXXX 159 | // type: string 160 | // items: 161 | // items: 162 | // properties: 163 | // count: 164 | // minimum: 0 165 | // type: integer 166 | // name: 167 | // type: string 168 | // type: object 169 | // type: array 170 | // updated_at: 171 | // format: date-time 172 | // type: string 173 | // type: object 174 | } 175 | 176 | func ExampleReflector_AddOperation_queryObject() { 177 | reflector := openapi31.NewReflector() 178 | reflector.Spec.Info. 179 | WithTitle("Things API"). 180 | WithVersion("1.2.3"). 181 | WithDescription("Put something here") 182 | 183 | type jsonFilter struct { 184 | Foo string `json:"foo"` 185 | Bar int `json:"bar"` 186 | Deeper struct { 187 | Val string `json:"val"` 188 | } `json:"deeper"` 189 | } 190 | 191 | type deepObjectFilter struct { 192 | Baz bool `query:"baz"` 193 | Quux float64 `query:"quux"` 194 | Deeper struct { 195 | Val string `query:"val"` 196 | } `query:"deeper"` 197 | } 198 | 199 | type req struct { 200 | ID string `path:"id" example:"XXX-XXXXX"` 201 | Locale string `query:"locale" pattern:"^[a-z]{2}-[A-Z]{2}$"` 202 | // Object values can be serialized in JSON (with json field tags in the value struct). 203 | JSONFilter jsonFilter `query:"json_filter"` 204 | // Or as deepObject (with same field tag as parent, .e.g query). 205 | DeepObjectFilter deepObjectFilter `query:"deep_object_filter"` 206 | } 207 | 208 | getOp, _ := reflector.NewOperationContext(http.MethodGet, "/things/{id}") 209 | 210 | getOp.AddReqStructure(new(req)) 211 | _ = reflector.AddOperation(getOp) 212 | 213 | schema, err := reflector.Spec.MarshalYAML() 214 | if err != nil { 215 | log.Fatal(err) 216 | } 217 | 218 | fmt.Println(string(schema)) 219 | 220 | // Output: 221 | // openapi: 3.1.0 222 | // info: 223 | // description: Put something here 224 | // title: Things API 225 | // version: 1.2.3 226 | // paths: 227 | // /things/{id}: 228 | // get: 229 | // parameters: 230 | // - in: query 231 | // name: locale 232 | // schema: 233 | // pattern: ^[a-z]{2}-[A-Z]{2}$ 234 | // type: string 235 | // - content: 236 | // application/json: 237 | // schema: 238 | // $ref: '#/components/schemas/Openapi31TestJsonFilter' 239 | // in: query 240 | // name: json_filter 241 | // - explode: true 242 | // in: query 243 | // name: deep_object_filter 244 | // schema: 245 | // $ref: '#/components/schemas/Openapi31TestDeepObjectFilter' 246 | // style: deepObject 247 | // - in: path 248 | // name: id 249 | // required: true 250 | // schema: 251 | // examples: 252 | // - XXX-XXXXX 253 | // type: string 254 | // responses: 255 | // "204": 256 | // description: No Content 257 | // components: 258 | // schemas: 259 | // Openapi31TestDeepObjectFilter: 260 | // properties: 261 | // baz: 262 | // type: boolean 263 | // deeper: 264 | // properties: 265 | // val: 266 | // type: string 267 | // type: object 268 | // quux: 269 | // format: double 270 | // type: number 271 | // type: object 272 | // Openapi31TestJsonFilter: 273 | // properties: 274 | // bar: 275 | // type: integer 276 | // deeper: 277 | // properties: 278 | // val: 279 | // type: string 280 | // type: object 281 | // foo: 282 | // type: string 283 | // type: object 284 | } 285 | -------------------------------------------------------------------------------- /openapi31/helper.go: -------------------------------------------------------------------------------- 1 | package openapi31 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/swaggest/openapi-go" 11 | ) 12 | 13 | // ToParameterOrRef exposes Parameter in general form. 14 | func (p Parameter) ToParameterOrRef() ParameterOrReference { 15 | return ParameterOrReference{ 16 | Parameter: &p, 17 | } 18 | } 19 | 20 | // Operation retrieves method Operation from PathItem. 21 | func (p PathItem) Operation(method string) (*Operation, error) { 22 | switch strings.ToUpper(method) { 23 | case http.MethodGet: 24 | return p.Get, nil 25 | case http.MethodPut: 26 | return p.Put, nil 27 | case http.MethodPost: 28 | return p.Post, nil 29 | case http.MethodDelete: 30 | return p.Delete, nil 31 | case http.MethodOptions: 32 | return p.Options, nil 33 | case http.MethodHead: 34 | return p.Head, nil 35 | case http.MethodPatch: 36 | return p.Patch, nil 37 | case http.MethodTrace: 38 | return p.Trace, nil 39 | default: 40 | return nil, fmt.Errorf("unexpected http method: %s", method) 41 | } 42 | } 43 | 44 | // SetOperation sets a method Operation to PathItem. 45 | func (p *PathItem) SetOperation(method string, op *Operation) error { 46 | method = strings.ToUpper(method) 47 | 48 | switch method { 49 | case http.MethodGet: 50 | p.Get = op 51 | case http.MethodPut: 52 | p.Put = op 53 | case http.MethodPost: 54 | p.Post = op 55 | case http.MethodDelete: 56 | p.Delete = op 57 | case http.MethodOptions: 58 | p.Options = op 59 | case http.MethodHead: 60 | p.Head = op 61 | case http.MethodPatch: 62 | p.Patch = op 63 | case http.MethodTrace: 64 | p.Trace = op 65 | default: 66 | return fmt.Errorf("unexpected http method: %s", method) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // SetupOperation creates operation if it is not present and applies setup functions. 73 | func (s *Spec) SetupOperation(method, path string, setup ...func(*Operation) error) error { 74 | method, path, pathParams, err := openapi.SanitizeMethodPath(method, path) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | pathItem := s.PathsEns().MapOfPathItemValues[path] 80 | 81 | operation, err := pathItem.Operation(method) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | if operation == nil { 87 | operation = &Operation{} 88 | } 89 | 90 | for _, f := range setup { 91 | if err := f(operation); err != nil { 92 | return err 93 | } 94 | } 95 | 96 | pathParamsMap := make(map[string]bool, len(pathParams)) 97 | for _, p := range pathParams { 98 | pathParamsMap[p] = true 99 | } 100 | 101 | if err := operation.validatePathParams(pathParamsMap); err != nil { 102 | return err 103 | } 104 | 105 | if err := pathItem.SetOperation(method, operation); err != nil { 106 | return err 107 | } 108 | 109 | s.PathsEns().WithMapOfPathItemValuesItem(path, pathItem) 110 | 111 | return nil 112 | } 113 | 114 | func (o *Operation) validatePathParams(pathParams map[string]bool) error { 115 | paramIndex := make(map[string]bool, len(o.Parameters)) 116 | 117 | var errs []string 118 | 119 | for _, p := range o.Parameters { 120 | if p.Parameter == nil { 121 | continue 122 | } 123 | 124 | if found := paramIndex[p.Parameter.Name+string(p.Parameter.In)]; found { 125 | errs = append(errs, "duplicate parameter in "+string(p.Parameter.In)+": "+p.Parameter.Name) 126 | 127 | continue 128 | } 129 | 130 | if found := pathParams[p.Parameter.Name]; !found && p.Parameter.In == ParameterInPath { 131 | errs = append(errs, "missing path parameter placeholder in url: "+p.Parameter.Name) 132 | 133 | continue 134 | } 135 | 136 | paramIndex[p.Parameter.Name+string(p.Parameter.In)] = true 137 | } 138 | 139 | for pathParam := range pathParams { 140 | if !paramIndex[pathParam+string(ParameterInPath)] { 141 | errs = append(errs, "undefined path parameter: "+pathParam) 142 | } 143 | } 144 | 145 | if len(errs) > 0 { 146 | return errors.New(strings.Join(errs, ", ")) 147 | } 148 | 149 | return nil 150 | } 151 | 152 | // AddOperation validates and sets operation by path and method. 153 | // 154 | // It will fail if operation with method and path already exists. 155 | func (s *Spec) AddOperation(method, path string, operation Operation) error { 156 | method = strings.ToLower(method) 157 | 158 | pathItem := s.PathsEns().MapOfPathItemValues[path] 159 | 160 | op, err := pathItem.Operation(method) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | if op != nil { 166 | return fmt.Errorf("operation already exists: %s %s", method, path) 167 | } 168 | 169 | // Add "No Content" response if there are no responses configured. 170 | if len(operation.ResponsesEns().MapOfResponseOrReferenceValues) == 0 && operation.Responses.Default == nil { 171 | operation.Responses.WithMapOfResponseOrReferenceValuesItem(strconv.Itoa(http.StatusNoContent), ResponseOrReference{ 172 | Response: &Response{ 173 | Description: http.StatusText(http.StatusNoContent), 174 | }, 175 | }) 176 | } 177 | 178 | return s.SetupOperation(method, path, func(op *Operation) error { 179 | *op = operation 180 | 181 | return nil 182 | }) 183 | } 184 | 185 | // UnknownParamIsForbidden indicates forbidden unknown parameters. 186 | func (o Operation) UnknownParamIsForbidden(in ParameterIn) bool { 187 | f, ok := o.MapOfAnything[xForbidUnknown+string(in)].(bool) 188 | 189 | return f && ok 190 | } 191 | 192 | var _ openapi.SpecSchema = &Spec{} 193 | 194 | // Title returns service title. 195 | func (s *Spec) Title() string { 196 | return s.Info.Title 197 | } 198 | 199 | // SetTitle describes the service. 200 | func (s *Spec) SetTitle(t string) { 201 | s.Info.Title = t 202 | } 203 | 204 | // Description returns service description. 205 | func (s *Spec) Description() string { 206 | if s.Info.Description != nil { 207 | return *s.Info.Description 208 | } 209 | 210 | return "" 211 | } 212 | 213 | // SetDescription describes the service. 214 | func (s *Spec) SetDescription(d string) { 215 | s.Info.WithDescription(d) 216 | } 217 | 218 | // Version returns service version. 219 | func (s *Spec) Version() string { 220 | return s.Info.Version 221 | } 222 | 223 | // SetVersion describes the service. 224 | func (s *Spec) SetVersion(v string) { 225 | s.Info.Version = v 226 | } 227 | 228 | // SetHTTPBasicSecurity sets security definition. 229 | func (s *Spec) SetHTTPBasicSecurity(securityName string, description string) { 230 | s.ComponentsEns().WithSecuritySchemesItem( 231 | securityName, 232 | SecuritySchemeOrReference{ 233 | SecurityScheme: (&SecurityScheme{ 234 | HTTP: (&SecuritySchemeHTTP{}).WithScheme("basic"), 235 | }).WithDescription(description), 236 | }, 237 | ) 238 | } 239 | 240 | // SetAPIKeySecurity sets security definition. 241 | func (s *Spec) SetAPIKeySecurity(securityName string, fieldName string, fieldIn openapi.In, description string) { 242 | s.ComponentsEns().WithSecuritySchemesItem( 243 | securityName, 244 | SecuritySchemeOrReference{ 245 | SecurityScheme: (&SecurityScheme{ 246 | APIKey: (&SecuritySchemeAPIKey{}). 247 | WithName(fieldName). 248 | WithIn(SecuritySchemeAPIKeyIn(fieldIn)), 249 | }).WithDescription(description), 250 | }, 251 | ) 252 | } 253 | 254 | // SetHTTPBearerTokenSecurity sets security definition. 255 | func (s *Spec) SetHTTPBearerTokenSecurity(securityName string, format string, description string) { 256 | ss := (&SecurityScheme{ 257 | HTTPBearer: (&SecuritySchemeHTTPBearer{}). 258 | WithScheme("bearer"), 259 | }).WithDescription(description) 260 | 261 | if format != "" { 262 | ss.HTTPBearer.WithBearerFormat(format) 263 | } 264 | 265 | s.ComponentsEns().WithSecuritySchemesItem( 266 | securityName, 267 | SecuritySchemeOrReference{ 268 | SecurityScheme: ss, 269 | }, 270 | ) 271 | } 272 | 273 | // SetReference sets a reference and discards existing content. 274 | func (r *ResponseOrReference) SetReference(ref string) { 275 | r.ReferenceEns().Ref = ref 276 | r.Response = nil 277 | } 278 | 279 | // SetReference sets a reference and discards existing content. 280 | func (r *RequestBodyOrReference) SetReference(ref string) { 281 | r.ReferenceEns().Ref = ref 282 | r.RequestBody = nil 283 | } 284 | -------------------------------------------------------------------------------- /openapi31/helper_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/swaggest/assertjson" 9 | "github.com/swaggest/openapi-go/openapi31" 10 | ) 11 | 12 | func TestSpec_SetOperation(t *testing.T) { 13 | s := openapi31.Spec{} 14 | op := openapi31.Operation{} 15 | 16 | op.WithParameters( 17 | openapi31.Parameter{In: openapi31.ParameterInPath, Name: "foo"}.ToParameterOrRef(), 18 | ) 19 | 20 | require.EqualError(t, s.AddOperation("bar", "/", op), 21 | "unexpected http method: bar") 22 | 23 | require.EqualError(t, s.AddOperation(http.MethodGet, "/", op), 24 | "missing path parameter placeholder in url: foo") 25 | 26 | require.EqualError(t, s.AddOperation(http.MethodGet, "/{bar}", op), 27 | "missing path parameter placeholder in url: foo, undefined path parameter: bar") 28 | 29 | require.NoError(t, s.AddOperation(http.MethodGet, "/{foo}", op)) 30 | 31 | require.EqualError(t, s.AddOperation(http.MethodGet, "/{foo}", op), 32 | "operation already exists: get /{foo}") 33 | 34 | op.WithParameters( 35 | openapi31.Parameter{In: openapi31.ParameterInPath, Name: "foo"}.ToParameterOrRef(), 36 | openapi31.Parameter{In: openapi31.ParameterInPath, Name: "foo"}.ToParameterOrRef(), 37 | openapi31.Parameter{In: openapi31.ParameterInQuery, Name: "bar"}.ToParameterOrRef(), 38 | openapi31.Parameter{In: openapi31.ParameterInQuery, Name: "bar"}.ToParameterOrRef(), 39 | ) 40 | 41 | require.EqualError(t, s.AddOperation(http.MethodGet, "/another/{foo}", op), 42 | "duplicate parameter in path: foo, duplicate parameter in query: bar") 43 | } 44 | 45 | func TestSpec_SetupOperation_pathRegex(t *testing.T) { 46 | s := openapi31.Spec{} 47 | 48 | for _, tc := range []struct { 49 | path string 50 | params []string 51 | }{ 52 | {`/{month}-{day}-{year}`, []string{"month", "day", "year"}}, 53 | {`/{month}/{day}/{year}`, []string{"month", "day", "year"}}, 54 | {`/{month:[\d]+}-{day:[\d]+}-{year:[\d]+}`, []string{"month", "day", "year"}}, 55 | {`/{articleSlug:[a-z-]+}`, []string{"articleSlug"}}, 56 | {"/articles/{rid:^[0-9]{5,6}}", []string{"rid"}}, 57 | {"/articles/{rid:^[0-9]{5,6}}/{zid:^[0-9]{5,6}}", []string{"rid", "zid"}}, 58 | {"/articles/{zid:^0[0-9]+}", []string{"zid"}}, 59 | {"/articles/{name:^@[a-z]+}/posts", []string{"name"}}, 60 | {"/articles/{op:^[0-9]+}/run", []string{"op"}}, 61 | {"/users/{userID:[^/]+}", []string{"userID"}}, 62 | {"/users/{userID:[^/]+}/books/{bookID:.+}", []string{"userID", "bookID"}}, 63 | } { 64 | t.Run(tc.path, func(t *testing.T) { 65 | require.NoError(t, s.SetupOperation(http.MethodGet, tc.path, 66 | func(operation *openapi31.Operation) error { 67 | var pp []openapi31.ParameterOrReference 68 | 69 | for _, p := range tc.params { 70 | pp = append(pp, openapi31.Parameter{In: openapi31.ParameterInPath, Name: p}.ToParameterOrRef()) 71 | } 72 | 73 | operation.WithParameters(pp...) 74 | 75 | return nil 76 | }, 77 | )) 78 | }) 79 | } 80 | } 81 | 82 | func TestSpec_SetupOperation_uncleanPath(t *testing.T) { 83 | s := openapi31.Spec{} 84 | f := func(operation *openapi31.Operation) error { 85 | operation.WithParameters(openapi31.Parameter{In: openapi31.ParameterInPath, Name: "userID"}.ToParameterOrRef()) 86 | 87 | return nil 88 | } 89 | 90 | require.NoError(t, s.SetupOperation(http.MethodGet, "/users/{userID:[^/]+}", f)) 91 | require.NoError(t, s.SetupOperation(http.MethodPost, "/users/{userID:[^/]+}", f)) 92 | 93 | assertjson.EqualMarshal(t, []byte(`{ 94 | "openapi":"","info":{"title":"","version":""}, 95 | "paths":{ 96 | "/users/{userID}":{ 97 | "get":{"parameters":[{"name":"userID","in":"path"}]}, 98 | "post":{"parameters":[{"name":"userID","in":"path"}]} 99 | } 100 | } 101 | }`), s) 102 | } 103 | -------------------------------------------------------------------------------- /openapi31/jsonschema.go: -------------------------------------------------------------------------------- 1 | package openapi31 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/swaggest/jsonschema-go" 7 | ) 8 | 9 | func isDeprecated(schema jsonschema.SchemaOrBool) *bool { 10 | if schema.TypeObject == nil { 11 | return nil 12 | } 13 | 14 | return schema.TypeObject.Deprecated 15 | } 16 | 17 | type toJSONSchemaContext struct { 18 | refsProcessed map[string]jsonschema.SchemaOrBool 19 | refsCount map[string]int 20 | spec *Spec 21 | } 22 | 23 | // ToJSONSchema converts OpenAPI Schema to JSON Schema. 24 | // 25 | // Local references are resolved against `#/components/schemas` in spec. 26 | func ToJSONSchema(s map[string]interface{}, spec *Spec) jsonschema.SchemaOrBool { 27 | js := jsonschema.SchemaOrBool{} 28 | 29 | if err := js.FromSimpleMap(s); err != nil { 30 | panic(err.Error()) 31 | } 32 | 33 | ctx := toJSONSchemaContext{ 34 | refsProcessed: map[string]jsonschema.SchemaOrBool{}, 35 | refsCount: map[string]int{}, 36 | spec: spec, 37 | } 38 | 39 | findReferences(js, ctx) 40 | 41 | // Inline root reference without recursions. 42 | if js.TypeObjectEns().Ref != nil { 43 | dstName := strings.TrimPrefix(*js.TypeObjectEns().Ref, componentsSchemas) 44 | if ctx.refsCount[dstName] == 1 { 45 | js = ctx.refsProcessed[dstName] 46 | delete(ctx.refsProcessed, dstName) 47 | } 48 | } 49 | 50 | if len(ctx.refsProcessed) > 0 { 51 | js.TypeObjectEns().WithExtraPropertiesItem( 52 | "components", 53 | map[string]interface{}{"schemas": ctx.refsProcessed}, 54 | ) 55 | } 56 | 57 | return js 58 | } 59 | 60 | func findReferences(js jsonschema.SchemaOrBool, ctx toJSONSchemaContext) { 61 | if js.TypeBoolean != nil { 62 | return 63 | } 64 | 65 | jso := js.TypeObjectEns() 66 | 67 | if jso.Ref != nil { //nolint:nestif 68 | if strings.HasPrefix(*jso.Ref, componentsSchemas) { 69 | dstName := strings.TrimPrefix(*jso.Ref, componentsSchemas) 70 | 71 | if _, alreadyProcessed := ctx.refsProcessed[dstName]; !alreadyProcessed { 72 | ctx.refsProcessed[dstName] = jsonschema.SchemaOrBool{} 73 | 74 | dst := ctx.spec.Components.Schemas[dstName] 75 | 76 | js := jsonschema.SchemaOrBool{} 77 | if err := js.FromSimpleMap(dst); err != nil { 78 | panic("BUG: " + err.Error()) 79 | } 80 | 81 | ctx.refsProcessed[dstName] = js 82 | findReferences(js, ctx) 83 | } 84 | 85 | ctx.refsCount[dstName]++ 86 | } 87 | 88 | return 89 | } 90 | 91 | if jso.Not != nil { 92 | findReferences(*jso.Not, ctx) 93 | } 94 | 95 | for _, allOf := range jso.AllOf { 96 | findReferences(allOf, ctx) 97 | } 98 | 99 | for _, oneOf := range jso.OneOf { 100 | findReferences(oneOf, ctx) 101 | } 102 | 103 | for _, anyOf := range jso.AnyOf { 104 | findReferences(anyOf, ctx) 105 | } 106 | 107 | if jso.Items != nil { 108 | if jso.Items.SchemaOrBool != nil { 109 | findReferences(*jso.Items.SchemaOrBool, ctx) 110 | } 111 | 112 | for _, item := range jso.Items.SchemaArray { 113 | findReferences(item, ctx) 114 | } 115 | } 116 | 117 | for _, propSchema := range jso.Properties { 118 | findReferences(propSchema, ctx) 119 | } 120 | 121 | if jso.AdditionalProperties != nil { 122 | findReferences(*jso.AdditionalProperties, ctx) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /openapi31/security_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/swaggest/assertjson" 9 | "github.com/swaggest/openapi-go" 10 | "github.com/swaggest/openapi-go/openapi31" 11 | ) 12 | 13 | func TestSpec_SetHTTPBasicSecurity(t *testing.T) { 14 | reflector := openapi31.Reflector{} 15 | securityName := "admin" 16 | 17 | // Declare security scheme. 18 | reflector.SpecEns().SetHTTPBasicSecurity(securityName, "Admin Access") 19 | 20 | oc, err := reflector.NewOperationContext(http.MethodGet, "/secure") 21 | require.NoError(t, err) 22 | oc.AddRespStructure(struct { 23 | Secret string `json:"secret"` 24 | }{}) 25 | 26 | // Add security requirement to operation. 27 | oc.AddSecurity(securityName) 28 | 29 | // Describe unauthorized response. 30 | oc.AddRespStructure(struct { 31 | Error string `json:"error"` 32 | }{}, func(cu *openapi.ContentUnit) { 33 | cu.HTTPStatus = http.StatusUnauthorized 34 | }) 35 | 36 | // Add operation to schema. 37 | require.NoError(t, reflector.AddOperation(oc)) 38 | 39 | assertjson.EqMarshal(t, `{ 40 | "openapi":"3.1.0","info":{"title":"","version":""}, 41 | "paths":{ 42 | "/secure":{ 43 | "get":{ 44 | "responses":{ 45 | "200":{ 46 | "description":"OK", 47 | "content":{ 48 | "application/json":{ 49 | "schema":{"properties":{"secret":{"type":"string"}},"type":"object"} 50 | } 51 | } 52 | }, 53 | "401":{ 54 | "description":"Unauthorized", 55 | "content":{ 56 | "application/json":{ 57 | "schema":{"properties":{"error":{"type":"string"}},"type":"object"} 58 | } 59 | } 60 | } 61 | }, 62 | "security":[{"admin":[]}] 63 | } 64 | } 65 | }, 66 | "components":{ 67 | "securitySchemes":{"admin":{"description":"Admin Access","type":"http","scheme":"basic"}} 68 | } 69 | }`, reflector.SpecSchema()) 70 | } 71 | 72 | func TestSpec_SetAPIKeySecurity(t *testing.T) { 73 | reflector := openapi31.Reflector{} 74 | securityName := "admin" 75 | 76 | // Declare security scheme. 77 | reflector.SpecEns().SetAPIKeySecurity("User", "sessid", "cookie", "Session cookie.") 78 | 79 | oc, err := reflector.NewOperationContext(http.MethodGet, "/secure") 80 | require.NoError(t, err) 81 | oc.AddRespStructure(struct { 82 | Secret string `json:"secret"` 83 | }{}) 84 | 85 | // Add security requirement to operation. 86 | oc.AddSecurity(securityName) 87 | 88 | // Describe unauthorized response. 89 | oc.AddRespStructure(struct { 90 | Error string `json:"error"` 91 | }{}, func(cu *openapi.ContentUnit) { 92 | cu.HTTPStatus = http.StatusUnauthorized 93 | }) 94 | 95 | // Add operation to schema. 96 | require.NoError(t, reflector.AddOperation(oc)) 97 | 98 | assertjson.EqMarshal(t, `{ 99 | "openapi":"3.1.0","info":{"title":"","version":""}, 100 | "paths":{ 101 | "/secure":{ 102 | "get":{ 103 | "responses":{ 104 | "200":{ 105 | "description":"OK", 106 | "content":{ 107 | "application/json":{ 108 | "schema":{"properties":{"secret":{"type":"string"}},"type":"object"} 109 | } 110 | } 111 | }, 112 | "401":{ 113 | "description":"Unauthorized", 114 | "content":{ 115 | "application/json":{ 116 | "schema":{"properties":{"error":{"type":"string"}},"type":"object"} 117 | } 118 | } 119 | } 120 | }, 121 | "security":[{"admin":[]}] 122 | } 123 | } 124 | }, 125 | "components":{ 126 | "securitySchemes":{ 127 | "User":{ 128 | "description":"Session cookie.","type":"apiKey","name":"sessid", 129 | "in":"cookie" 130 | } 131 | } 132 | } 133 | }`, reflector.SpecSchema()) 134 | } 135 | -------------------------------------------------------------------------------- /openapi31/testdata/albums_api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | description: This services keeps track of albums. 4 | title: Albums API 5 | version: v1.2.3 6 | paths: 7 | /albums: 8 | get: 9 | responses: 10 | "200": 11 | content: 12 | application/json: 13 | schema: 14 | items: 15 | $ref: '#/components/schemas/Album' 16 | type: array 17 | description: OK 18 | summary: List albums 19 | tags: 20 | - Albums 21 | post: 22 | requestBody: 23 | content: 24 | application/json: 25 | schema: 26 | $ref: '#/components/schemas/Album' 27 | responses: 28 | "200": 29 | content: 30 | application/json: 31 | schema: 32 | $ref: '#/components/schemas/Album' 33 | description: OK 34 | summary: Create an album 35 | tags: 36 | - Albums 37 | /albums/{id}: 38 | get: 39 | parameters: 40 | - in: path 41 | name: id 42 | required: true 43 | schema: 44 | type: string 45 | responses: 46 | "200": 47 | content: 48 | application/json: 49 | schema: 50 | $ref: '#/components/schemas/Album' 51 | description: OK 52 | "404": 53 | content: 54 | application/json: 55 | schema: 56 | properties: 57 | message: 58 | type: string 59 | type: object 60 | description: Not Found 61 | summary: Get album 62 | tags: 63 | - Albums 64 | components: 65 | schemas: 66 | Album: 67 | properties: 68 | artist: 69 | type: string 70 | id: 71 | type: string 72 | price: 73 | type: number 74 | title: 75 | type: string 76 | type: object 77 | -------------------------------------------------------------------------------- /openapi31/testdata/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.1.0","info":{"title":"SampleAPI","version":"1.2.3"}, 3 | "paths":{ 4 | "/somewhere/{in_path}":{ 5 | "summary":"Path Summary","description":"Path Description", 6 | "get":{ 7 | "parameters":[ 8 | { 9 | "name":"in_query1","in":"query","description":"Query parameter.","required":true, 10 | "schema":{"description":"Query parameter.","type":"integer"} 11 | }, 12 | { 13 | "name":"in_query3","in":"query","description":"Query parameter.","required":true, 14 | "schema":{"description":"Query parameter.","type":"integer"} 15 | }, 16 | {"name":"in_path","in":"path","required":true,"schema":{"type":"integer"}}, 17 | {"name":"in_cookie","in":"cookie","deprecated":true,"schema":{"deprecated":true,"type":"string"}}, 18 | {"name":"in_header","in":"header","schema":{"type":"number"}} 19 | ], 20 | "responses":{ 21 | "200":{ 22 | "description":"This is a sample response.", 23 | "headers":{ 24 | "X-Header-Field":{ 25 | "style":"simple","description":"Sample header response.", 26 | "schema":{"description":"Sample header response.","type":"string"} 27 | } 28 | }, 29 | "content":{"application/json":{"schema":{"$ref":"#/components/schemas/Openapi31TestResp"}}} 30 | } 31 | } 32 | }, 33 | "post":{ 34 | "parameters":[ 35 | { 36 | "name":"in_query1","in":"query","description":"Query parameter.","required":true, 37 | "schema":{"description":"Query parameter.","type":"integer"} 38 | }, 39 | { 40 | "name":"in_query2","in":"query","description":"Query parameter.","required":true, 41 | "schema":{"description":"Query parameter.","type":"integer"} 42 | }, 43 | { 44 | "name":"in_query3","in":"query","description":"Query parameter.","required":true, 45 | "schema":{"description":"Query parameter.","type":"integer"} 46 | }, 47 | {"name":"array_csv","in":"query","schema":{"items":{"type":"string"},"type":["array","null"]},"explode":false}, 48 | { 49 | "name":"array_swg2_csv","in":"query","schema":{"items":{"type":"string"},"type":["array","null"]},"style":"form", 50 | "explode":false 51 | }, 52 | { 53 | "name":"array_swg2_ssv","in":"query","schema":{"items":{"type":"string"},"type":["array","null"]}, 54 | "style":"spaceDelimited","explode":false 55 | }, 56 | { 57 | "name":"array_swg2_pipes","in":"query","schema":{"items":{"type":"string"},"type":["array","null"]}, 58 | "style":"pipeDelimited","explode":false 59 | }, 60 | {"name":"in_path","in":"path","required":true,"schema":{"type":"integer"}}, 61 | {"name":"in_cookie","in":"cookie","deprecated":true,"schema":{"deprecated":true,"type":"string"}}, 62 | {"name":"in_header","in":"header","schema":{"type":"number"}}, 63 | {"name":"uuid","in":"header","schema":{"$ref":"#/components/schemas/Openapi31TestUUID"}} 64 | ], 65 | "requestBody":{ 66 | "content":{ 67 | "application/json":{"schema":{"$ref":"#/components/schemas/Openapi31TestReq"}}, 68 | "multipart/form-data":{"schema":{"$ref":"#/components/schemas/FormDataOpenapi31TestReq"}} 69 | } 70 | }, 71 | "responses":{ 72 | "200":{ 73 | "description":"This is a sample response.", 74 | "headers":{ 75 | "X-Header-Field":{ 76 | "style":"simple","description":"Sample header response.", 77 | "schema":{"description":"Sample header response.","type":"string"} 78 | } 79 | }, 80 | "content":{"application/json":{"schema":{"$ref":"#/components/schemas/Openapi31TestResp"}}} 81 | }, 82 | "409":{ 83 | "description":"Conflict", 84 | "content":{ 85 | "application/json":{"schema":{"items":{"$ref":"#/components/schemas/Openapi31TestResp"},"type":["null","array"]}}, 86 | "text/html":{"schema":{"type":"string"}} 87 | } 88 | } 89 | } 90 | } 91 | } 92 | }, 93 | "components":{ 94 | "schemas":{ 95 | "FormDataOpenapi31TestReq":{ 96 | "properties":{ 97 | "in_form1":{"type":"string"},"in_form2":{"type":"string"},"upload1":{"$ref":"#/components/schemas/MultipartFile"}, 98 | "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"} 99 | }, 100 | "type":"object" 101 | }, 102 | "MultipartFile":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}, 103 | "MultipartFileHeader":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}, 104 | "Openapi31TestReq":{"properties":{"in_body1":{"type":"integer"},"in_body2":{"type":"string"}},"type":"object"}, 105 | "Openapi31TestResp":{ 106 | "description":"This is a sample response.", 107 | "properties":{ 108 | "arrayOfAnything":{"items":{},"type":"array"},"field1":{"type":"integer"},"field2":{"type":"string"}, 109 | "info":{ 110 | "properties":{"bar":{"description":"This is Bar.","type":"number"},"foo":{"default":"baz","pattern":"\\d+","type":"string"}}, 111 | "required":["foo"],"type":"object" 112 | }, 113 | "map":{"additionalProperties":{"type":"integer"},"type":"object"}, 114 | "mapOfAnything":{"additionalProperties":{},"type":"object"},"nullableWhatever":{}, 115 | "parent":{"$ref":"#/components/schemas/Openapi31TestResp"}, 116 | "recursiveArray":{"items":{"$ref":"#/components/schemas/Openapi31TestResp"},"type":"array"}, 117 | "recursiveStructArray":{"items":{"$ref":"#/components/schemas/Openapi31TestResp"},"type":"array"}, 118 | "uuid":{"$ref":"#/components/schemas/Openapi31TestUUID"},"whatever":{} 119 | }, 120 | "title":"Sample Response","type":"object","x-foo":"bar" 121 | }, 122 | "Openapi31TestUUID":{"items":{"minimum":0,"type":"integer"},"type":["array","null"]} 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /openapi31/testdata/openapi_req.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.1.0","info":{"title":"SampleAPI","description":"This a sample API description.","version":"1.2.3"}, 3 | "paths":{ 4 | "/somewhere/{in_path}":{ 5 | "get":{ 6 | "parameters":[ 7 | { 8 | "name":"in_query1","in":"query","description":"Query parameter.","required":true, 9 | "schema":{"description":"Query parameter.","type":"integer"} 10 | }, 11 | { 12 | "name":"in_query3","in":"query","description":"Query parameter.","required":true, 13 | "schema":{"description":"Query parameter.","type":"integer"} 14 | }, 15 | {"name":"in_path","in":"path","required":true,"schema":{"type":"integer"}}, 16 | {"name":"in_cookie","in":"cookie","deprecated":true,"schema":{"deprecated":true,"type":"string"}}, 17 | {"name":"in_header","in":"header","schema":{"type":"number"}} 18 | ], 19 | "requestBody":{"description":"Request body in CSV format.","content":{"text/csv":{"schema":{"type":"string"}}}}, 20 | "responses":{"204":{"description":"No Content"}} 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /openapi31/testdata/openapi_req_array.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.1.0","info":{"title":"SampleAPI","version":"1.2.3"}, 3 | "paths":{ 4 | "/somewhere":{ 5 | "post":{ 6 | "requestBody":{ 7 | "content":{ 8 | "application/json":{"schema":{"items":{"$ref":"#/components/schemas/Openapi31TestGetReq"},"type":["null","array"]}} 9 | } 10 | }, 11 | "responses":{"204":{"description":"No Content"}} 12 | } 13 | } 14 | }, 15 | "components":{ 16 | "schemas":{ 17 | "Openapi31TestGetReq":{ 18 | "properties":{ 19 | "c":{"deprecated":true,"type":"string"},"h":{"type":"number"},"p":{"type":"integer"}, 20 | "q1":{"description":"Query parameter.","type":"integer"},"q3":{"description":"Query parameter.","type":"integer"} 21 | }, 22 | "required":["q1","q3"],"type":"object" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /openapi31/testdata/req_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties":{ 3 | "in_form1":{"type":"string"},"in_form2":{"type":"string"}, 4 | "upload1":{"$ref":"#/components/schemas/MultipartFile"}, 5 | "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"} 6 | }, 7 | "type":"object", 8 | "components":{ 9 | "schemas":{ 10 | "MultipartFile":{"type":"string","format":"binary","contentMediaType": "application/octet-stream"}, 11 | "MultipartFileHeader":{"type":"string","format":"binary","contentMediaType": "application/octet-stream"} 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /openapi31/testdata/resp_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref":"#/components/schemas/Openapi31TestResp", 3 | "components":{ 4 | "schemas":{ 5 | "Openapi31TestResp":{ 6 | "title":"Sample Response","description":"This is a sample response.", 7 | "properties":{ 8 | "arrayOfAnything":{"items":{},"type":"array"},"field1":{"type":"integer"},"field2":{"type":"string"}, 9 | "info":{ 10 | "required":["foo"], 11 | "properties":{"bar":{"description":"This is Bar.","type":"number"},"foo":{"default":"baz","pattern":"\\d+","type":"string"}}, 12 | "type":"object" 13 | }, 14 | "map":{"additionalProperties":{"type":"integer"},"type":"object"}, 15 | "mapOfAnything":{"additionalProperties":{},"type":"object"},"nullableWhatever":{}, 16 | "parent":{"$ref":"#/components/schemas/Openapi31TestResp"}, 17 | "recursiveArray":{"items":{"$ref":"#/components/schemas/Openapi31TestResp"},"type":"array"}, 18 | "recursiveStructArray":{"items":{"$ref":"#/components/schemas/Openapi31TestResp"},"type":"array"}, 19 | "uuid":{"$ref":"#/components/schemas/Openapi31TestUUID"},"whatever":{} 20 | }, 21 | "type":"object","x-foo":"bar" 22 | }, 23 | "Openapi31TestUUID":{"items":{"minimum":0,"type":"integer"},"type":["array","null"]} 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /openapi31/testdata/uploads.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi":"3.1.0","info":{"title":"","version":""}, 3 | "paths":{ 4 | "/upload":{ 5 | "post":{ 6 | "requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/FormDataOpenapi31TestReq"}}}}, 7 | "responses":{"204":{"description":"No Content"}} 8 | } 9 | } 10 | }, 11 | "components":{ 12 | "schemas":{ 13 | "FormDataOpenapi31TestReq":{ 14 | "properties":{ 15 | "upload1":{"$ref":"#/components/schemas/MultipartFile"}, 16 | "upload2":{"$ref":"#/components/schemas/MultipartFileHeader"}, 17 | "uploads3":{"items":{"$ref":"#/components/schemas/MultipartFile"},"type":"array"}, 18 | "uploads4":{"items":{"$ref":"#/components/schemas/MultipartFileHeader"},"type":"array"} 19 | }, 20 | "type":"object" 21 | }, 22 | "MultipartFile":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}, 23 | "MultipartFileHeader":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"} 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /openapi31/upload_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "mime/multipart" 5 | "net/http" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "github.com/swaggest/assertjson" 11 | "github.com/swaggest/openapi-go/openapi31" 12 | ) 13 | 14 | func TestNewReflector_uploads(t *testing.T) { 15 | r := openapi31.NewReflector() 16 | 17 | oc, err := r.NewOperationContext(http.MethodPost, "/upload") 18 | require.NoError(t, err) 19 | 20 | type req struct { 21 | Upload1 multipart.File `formData:"upload1"` 22 | Upload2 *multipart.FileHeader `formData:"upload2"` 23 | Uploads3 []multipart.File `formData:"uploads3"` 24 | Uploads4 []*multipart.FileHeader `formData:"uploads4"` 25 | } 26 | 27 | oc.AddReqStructure(req{}) 28 | 29 | require.NoError(t, r.AddOperation(oc)) 30 | 31 | schema, err := assertjson.MarshalIndentCompact(r.SpecSchema(), "", " ", 120) 32 | require.NoError(t, err) 33 | 34 | require.NoError(t, os.WriteFile("testdata/uploads_last_run.json", schema, 0o600)) 35 | 36 | expected, err := os.ReadFile("testdata/uploads.json") 37 | require.NoError(t, err) 38 | 39 | assertjson.Equal(t, expected, schema) 40 | } 41 | -------------------------------------------------------------------------------- /openapi31/walk_schema.go: -------------------------------------------------------------------------------- 1 | package openapi31 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/swaggest/jsonschema-go" 8 | "github.com/swaggest/openapi-go" 9 | "github.com/swaggest/openapi-go/internal" 10 | ) 11 | 12 | // WalkResponseJSONSchemas provides JSON schemas for response structure. 13 | func (r *Reflector) WalkResponseJSONSchemas(cu openapi.ContentUnit, cb openapi.JSONSchemaCallback, done func(oc openapi.OperationContext)) error { 14 | oc := operationContext{ 15 | OperationContext: internal.NewOperationContext(http.MethodGet, "/"), 16 | op: &Operation{}, 17 | } 18 | 19 | oc.AddRespStructure(nil, func(c *openapi.ContentUnit) { 20 | *c = cu 21 | }) 22 | 23 | defer func() { 24 | if done != nil { 25 | done(oc) 26 | } 27 | }() 28 | 29 | op := oc.op 30 | 31 | if err := r.setupResponse(op, oc); err != nil { 32 | return err 33 | } 34 | 35 | var resp *Response 36 | 37 | for _, r := range op.Responses.MapOfResponseOrReferenceValues { 38 | resp = r.Response 39 | } 40 | 41 | if err := r.provideHeaderSchemas(resp, cb); err != nil { 42 | return err 43 | } 44 | 45 | for _, cont := range resp.Content { 46 | if cont.Schema == nil { 47 | continue 48 | } 49 | 50 | sm := ToJSONSchema(cont.Schema, r.Spec) 51 | 52 | if err := cb(openapi.InBody, "body", &sm, false); err != nil { 53 | return fmt.Errorf("response body schema: %w", err) 54 | } 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (r *Reflector) provideHeaderSchemas(resp *Response, cb openapi.JSONSchemaCallback) error { 61 | for name, h := range resp.Headers { 62 | if h.Header.Schema == nil { 63 | continue 64 | } 65 | 66 | hh := h.Header 67 | schema := ToJSONSchema(hh.Schema, r.Spec) 68 | 69 | required := false 70 | if hh.Required != nil && *hh.Required { 71 | required = true 72 | } 73 | 74 | name = http.CanonicalHeaderKey(name) 75 | 76 | if err := cb(openapi.InHeader, name, &schema, required); err != nil { 77 | return fmt.Errorf("response header schema (%s): %w", name, err) 78 | } 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // WalkRequestJSONSchemas iterates over request parameters of a ContentUnit and call user function for param schemas. 85 | func (r *Reflector) WalkRequestJSONSchemas( 86 | method string, 87 | cu openapi.ContentUnit, 88 | cb openapi.JSONSchemaCallback, 89 | done func(oc openapi.OperationContext), 90 | ) error { 91 | oc := operationContext{ 92 | OperationContext: internal.NewOperationContext(method, "/"), 93 | op: &Operation{}, 94 | } 95 | 96 | oc.AddReqStructure(nil, func(c *openapi.ContentUnit) { 97 | *c = cu 98 | }) 99 | 100 | defer func() { 101 | if done != nil { 102 | done(oc) 103 | } 104 | }() 105 | 106 | op := oc.op 107 | 108 | if err := r.setupRequest(op, oc); err != nil { 109 | return err 110 | } 111 | 112 | err := r.provideParametersJSONSchemas(op, cb) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | if op.RequestBody == nil || op.RequestBody.RequestBody == nil { 118 | return nil 119 | } 120 | 121 | for ct, content := range op.RequestBody.RequestBody.Content { 122 | schema := ToJSONSchema(content.Schema, r.Spec) 123 | 124 | if ct == mimeJSON { 125 | err = cb(openapi.InBody, "body", &schema, false) 126 | if err != nil { 127 | return fmt.Errorf("request body schema: %w", err) 128 | } 129 | } 130 | 131 | if ct == mimeFormUrlencoded { 132 | if err = provideFormDataSchemas(schema, cb); err != nil { 133 | return err 134 | } 135 | } 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func provideFormDataSchemas(schema jsonschema.SchemaOrBool, cb openapi.JSONSchemaCallback) error { 142 | for name, propertySchema := range schema.TypeObject.Properties { 143 | propertySchema := propertySchema 144 | 145 | if propertySchema.TypeObject != nil && len(schema.TypeObject.ExtraProperties) > 0 { 146 | cp := *propertySchema.TypeObject 147 | propertySchema.TypeObject = &cp 148 | propertySchema.TypeObject.ExtraProperties = schema.TypeObject.ExtraProperties 149 | } 150 | 151 | isRequired := false 152 | 153 | for _, req := range schema.TypeObject.Required { 154 | if req == name { 155 | isRequired = true 156 | 157 | break 158 | } 159 | } 160 | 161 | err := cb(openapi.InFormData, name, &propertySchema, isRequired) 162 | if err != nil { 163 | return fmt.Errorf("request body schema: %w", err) 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func (r *Reflector) provideParametersJSONSchemas(op *Operation, cb openapi.JSONSchemaCallback) error { 171 | for _, p := range op.Parameters { 172 | pp := p.Parameter 173 | 174 | required := false 175 | if pp.Required != nil && *pp.Required { 176 | required = true 177 | } 178 | 179 | sc := paramSchema(pp) 180 | 181 | if sc == nil { 182 | if err := cb(openapi.In(pp.In), pp.Name, nil, required); err != nil { 183 | return fmt.Errorf("schema for parameter (%s, %s): %w", pp.In, pp.Name, err) 184 | } 185 | 186 | continue 187 | } 188 | 189 | schema := ToJSONSchema(sc, r.Spec) 190 | 191 | if err := cb(openapi.In(pp.In), pp.Name, &schema, required); err != nil { 192 | return fmt.Errorf("schema for parameter (%s, %s): %w", pp.In, pp.Name, err) 193 | } 194 | } 195 | 196 | return nil 197 | } 198 | 199 | func paramSchema(p *Parameter) map[string]interface{} { 200 | sc := p.Schema 201 | 202 | if sc == nil { 203 | if jsc, ok := p.Content[mimeJSON]; ok { 204 | sc = jsc.Schema 205 | } 206 | } 207 | 208 | return sc 209 | } 210 | -------------------------------------------------------------------------------- /openapi31/walk_schema_test.go: -------------------------------------------------------------------------------- 1 | package openapi31_test 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/swaggest/assertjson" 11 | jsonschema "github.com/swaggest/jsonschema-go" 12 | "github.com/swaggest/openapi-go" 13 | "github.com/swaggest/openapi-go/openapi31" 14 | ) 15 | 16 | func TestReflector_WalkRequestJSONSchemas(t *testing.T) { 17 | r := openapi31.NewReflector() 18 | 19 | type Embed struct { 20 | Query1 int `query:"query1" minimum:"3"` 21 | Query2 string `query:"query2" minLength:"2"` 22 | Query3 bool `query:"query3" description:"Trivial schema."` 23 | } 24 | 25 | type DeeplyEmbedded struct { 26 | Embed 27 | } 28 | 29 | type req struct { 30 | *DeeplyEmbedded 31 | 32 | Path1 int `path:"path1" minimum:"3"` 33 | Path2 string `path:"path2" minLength:"2"` 34 | Path3 bool `path:"path3" description:"Trivial schema."` 35 | 36 | Header1 int `header:"header1" minimum:"3"` 37 | Header2 string `header:"header2" minLength:"2"` 38 | Header3 bool `header:"header3" description:"Trivial schema."` 39 | 40 | Cookie1 int `cookie:"cookie1" minimum:"3"` 41 | Cookie2 string `cookie:"cookie2" minLength:"2"` 42 | Cookie3 bool `cookie:"cookie3" description:"Trivial schema."` 43 | 44 | Form1 int `form:"form1" minimum:"3"` 45 | Form2 string `form:"form2" minLength:"2"` 46 | Form3 bool `form:"form3" description:"Trivial schema."` 47 | } 48 | 49 | cu := openapi.ContentUnit{} 50 | cu.Structure = req{} 51 | 52 | schemas := map[string]*jsonschema.SchemaOrBool{} 53 | doneCalled := 0 54 | 55 | require.NoError(t, r.WalkRequestJSONSchemas(http.MethodPost, cu, 56 | func(in openapi.In, paramName string, schema *jsonschema.SchemaOrBool, required bool) error { 57 | schemas[string(in)+"-"+paramName+"-"+strconv.FormatBool(required)+"-"+ 58 | strconv.FormatBool(schema.IsTrivial(r.ResolveJSONSchemaRef))] = schema 59 | 60 | return nil 61 | }, 62 | func(_ openapi.OperationContext) { 63 | doneCalled++ 64 | }, 65 | )) 66 | 67 | assert.Equal(t, 1, doneCalled) 68 | assertjson.EqMarshal(t, `{ 69 | "cookie-cookie1-false-false":{"minimum":3,"type":"integer"}, 70 | "cookie-cookie2-false-false":{"minLength":2,"type":"string"}, 71 | "cookie-cookie3-false-true":{"description":"Trivial schema.","type":"boolean"}, 72 | "formData-form1-false-false":{"minimum":3,"type":"integer"}, 73 | "formData-form2-false-false":{"minLength":2,"type":"string"}, 74 | "formData-form3-false-true":{"description":"Trivial schema.","type":"boolean"}, 75 | "header-header1-false-false":{"minimum":3,"type":"integer"}, 76 | "header-header2-false-false":{"minLength":2,"type":"string"}, 77 | "header-header3-false-true":{"description":"Trivial schema.","type":"boolean"}, 78 | "path-path1-true-false":{"minimum":3,"type":"integer"}, 79 | "path-path2-true-false":{"minLength":2,"type":"string"}, 80 | "path-path3-true-true":{"description":"Trivial schema.","type":"boolean"}, 81 | "query-form1-false-false":{"minimum":3,"type":"integer"}, 82 | "query-form2-false-false":{"minLength":2,"type":"string"}, 83 | "query-form3-false-true":{"description":"Trivial schema.","type":"boolean"}, 84 | "query-query1-false-false":{"minimum":3,"type":"integer"}, 85 | "query-query2-false-false":{"minLength":2,"type":"string"}, 86 | "query-query3-false-true":{"description":"Trivial schema.","type":"boolean"} 87 | }`, schemas) 88 | 89 | assertjson.EqMarshal(t, `{ 90 | "openapi":"3.1.0","info":{"title":"","version":""}, 91 | "components":{ 92 | "schemas":{ 93 | "FormDataOpenapi31TestReq":{ 94 | "type":"object", 95 | "properties":{ 96 | "form1":{"minimum":3,"type":"integer"}, 97 | "form2":{"minLength":2,"type":"string"}, 98 | "form3":{"type":"boolean","description":"Trivial schema."} 99 | } 100 | } 101 | } 102 | } 103 | }`, r.Spec) 104 | } 105 | 106 | func TestReflector_WalkRequestJSONSchemas_jsonBody(t *testing.T) { 107 | r := openapi31.NewReflector() 108 | 109 | type Embed struct { 110 | Query1 int `query:"query1" minimum:"3"` 111 | Query2 string `query:"query2" minLength:"2"` 112 | Query3 bool `query:"query3" description:"Trivial schema."` 113 | 114 | Foo int `json:"foo" minimum:"6"` 115 | } 116 | 117 | type DeeplyEmbedded struct { 118 | Embed 119 | } 120 | 121 | type req struct { 122 | *DeeplyEmbedded 123 | 124 | Path1 int `path:"path1" minimum:"3"` 125 | Path2 string `path:"path2" minLength:"2"` 126 | Path3 bool `path:"path3" description:"Trivial schema."` 127 | 128 | Header1 int `header:"header1" minimum:"3"` 129 | Header2 string `header:"header2" minLength:"2"` 130 | Header3 bool `header:"header3" description:"Trivial schema."` 131 | 132 | Cookie1 int `cookie:"cookie1" minimum:"3"` 133 | Cookie2 string `cookie:"cookie2" minLength:"2"` 134 | Cookie3 bool `cookie:"cookie3" description:"Trivial schema."` 135 | 136 | Bar []string `json:"bar" minItems:"15"` 137 | } 138 | 139 | cu := openapi.ContentUnit{} 140 | cu.Structure = req{} 141 | 142 | schemas := map[string]*jsonschema.SchemaOrBool{} 143 | doneCalled := 0 144 | 145 | require.NoError(t, r.WalkRequestJSONSchemas(http.MethodPost, cu, 146 | func(in openapi.In, paramName string, schema *jsonschema.SchemaOrBool, required bool) error { 147 | schemas[string(in)+"-"+paramName+"-"+strconv.FormatBool(required)+"-"+ 148 | strconv.FormatBool(schema.IsTrivial(r.ResolveJSONSchemaRef))] = schema 149 | 150 | return nil 151 | }, 152 | func(_ openapi.OperationContext) { 153 | doneCalled++ 154 | }, 155 | )) 156 | 157 | assert.Equal(t, 1, doneCalled) 158 | assertjson.EqMarshal(t, `{ 159 | "body-body-false-false":{ 160 | "properties":{ 161 | "bar":{"items":{"type":"string"},"minItems":15,"type":["array","null"]}, 162 | "foo":{"minimum":6,"type":"integer"} 163 | }, 164 | "type":"object" 165 | }, 166 | "cookie-cookie1-false-false":{"minimum":3,"type":"integer"}, 167 | "cookie-cookie2-false-false":{"minLength":2,"type":"string"}, 168 | "cookie-cookie3-false-true":{"description":"Trivial schema.","type":"boolean"}, 169 | "header-header1-false-false":{"minimum":3,"type":"integer"}, 170 | "header-header2-false-false":{"minLength":2,"type":"string"}, 171 | "header-header3-false-true":{"description":"Trivial schema.","type":"boolean"}, 172 | "path-path1-true-false":{"minimum":3,"type":"integer"}, 173 | "path-path2-true-false":{"minLength":2,"type":"string"}, 174 | "path-path3-true-true":{"description":"Trivial schema.","type":"boolean"}, 175 | "query-query1-false-false":{"minimum":3,"type":"integer"}, 176 | "query-query2-false-false":{"minLength":2,"type":"string"}, 177 | "query-query3-false-true":{"description":"Trivial schema.","type":"boolean"} 178 | }`, schemas) 179 | 180 | assertjson.EqMarshal(t, `{ 181 | "openapi":"3.1.0","info":{"title":"","version":""}, 182 | "components":{ 183 | "schemas":{ 184 | "Openapi31TestReq":{ 185 | "properties":{ 186 | "bar":{"items":{"type":"string"},"minItems":15,"type":["array","null"]}, 187 | "foo":{"minimum":6,"type":"integer"} 188 | }, 189 | "type":"object" 190 | } 191 | } 192 | } 193 | }`, r.Spec) 194 | } 195 | 196 | func TestReflector_WalkResponseJSONSchemas(t *testing.T) { 197 | r := openapi31.NewReflector() 198 | 199 | type Embed struct { 200 | Header1 int `header:"header1" minimum:"3"` 201 | Header2 string `header:"header2" minLength:"2"` 202 | Header3 bool `header:"header3" description:"Trivial schema."` 203 | 204 | Foo int `json:"foo" minimum:"6"` 205 | } 206 | 207 | type DeeplyEmbedded struct { 208 | Embed 209 | } 210 | 211 | type req struct { 212 | *DeeplyEmbedded 213 | 214 | Header4 int `header:"header4" minimum:"3"` 215 | Header5 string `header:"header5" minLength:"2"` 216 | Header6 bool `header:"header6" description:"Trivial schema."` 217 | 218 | Bar []string `json:"bar" minItems:"15"` 219 | } 220 | 221 | cu := openapi.ContentUnit{} 222 | cu.Structure = req{} 223 | 224 | schemas := map[string]*jsonschema.SchemaOrBool{} 225 | doneCalled := 0 226 | 227 | require.NoError(t, r.WalkResponseJSONSchemas(cu, 228 | func(in openapi.In, paramName string, schema *jsonschema.SchemaOrBool, required bool) error { 229 | schemas[string(in)+"-"+paramName+"-"+strconv.FormatBool(required)+"-"+ 230 | strconv.FormatBool(schema.IsTrivial(r.ResolveJSONSchemaRef))] = schema 231 | 232 | return nil 233 | }, 234 | func(_ openapi.OperationContext) { 235 | doneCalled++ 236 | }, 237 | )) 238 | 239 | assert.Equal(t, 1, doneCalled) 240 | assertjson.EqMarshal(t, `{ 241 | "body-body-false-false":{ 242 | "properties":{ 243 | "bar":{"items":{"type":"string"},"minItems":15,"type":["array","null"]}, 244 | "foo":{"minimum":6,"type":"integer"} 245 | }, 246 | "type":"object" 247 | }, 248 | "header-Header1-false-false":{"minimum":3,"type":"integer"}, 249 | "header-Header2-false-false":{"minLength":2,"type":"string"}, 250 | "header-Header3-false-true":{"description":"Trivial schema.","type":"boolean"}, 251 | "header-Header4-false-false":{"minimum":3,"type":"integer"}, 252 | "header-Header5-false-false":{"minLength":2,"type":"string"}, 253 | "header-Header6-false-true":{"description":"Trivial schema.","type":"boolean"} 254 | }`, schemas) 255 | 256 | assertjson.EqMarshal(t, `{ 257 | "openapi":"3.1.0","info":{"title":"","version":""}, 258 | "components":{ 259 | "schemas":{ 260 | "Openapi31TestReq":{ 261 | "properties":{ 262 | "bar":{"items":{"type":"string"},"minItems":15,"type":["array","null"]}, 263 | "foo":{"minimum":6,"type":"integer"} 264 | }, 265 | "type":"object" 266 | } 267 | } 268 | } 269 | }`, r.Spec) 270 | } 271 | -------------------------------------------------------------------------------- /openapi31/yaml.go: -------------------------------------------------------------------------------- 1 | package openapi31 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | // UnmarshalYAML reads from YAML bytes. 13 | func (s *Spec) UnmarshalYAML(data []byte) error { 14 | var v interface{} 15 | 16 | err := yaml.Unmarshal(data, &v) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | v = convertMapI2MapS(v) 22 | 23 | data, err = json.Marshal(v) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return s.UnmarshalJSON(data) 29 | } 30 | 31 | // MarshalYAML produces YAML bytes. 32 | func (s *Spec) MarshalYAML() ([]byte, error) { 33 | jsonData, err := s.MarshalJSON() 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | var v orderedMap 39 | 40 | err = json.Unmarshal(jsonData, &v) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return yaml.Marshal(yaml.MapSlice(v)) 46 | } 47 | 48 | type orderedMap []yaml.MapItem 49 | 50 | func (om *orderedMap) UnmarshalJSON(data []byte) error { 51 | var mapData map[string]interface{} 52 | 53 | err := json.Unmarshal(data, &mapData) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | keys, err := objectKeys(data) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | for _, key := range keys { 64 | *om = append(*om, yaml.MapItem{ 65 | Key: key, 66 | Value: mapData[key], 67 | }) 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func objectKeys(b []byte) ([]string, error) { 74 | d := json.NewDecoder(bytes.NewReader(b)) 75 | 76 | t, err := d.Token() 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | if t != json.Delim('{') { 82 | return nil, errors.New("expected start of object") 83 | } 84 | 85 | var keys []string 86 | 87 | for { 88 | t, err := d.Token() 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if t == json.Delim('}') { 94 | return keys, nil 95 | } 96 | 97 | if s, ok := t.(string); ok { 98 | keys = append(keys, s) 99 | } 100 | 101 | if err := skipValue(d); err != nil { 102 | return nil, err 103 | } 104 | } 105 | } 106 | 107 | var errUnterminated = errors.New("unterminated array or object") 108 | 109 | func skipValue(d *json.Decoder) error { 110 | t, err := d.Token() 111 | if err != nil { 112 | return err 113 | } 114 | 115 | switch t { 116 | case json.Delim('['), json.Delim('{'): 117 | for { 118 | if err := skipValue(d); err != nil { 119 | if errors.Is(err, errUnterminated) { 120 | break 121 | } 122 | 123 | return err 124 | } 125 | } 126 | case json.Delim(']'), json.Delim('}'): 127 | return errUnterminated 128 | } 129 | 130 | return nil 131 | } 132 | 133 | // convertMapI2MapS walks the given dynamic object recursively, and 134 | // converts maps with interface{} key type to maps with string key type. 135 | // This function comes handy if you want to marshal a dynamic object into 136 | // JSON where maps with interface{} key type are not allowed. 137 | // 138 | // Recursion is implemented into values of the following types: 139 | // 140 | // -map[interface{}]interface{} 141 | // -map[string]interface{} 142 | // -[]interface{} 143 | // 144 | // When converting map[interface{}]interface{} to map[string]interface{}, 145 | // fmt.Sprint() with default formatting is used to convert the key to a string key. 146 | // 147 | // See github.com/icza/dyno. 148 | func convertMapI2MapS(v interface{}) interface{} { 149 | switch x := v.(type) { 150 | case map[interface{}]interface{}: 151 | m := map[string]interface{}{} 152 | 153 | for k, v2 := range x { 154 | switch k2 := k.(type) { 155 | case string: // Fast check if it's already a string 156 | m[k2] = convertMapI2MapS(v2) 157 | default: 158 | m[fmt.Sprint(k)] = convertMapI2MapS(v2) 159 | } 160 | } 161 | 162 | v = m 163 | 164 | case []interface{}: 165 | for i, v2 := range x { 166 | x[i] = convertMapI2MapS(v2) 167 | } 168 | 169 | case map[string]interface{}: 170 | for k, v2 := range x { 171 | x[k] = convertMapI2MapS(v2) 172 | } 173 | } 174 | 175 | return v 176 | } 177 | -------------------------------------------------------------------------------- /operation.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/swaggest/jsonschema-go" 10 | ) 11 | 12 | // In defines value location in HTTP content. 13 | type In string 14 | 15 | // In values enumeration. 16 | const ( 17 | InPath = In("path") 18 | InQuery = In("query") 19 | InHeader = In("header") 20 | InCookie = In("cookie") 21 | InFormData = In("formData") 22 | InBody = In("body") 23 | ) 24 | 25 | // ContentOption configures ContentUnit. 26 | type ContentOption func(cu *ContentUnit) 27 | 28 | // ContentUnit defines HTTP content. 29 | type ContentUnit struct { 30 | Structure interface{} 31 | ContentType string 32 | Format string 33 | 34 | // HTTPStatus can have values 100-599 for single status, or 1-5 for status families (e.g. 2XX) 35 | HTTPStatus int 36 | 37 | // IsDefault indicates default response. 38 | IsDefault bool 39 | 40 | Description string 41 | 42 | // Customize allows fine control over prepared content entities. 43 | // The cor value can be asserted to one of these types: 44 | // *openapi3.RequestBodyOrRef 45 | // *openapi3.ResponseOrRef 46 | // *openapi31.RequestBodyOrReference 47 | // *openapi31.ResponseOrReference 48 | Customize func(cor ContentOrReference) 49 | 50 | fieldMapping map[In]map[string]string 51 | } 52 | 53 | // ContentOrReference defines content entity that can be a reference. 54 | type ContentOrReference interface { 55 | SetReference(ref string) 56 | } 57 | 58 | // WithCustomize is a ContentUnit option. 59 | func WithCustomize(customize func(cor ContentOrReference)) ContentOption { 60 | return func(cu *ContentUnit) { 61 | cu.Customize = customize 62 | } 63 | } 64 | 65 | // WithReference is a ContentUnit option. 66 | func WithReference(ref string) ContentOption { 67 | return func(cu *ContentUnit) { 68 | cu.Customize = func(cor ContentOrReference) { 69 | cor.SetReference(ref) 70 | } 71 | } 72 | } 73 | 74 | // ContentUnitPreparer defines self-contained ContentUnit. 75 | type ContentUnitPreparer interface { 76 | SetupContentUnit(cu *ContentUnit) 77 | } 78 | 79 | // WithContentType is a ContentUnit option. 80 | func WithContentType(contentType string) func(cu *ContentUnit) { 81 | return func(cu *ContentUnit) { 82 | cu.ContentType = contentType 83 | } 84 | } 85 | 86 | // WithHTTPStatus is a ContentUnit option. 87 | func WithHTTPStatus(httpStatus int) func(cu *ContentUnit) { 88 | return func(cu *ContentUnit) { 89 | cu.HTTPStatus = httpStatus 90 | } 91 | } 92 | 93 | // SetFieldMapping sets custom field mapping. 94 | func (c *ContentUnit) SetFieldMapping(in In, fieldToParamName map[string]string) { 95 | if len(fieldToParamName) == 0 { 96 | return 97 | } 98 | 99 | if c.fieldMapping == nil { 100 | c.fieldMapping = make(map[In]map[string]string) 101 | } 102 | 103 | c.fieldMapping[in] = fieldToParamName 104 | } 105 | 106 | // FieldMapping returns custom field mapping. 107 | func (c ContentUnit) FieldMapping(in In) map[string]string { 108 | return c.fieldMapping[in] 109 | } 110 | 111 | // OperationContext defines operation and processing state. 112 | type OperationContext interface { 113 | OperationInfo 114 | OperationInfoReader 115 | OperationState 116 | 117 | Method() string 118 | PathPattern() string 119 | 120 | Request() []ContentUnit 121 | Response() []ContentUnit 122 | 123 | AddReqStructure(i interface{}, options ...ContentOption) 124 | AddRespStructure(o interface{}, options ...ContentOption) 125 | 126 | UnknownParamsAreForbidden(in In) bool 127 | } 128 | 129 | // OperationInfo extends OperationContext with general information. 130 | type OperationInfo interface { 131 | SetTags(tags ...string) 132 | SetIsDeprecated(isDeprecated bool) 133 | SetSummary(summary string) 134 | SetDescription(description string) 135 | SetID(operationID string) 136 | 137 | AddSecurity(securityName string, scopes ...string) 138 | } 139 | 140 | // OperationInfoReader exposes current state of operation context. 141 | type OperationInfoReader interface { 142 | Tags() []string 143 | IsDeprecated() bool 144 | Summary() string 145 | Description() string 146 | ID() string 147 | } 148 | 149 | // OperationState extends OperationContext with processing state information. 150 | type OperationState interface { 151 | IsProcessingResponse() bool 152 | ProcessingIn() In 153 | 154 | SetIsProcessingResponse(isProcessingResponse bool) 155 | SetProcessingIn(in In) 156 | } 157 | 158 | type ocCtxKey struct{} 159 | 160 | // WithOperationCtx is a jsonschema.ReflectContext option. 161 | func WithOperationCtx(oc OperationContext, isProcessingResponse bool, in In) func(rc *jsonschema.ReflectContext) { 162 | return func(rc *jsonschema.ReflectContext) { 163 | oc.SetIsProcessingResponse(isProcessingResponse) 164 | oc.SetProcessingIn(in) 165 | 166 | rc.Context = context.WithValue(rc.Context, ocCtxKey{}, oc) 167 | } 168 | } 169 | 170 | // OperationCtx retrieves operation context from reflect context. 171 | func OperationCtx(rc *jsonschema.ReflectContext) (OperationContext, bool) { 172 | if oc, ok := rc.Value(ocCtxKey{}).(OperationContext); ok { 173 | return oc, true 174 | } 175 | 176 | return nil, false 177 | } 178 | 179 | var regexFindPathParameter = regexp.MustCompile(`{([^}:]+)(:[^}]+)?(?:})`) 180 | 181 | // SanitizeMethodPath validates method and parses path element names. 182 | func SanitizeMethodPath(method, pathPattern string) (cleanMethod string, cleanPath string, pathParams []string, err error) { 183 | method = strings.ToLower(method) 184 | pathParametersSubmatches := regexFindPathParameter.FindAllStringSubmatch(pathPattern, -1) 185 | 186 | switch method { 187 | case "get", "put", "post", "delete", "options", "head", "patch", "trace": 188 | break 189 | default: 190 | return "", "", nil, fmt.Errorf("unexpected http method: %s", method) 191 | } 192 | 193 | if len(pathParametersSubmatches) > 0 { 194 | for _, submatch := range pathParametersSubmatches { 195 | pathParams = append(pathParams, submatch[1]) 196 | 197 | if submatch[2] != "" { // Remove gorilla.Mux-style regexp in path. 198 | pathPattern = strings.Replace(pathPattern, submatch[0], "{"+submatch[1]+"}", 1) 199 | } 200 | } 201 | } 202 | 203 | return method, pathPattern, pathParams, nil 204 | } 205 | -------------------------------------------------------------------------------- /operation_test.go: -------------------------------------------------------------------------------- 1 | package openapi_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/swaggest/openapi-go" 9 | ) 10 | 11 | func TestContentUnit_options(t *testing.T) { 12 | cu := openapi.ContentUnit{} 13 | openapi.WithContentType("text/csv")(&cu) 14 | openapi.WithHTTPStatus(http.StatusConflict)(&cu) 15 | 16 | assert.Equal(t, "text/csv", cu.ContentType) 17 | assert.Equal(t, http.StatusConflict, cu.HTTPStatus) 18 | } 19 | -------------------------------------------------------------------------------- /reflector.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import "github.com/swaggest/jsonschema-go" 4 | 5 | // Reflector defines OpenAPI reflector behavior. 6 | type Reflector interface { 7 | JSONSchemaWalker 8 | 9 | NewOperationContext(method, pathPattern string) (OperationContext, error) 10 | AddOperation(oc OperationContext) error 11 | 12 | SpecSchema() SpecSchema 13 | JSONSchemaReflector() *jsonschema.Reflector 14 | } 15 | 16 | // JSONSchemaCallback is a user function called by JSONSchemaWalker. 17 | type JSONSchemaCallback func(in In, paramName string, schema *jsonschema.SchemaOrBool, required bool) error 18 | 19 | // JSONSchemaWalker can extract JSON schemas (for example, for validation purposes) from a ContentUnit. 20 | type JSONSchemaWalker interface { 21 | ResolveJSONSchemaRef(ref string) (s jsonschema.SchemaOrBool, found bool) 22 | WalkRequestJSONSchemas(method string, cu ContentUnit, cb JSONSchemaCallback, done func(oc OperationContext)) error 23 | WalkResponseJSONSchemas(cu ContentUnit, cb JSONSchemaCallback, done func(oc OperationContext)) error 24 | } 25 | -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swaggest/openapi-go/ba6524ec2e1875953271908d2949de6fb22765ce/resources/logo.png -------------------------------------------------------------------------------- /resources/schema/openapi31-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": { 3 | "/DefsType/i":"", 4 | "/Apikey/": "APIKey" 5 | } 6 | } -------------------------------------------------------------------------------- /resources/schema/openapi31-patch.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"op":"remove","path":"/$ref"},{"op":"remove","path":"/$defs/info/$ref"}, 3 | {"op":"add","path":"/$defs/info/patternProperties","value":{"^x-":{}}}, 4 | {"op":"remove","path":"/$defs/contact/$ref"}, 5 | {"op":"add","path":"/$defs/contact/patternProperties","value":{"^x-":{}}}, 6 | {"op":"remove","path":"/$defs/license/$ref"}, 7 | {"op":"add","path":"/$defs/license/patternProperties","value":{"^x-":{}}}, 8 | {"op":"remove","path":"/$defs/server/$ref"}, 9 | {"op":"add","path":"/$defs/server/patternProperties","value":{"^x-":{}}}, 10 | {"op":"remove","path":"/$defs/server-variable/$ref"}, 11 | {"op":"add","path":"/$defs/server-variable/patternProperties","value":{"^x-":{}}}, 12 | { 13 | "op":"add","path":"/$defs/components/properties/schemas/additionalProperties/x-go-type", 14 | "value":"map[string]interface{}" 15 | }, 16 | {"op":"remove","path":"/$defs/components/patternProperties"}, 17 | {"op":"remove","path":"/$defs/components/$ref"}, 18 | {"op":"remove","path":"/$defs/components/unevaluatedProperties"}, 19 | {"op":"add","path":"/$defs/paths/patternProperties/^x-","value":{}}, 20 | {"op":"remove","path":"/$defs/paths/$ref"},{"op":"remove","path":"/$defs/path-item/$ref"}, 21 | {"op":"add","path":"/$defs/path-item/patternProperties","value":{"^x-":{}}}, 22 | { 23 | "op":"add","path":"/$defs/path-item-or-reference/oneOf", 24 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/path-item"}] 25 | }, 26 | {"op":"remove","path":"/$defs/operation/$ref"}, 27 | {"op":"add","path":"/$defs/operation/patternProperties","value":{"^x-":{}}}, 28 | {"op":"remove","path":"/$defs/external-documentation/$ref"}, 29 | {"op":"add","path":"/$defs/external-documentation/patternProperties","value":{"^x-":{}}}, 30 | { 31 | "op":"add","path":"/$defs/parameter/properties/schema/x-go-type","value":"map[string]interface{}" 32 | }, 33 | { 34 | "op":"add","path":"/$defs/parameter/properties/style", 35 | "value":{"type":"string","enum":["form","spaceDelimited","pipeDelimited","deepObject"]} 36 | }, 37 | {"op":"add","path":"/$defs/parameter/properties/explode","value":{"type":"boolean"}}, 38 | {"op":"add","path":"/$defs/parameter/properties/example","value":{}}, 39 | { 40 | "op":"add","path":"/$defs/parameter/properties/examples", 41 | "value":{"type":"object","additionalProperties":{"$ref":"#/$defs/example-or-reference"}} 42 | }, 43 | { 44 | "op":"add","path":"/$defs/parameter/oneOf/0/allOf", 45 | "value":[ 46 | {"$ref":"#/$defs/examples"}, 47 | {"$ref":"#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-path"}, 48 | {"$ref":"#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-header"}, 49 | {"$ref":"#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-query"}, 50 | {"$ref":"#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-cookie"}, 51 | {"$ref":"#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-form"} 52 | ] 53 | }, 54 | {"op":"remove","path":"/$defs/parameter/dependentSchemas/schema/properties"}, 55 | {"op":"remove","path":"/$defs/parameter/dependentSchemas/schema/allOf"}, 56 | {"op":"remove","path":"/$defs/parameter/$ref"}, 57 | {"op":"add","path":"/$defs/parameter/patternProperties","value":{"^x-":{}}}, 58 | { 59 | "op":"add","path":"/$defs/parameter-or-reference/oneOf", 60 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/parameter"}] 61 | }, 62 | {"op":"remove","path":"/$defs/request-body/$ref"}, 63 | {"op":"add","path":"/$defs/request-body/patternProperties","value":{"^x-":{}}}, 64 | { 65 | "op":"add","path":"/$defs/request-body-or-reference/oneOf", 66 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/request-body"}] 67 | }, 68 | { 69 | "op":"add","path":"/$defs/media-type/properties/schema/x-go-type", 70 | "value":"map[string]interface{}" 71 | }, 72 | {"op":"add","path":"/$defs/media-type/properties/example","value":{}}, 73 | { 74 | "op":"add","path":"/$defs/media-type/properties/examples", 75 | "value":{"type":"object","additionalProperties":{"$ref":"#/$defs/example-or-reference"}} 76 | }, 77 | {"op":"remove","path":"/$defs/media-type/allOf"}, 78 | {"op":"add","path":"/$defs/media-type/patternProperties","value":{"^x-":{}}}, 79 | {"op":"remove","path":"/$defs/encoding/allOf/0/$ref"}, 80 | {"op":"add","path":"/$defs/encoding/allOf/0/patternProperties","value":{"^x-":{}}}, 81 | {"op":"add","path":"/$defs/responses/patternProperties/^x-","value":{}}, 82 | {"op":"remove","path":"/$defs/responses/$ref"},{"op":"remove","path":"/$defs/response/$ref"}, 83 | {"op":"add","path":"/$defs/response/patternProperties","value":{"^x-":{}}}, 84 | { 85 | "op":"add","path":"/$defs/response-or-reference/oneOf", 86 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/response"}] 87 | }, 88 | {"op":"remove","path":"/$defs/callbacks/$ref"}, 89 | {"op":"add","path":"/$defs/callbacks/patternProperties","value":{"^x-":{}}}, 90 | { 91 | "op":"add","path":"/$defs/callbacks-or-reference/oneOf", 92 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/callbacks"}] 93 | }, 94 | {"op":"remove","path":"/$defs/example/$ref"}, 95 | {"op":"add","path":"/$defs/example/patternProperties","value":{"^x-":{}}}, 96 | { 97 | "op":"add","path":"/$defs/example-or-reference/oneOf", 98 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/example"}] 99 | }, 100 | {"op":"remove","path":"/$defs/link/$ref"}, 101 | {"op":"add","path":"/$defs/link/patternProperties","value":{"^x-":{}}}, 102 | { 103 | "op":"add","path":"/$defs/link-or-reference/oneOf", 104 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/link"}] 105 | }, 106 | {"op":"add","path":"/$defs/header/properties/schema/x-go-type","value":"map[string]interface{}"}, 107 | {"op":"add","path":"/$defs/header/properties/example","value":{}}, 108 | { 109 | "op":"add","path":"/$defs/header/properties/examples", 110 | "value":{"type":"object","additionalProperties":{"$ref":"#/$defs/example-or-reference"}} 111 | }, 112 | {"op":"add","path":"/$defs/header/properties/style","value":{"default":"simple","const":"simple"}}, 113 | {"op":"add","path":"/$defs/header/properties/explode","value":{"default":false,"type":"boolean"}}, 114 | {"op":"remove","path":"/$defs/header/dependentSchemas"}, 115 | {"op":"remove","path":"/$defs/header/$ref"}, 116 | {"op":"add","path":"/$defs/header/patternProperties","value":{"^x-":{}}}, 117 | { 118 | "op":"add","path":"/$defs/header-or-reference/oneOf", 119 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/header"}] 120 | }, 121 | {"op":"remove","path":"/$defs/tag/$ref"}, 122 | {"op":"add","path":"/$defs/tag/patternProperties","value":{"^x-":{}}}, 123 | {"op":"remove","path":"/$defs/security-scheme/allOf/0/$ref"}, 124 | {"op":"add","path":"/$defs/security-scheme/allOf/0/patternProperties","value":{"^x-":{}}}, 125 | { 126 | "op":"add","path":"/$defs/security-scheme-or-reference/oneOf", 127 | "value":[{"$ref":"#/$defs/reference"},{"$ref":"#/$defs/security-scheme"}] 128 | }, 129 | {"op":"remove","path":"/$defs/oauth-flows/$ref"}, 130 | {"op":"remove","path":"/$defs/oauth-flows/$defs/implicit/$ref"}, 131 | {"op":"add","path":"/$defs/oauth-flows/$defs/implicit/patternProperties","value":{"^x-":{}}}, 132 | {"op":"remove","path":"/$defs/oauth-flows/$defs/password/$ref"}, 133 | {"op":"add","path":"/$defs/oauth-flows/$defs/password/patternProperties","value":{"^x-":{}}}, 134 | {"op":"remove","path":"/$defs/oauth-flows/$defs/client-credentials/$ref"}, 135 | { 136 | "op":"add","path":"/$defs/oauth-flows/$defs/client-credentials/patternProperties", 137 | "value":{"^x-":{}} 138 | }, 139 | {"op":"remove","path":"/$defs/oauth-flows/$defs/authorization-code/$ref"}, 140 | { 141 | "op":"add","path":"/$defs/oauth-flows/$defs/authorization-code/patternProperties", 142 | "value":{"^x-":{}} 143 | }, 144 | {"op":"add","path":"/$defs/oauth-flows/patternProperties","value":{"^x-":{}}}, 145 | {"op":"test","path":"/$defs/specification-extensions/patternProperties/^x-","value":true}, 146 | {"op":"replace","path":"/$defs/specification-extensions/patternProperties/^x-","value":{}}, 147 | {"op":"test","path":"/$defs/examples/properties/example","value":true}, 148 | {"op":"replace","path":"/$defs/examples/properties/example","value":{}}, 149 | {"op":"add","path":"/patternProperties","value":{"^x-":{}}} 150 | ] -------------------------------------------------------------------------------- /spec.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | // SpecSchema abstracts OpenAPI schema implementation to generalize multiple revisions. 4 | type SpecSchema interface { 5 | Title() string 6 | Description() string 7 | Version() string 8 | 9 | SetTitle(t string) 10 | SetDescription(d string) 11 | SetVersion(v string) 12 | 13 | SetHTTPBasicSecurity(securityName string, description string) 14 | SetAPIKeySecurity(securityName string, fieldName string, fieldIn In, description string) 15 | SetHTTPBearerTokenSecurity(securityName string, format string, description string) 16 | } 17 | --------------------------------------------------------------------------------