From 61c1afb705631adf03def0da583ba7dab50cc685 Mon Sep 17 00:00:00 2001 From: Qifan Wu <119459010+QifanWuCFLT@users.noreply.github.com> Date: Thu, 6 Apr 2023 10:02:09 -0700 Subject: [PATCH] Reset master and merge the commits ahead (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add couple tests around multiple file-specs (one needs fixing) + some code reuse (#236) * Fix wrong `Found unresolved ref` error when converting from Swagger/OpenAPIv2 spec (#237) * various fixes mainly to openapi2<->openapi3 conversion (#239) * Add deprecated field in Schema (#242) Change-Id: If750ff340ae29cf24a6ad870071502c9327485ca * Fix openapi3.referencedDocumentPath (#248) * follow lint rules (#250) * Fix broken link to alternative projects (#255) * openapi2 security scheme requires accessCode not accesscode (#256) Signed-off-by: Pierre Fenoll * Validator: check readOnly/writeOnly properties (#246) * feat: add Goa to README (#261) Goa v3 depend on kin-openapi https://github.com/goadesign/goa/blob/v3/go.mod * swagger2 formData & request body refs (#260) Co-authored-by: Francis Lennon * Add support for error aggregation for request/response validation (#259) * Prevent a panic in the error encoder (#262) * Adds ipv4 and ipv6 formats support (#258) Co-authored-by: Pierre Fenoll * validate pattern or schema, not pattern xor schema anymore (#265) * Consumes request bodies (#263) Co-authored-by: Francis Lennon Co-authored-by: Pierre Fenoll * fixed panic in path validation (issue #264) (#266) Co-authored-by: Samuel Monderer * Update doc.go (#272) * Exposing Components 'IdentifierRegExp' to enable customized component key #270 (#273) * Add support for application/problem+json (#275) Add support for content type application/problem+json for response validation * Enables jsonpointer support in openapi3 (#276) * Fix flaky CI (#278) * Fix failfast flag handling (#284) * Add OIDC Schema format as per spec (#287) Co-authored-by: Pierre Fenoll * Support for alternate http auth mechanisms (#291) Fixes #290 * Return a more specific error when more than oneOf schemas match (#292) * fix bug on indice to compare (#295) * support extensions in oasv3.Server (#302) Signed-off-by: Pierre Fenoll * Add an example showing how to decode some extension props (#304) * clarify defaults around openapi3filter.Options and openapi3filter.Aut… (#305) Signed-off-by: Pierre Fenoll * Add extensions in missing resources (#306) * Enlarge support for JSON Path in $ref resolution (#307) * Prevent infinite loop while loading openapi spec with recursive references (#310) * nitpicks (#313) * mention alternatives in README.md (#315) Signed-off-by: Pierre Fenoll * Bypass any file/URL reading by ReadFromURIFunc (#316) * Drop `sl.LoadSwaggerFromURIFunc` (#317) * Add an openapi3gen example + options (#320) * Adds oneOf/discriminator/mapping management (#321) * reproduce incorrect discriminator handling with ValidateRequest (#323) * prepare for #210 (#325) * go:embed loader.ReadFromURIFunc example (#319) * Rework router (#210) * Reset compiledPattern when updating Pattern (#327) * address #326 (#330) Signed-off-by: Pierre Fenoll * Drop test dependency on go:embed (#331) * Update README.md (#333) * introduce openapi3filter.RegisteredBodyDecoder (#340) Signed-off-by: Pierre Fenoll * openapi3: allow variables in schemes in gorillamux router + better server variables validation (#337) * Fix following refs to non-openapi3 root documents (but that are sub-documents) (#346) * reproduce failing to load JSON refs in non-openapi document (#314) * repro #341 (#342) * [Bugfix] fail readURL on http code > 399 (#345) * [Bugfix] fail readUR if external reference returned http code > 399 * Replaced httpmock with httptest * Use require.EqualError instead testing Error and Contains of the errormessage * Test LoadSwaggerFromData as well * Support loading documents with `filepath.FromSlash` (#251) * [Bugfix] fixed error message when only file is referenced (#348) without internal reference * Follow callbacks references (#347) * CI: test go1.14 (#349) * Clean APIs from trademarked name "Swagger" (#351) * Fix CI (#352) Signed-off-by: Pierre Fenoll * cannot reproduce #353 (#354) Signed-off-by: Pierre Fenoll * add example usage of request validation with gorilla/mux router (#359) Signed-off-by: Pierre Fenoll * Have Header Object follow the structure of the Parameter Object (#355) * CI: fix tests after tag (#363) Signed-off-by: Pierre Fenoll * Update openapi2_conv.go (#365) Co-authored-by: Pierre Fenoll * update that tag again... (#374) * fix drilling down struct looking for additionalProperties (#377) * fix drilling down additionalProperties in the boolean case (#378) * Compile pattern on validate (#375) Co-authored-by: Pierre Fenoll * Add uint type to openapi3gen (#379) * reproduce and fix issue #382 (#383) * Detect if a field is anonymous and handle the indirection (#386) * Add missing yaml tags in marshaling openapi2.T (#391) * Support reference cycles (#393) * Add support for embedded struct pointers (#396) * fix: Allow encoded path parameters with slashes (#400) * Accept multipart/form-data's part without Content-Type (#399) * fix that CI go:embed test forever, again (#405) * fix bad ci script. I was under the impression this was working when I… (#406) * Fix handling recursive refs (#403) * fix issue 407, where if server URL has no path it throws exception (#408) Co-authored-by: Naer Chang * feature: Add more discriminator error messages and return specific er… (#394) * feature: Add more discriminator error messages and return specific error when possible * feature: Always show more specific error message * test: Add schema oneOf tests * clean: Add missing word * Add nomad to list of projects using kin-openapi in README (#413) * Schema customization plug-point (#411) * Update README: remove github.com/getkin/kin (#414) * fix alters by LGTM.com (#415) * Add support for "application/x-yaml" (#421) * sort out possible mishandling of ipv4 vs v6 (#431) * Panic with customizer and embedded structs (#434) * Fix #422 added support for error unwrapping for errors with a single sub-error (#433) * Do not escape regular expressions again (getkin#429) (#435) * improve response validation error (#437) * Define const schema types (#438) * reproduce issue #436 (#439) * Fix scheme handling in v2->v3 conversion (#441) * reproduce + fix #444: ValidateRequest for application/x-yaml (#445) * Internalize references (#443) * ClientCredentials conversion to OpenAPI v2 (#449) * Fix issue https://github.com/getkin/kin-openapi/issues/410 (#450) Co-authored-by: Pierre Fenoll * try reproducing #447 (#448) * fix: duplicate error reason when parameter is required but not present (#453) * Provide support for generating recursive types into OpenAPI doc #451 + my touches (#454) Co-authored-by: Peter Broadhurst * v2Tov3: handle parameter schema refs (#455) Co-authored-by: Vincent Behar * nitpicking: use type openapi3.Schemas (#456) * Create FUNDING.yml (#458) * Insert produces field (#461) * fix error reason typo (#466) * Update rfc422 regex as per spec: 'case insensitive on input' (#463) * work around localhost host mismatch with relative server url (#467) Co-authored-by: Chris Rodwell * Add openapi3 validator middleware (#462) * document union behaviour of XyzRef.s (#468) * extensible-paths (#470) * fix recipe for validating http requests/responses (#474) * amend README.md to reflect BodyDecoder type (#475) * openapi2conv: Convert response headers (#483) * Fix oauth2 in openapi2conv.FromV3SecurityScheme (#491) * Fix openapi3 validation: path param must be required (#490) * updated date-time string format regexp to fully comply to standard (#493) * distinguish form data in fromV3RequestBodies (#494) * feat: cache resolved refs, improve URI reader extensibility (#469) * Fix OpenAPI 3 validation: request body content is required (#498) * Add OpenAPI 3 externalDocs validation (#497) * issue/500 (#501) Co-authored-by: Nathaniel J Cochran * Fix OpenAPI 3 validation: operationId must be unique (#504) * Check response headers and links (#505) Co-authored-by: Ole Petersen Co-authored-by: Pierre Fenoll * fix that test situation (#506) * Define missing XML in schema, minor fixes and doc additions (#508) * discriminator value should verify the type is string to avoid panic (#509) * Add nilness check to CI (#510) * Add support for formats defined in JSON Draft 2019-09 (#512) Co-authored-by: Steve Lessard * Change the order of request validation to validate the Security schemas first before all other paramters (#514) Co-authored-by: yarne * Add support for allowEmptyValue (#515) Co-authored-by: Pierre Fenoll * RequestError Error() does not include reason if it is the same as err (#517) Co-authored-by: Kanda * Fix ExampleValidator test for 32-bit architectures (#516) * openapi2: add missing schemes field of operation object (#519) * Run CI tests on 386 too cc #516 (#518) * Add ExcludeSchema sentinel error for schemaCustomizer (#522) Co-authored-by: Pierre Fenoll * test link refs (#525) * add missing validation of components: examples, links, callbacks (#526) * openapi2: remove undefined tag (#527) * testing: fix incorrect document (#529) * testing: compare graphs using graph tools (#528) * Fix some golints (#530) * Internalize parameter references in the path as well (#540) * fix bad error message on invalid value parse on query parameter (#541) Co-authored-by: Kanda * Follow up to #540 with more tests (#549) * feat: handling `default` in request body and parameter schema (#544) * wip setting defaults for #206 Signed-off-by: Pierre Fenoll * introduce body encoders Signed-off-by: Pierre Fenoll * re-encode only when needed Signed-off-by: Pierre Fenoll * set default for parameter and add more test cases Co-authored-by: Pierre Fenoll * following up on #544: do not pass through on unhandled case (#550) * Fix for CVE-2022-28948 (#552) * CI: check-goimports Signed-off-by: Pierre Fenoll * reorder imports per new CI check Signed-off-by: Pierre Fenoll * switch from github.com/ghodss/yaml to github.com/invopop/yaml Signed-off-by: Pierre Fenoll * remove all direct dependencies on gopkg.in/yaml.v2 Signed-off-by: Pierre Fenoll * upgrade gopkg.in/yaml.v2 to latest published tag Signed-off-by: Pierre Fenoll * upgrade gopkg.in/yaml.v3 to latest published tag Signed-off-by: Pierre Fenoll * TestIssue430: fix racey behavior (#553) * Handle port number variable of servers given to gorillamux.NewRouter (#524) * update README.md with newer router/validator example (#554) * Unit tests (#556) * add gitlab.com/jamietanna/httptest-openapi to README.md (#557) * fix: add deprecated field to openapi2.Operation (#559) * fix: openapi2conv respects produces field (#575) * Use go1.19 formatting (#584) * Fix `resolveSchemaRef()` to load correctly an other spec. file referenced by `$ref` (#583) * Protect from recursion in openapi3.InternaliseRefs (#578) Co-authored-by: Dmitriy Lukiyanchuk * cleanup after #583 (#585) * upgrade CI tools (#586) * #482 integer support broken with yaml (#577) Co-authored-by: Christian Boitel * Match on overridden servers at the path level, fixes #564 (#565) Co-authored-by: Pierre Fenoll * feat: support validation options specifically for disabling pattern validation (#590) * Add sponsor logo (#595) * Examples validation (#592) Co-authored-by: Pierre Fenoll * use %w to wrap the errors (#596) * Expose request/response validation options in the middleware Validator (#608) * fix: detects circular references that can't be handled at the moment to avoid infinite loops loading documents (#607) * Validate default values against schema (#610) * fix: only inject default value for matched oneOf or anyOf (#604) * Deterministic validation (#602) * Improve error message when path validation fails (#605) * Correctly resolve path of yaml resource if double referenced. (#611) * Fix second level relative ref in property resolving (#622) Co-authored-by: Dmitriy Lukiyanchuk * rework convertError Example code to show query schema error (#626) * Allow validations options when creating legace Router (#614) * Additional error information (#617) * Add SIMITGROUP`s repo to dependants shortlist (#627) * Introduce package-wide CircularReferenceCounter to work around #615 (#628) Co-authored-by: sorintm * fix: embedded struct handling (#630) * openapi3filter: Fallback to string when decoding request parameters (#631) * Introduce `(openapi3.*Server).BasePath()` and `(openapi3.Servers).BasePath()` (#633) * Actually #624, thanks to @orensolo (#634) * Check for superfluous trailing whitespace (#636) * show errors in security requirements (#637) * Fix validation of complex enum values (#647) * readOnly writeOnly validation (#599) * fix: yaml marshal output (#649) * openapi3filter: add missing response headers validation (#650) * Fix lost error types in oneOf (#658) * Add RegisterBodyEncoder (#656) * fix panic slice out of range error #652 (#654) * Fixed recurive reference resolving when property referencies local co… (#660) Co-authored-by: Anton Tolokan * Add CodeQL workflow for GitHub code scanning (#661) Co-authored-by: LGTM Migrator * Support x-nullable (#670) * Update content length after replacing request body (#672) * fix: optional defaults (#662) * fix: wrap the error that came back from the callback (#674) (#675) * fix: openapi3.SchemaError message customize (#678) (#679) Co-authored-by: Pierre Fenoll * openapi3filter: fix crash when given arrays of objects as query parameters (#664) * fix: error path is lost (#681) (#682) * feat: formatting some error messages (#684) * fix: query param pattern (#665) * fix: errors in oneOf not contain path (#676) (#677) * fix tests after merge train (#686) * Internalize recursive external references #618 (#655) * Add variadic options to Validate method (#692) * fix: setting defaults for oneOf and anyOf (#690) * Try decoding as JSON first then YAML, for speed (#693) Fixes https://github.com/getkin/kin-openapi/issues/680 * Use and update GetBody() member of request (#704) * Bugfix/issue638 (#700) * Add json patch support (#702) * openapi3filter: Include schema ref or title in response body validation errors (#699) Co-authored-by: Steve Lessard * openapi3filter: parse integers with strconv.ParseInt instead of ParseFloat (#711) Co-authored-by: Steve Lessard * Fix inconsistent processing of server variables in gorillamux router (#705) Co-authored-by: Steve Lessard * Fix links to OpenAPI spec after GitHub changes (#714) * openapi3: patch YAML serialization of dates (#698) Co-authored-by: Pierre Fenoll * Leave allocation capacity guessing to the runtime (#716) * openapi3filter: validate non-string headers (#712) Co-authored-by: Steve Lessard * openapi3: unexport ValidationOptions fields and add some more (#717) * openapi3: introduce (Paths).InMatchingOrder() paths iterator (#719) * feat: improve error reporting for bad/missing discriminator (#718) * openapi3: continue validation on valid oneOf properties (#721) * openapi3filter: use option to skip setting defaults on validation (#708) * openapi3: remove email string format (#727) * openapi3filter: support for allOf request schema in multipart/form-data (#729) fix https://github.com/getkin/kin-openapi/issues/722 * Disallow unexpected fields in validation and drop `jsoninfo` package (#728) Fixes https://github.com/getkin/kin-openapi/issues/513 Fixes https://github.com/getkin/kin-openapi/issues/37 * openapi3filter: RegisterBodyDecoder for application/zip (#730) * Keep track of API changes with CI (#732) * openapi3filter: RegisterBodyDecoder for text/csv (#734) fix https://github.com/getkin/kin-openapi/issues/696 * #741 uri cache mutex (#742) * Specify UseNumber() in the JSON decoder during JSON validation (#738) * openapi3: fix error phrase in security scheme (#745) Co-authored-by: Pierre Fenoll * openapi3: remove value data from `SchemaError.Reason` field (#737) Resolves https://github.com/getkin/kin-openapi/issues/735 * fix additional properties false not validated (#747) * Refine schema error reason message (#748) * openapi3: fix validation of non-empty interface slice value against array schema (#752) Resolves https://github.com/getkin/kin-openapi/issues/751 * openapi3: empty scopes are valid (#754) * openapi3: fix integer enum schema validation after json.Number PR (#755) * optional readOnly and writeOnly validations (#758) * openapi3: fix resolving Callbacks (#757) Co-authored-by: Pierre Fenoll fix https://github.com/getkin/kin-openapi/issues/341 * fixup some coding style divergences (#760) * openapi3: make `bad data ...` error more actionable (#761) * openapi3: add test from #731 showing validating doc first is required (#762) closes https://github.com/getkin/kin-openapi/issues/731 * cmd/validate: more expressive errors (#769) * openapi3: fix an infinite loop that may have been introduced in #700 (#768) * openapi3: fix default values count even when disabled (#767) (#770) * openapi3: sort extra fields only once, during deserialization (#773) * feat: support nil uuid (#778) --------- Signed-off-by: Pierre Fenoll Co-authored-by: Pierre Fenoll Co-authored-by: Tevic Co-authored-by: Kaushal Madappa Co-authored-by: Kevin Disneur Co-authored-by: 森 優太 <59682979+uta-mori@users.noreply.github.com> Co-authored-by: FrancisLennon17 Co-authored-by: Francis Lennon Co-authored-by: Zachary Lozano Co-authored-by: Richard Rance Co-authored-by: Riccardo Manfrin Co-authored-by: Samuel Monderer Co-authored-by: Samuel Monderer Co-authored-by: duohedron <40067856+duohedron@users.noreply.github.com> Co-authored-by: heyvister <41934916+heyvister@users.noreply.github.com> Co-authored-by: DanielXu77 <52269333+DanielXu77@users.noreply.github.com> Co-authored-by: Gordon Allott Co-authored-by: Michael Krotscheck Co-authored-by: Jake Scott Co-authored-by: C H Co-authored-by: Sergi Castro Co-authored-by: hottestseason Co-authored-by: Reuven Harrison Co-authored-by: Steffen Rumpf <39158011+steffakasid@users.noreply.github.com> Co-authored-by: jasmanx11 <61581398+jasmanx11@users.noreply.github.com> Co-authored-by: Alexander Bolgov <49677698+alexanderbolgov-ef@users.noreply.github.com> Co-authored-by: bianca rosa Co-authored-by: Derek Strickland <1111455+DerekStrickland@users.noreply.github.com> Co-authored-by: Rodrigo Fernandes Co-authored-by: stakme Co-authored-by: NaerChang2 Co-authored-by: Naer Chang Co-authored-by: Peter Broadhurst Co-authored-by: Oleksandr Redko Co-authored-by: Guilherme Cardoso Co-authored-by: Bion <520596+bionoren@users.noreply.github.com> Co-authored-by: José María Martín Luque Co-authored-by: David Sharnoff Co-authored-by: Mansur Marvanov Co-authored-by: jhwz <52683873+jhwz@users.noreply.github.com> Co-authored-by: Luukvdm Co-authored-by: Andrey Dyatlov Co-authored-by: Nick Ufer Co-authored-by: Vincent Behar Co-authored-by: Matteo Pietro Dazzi Co-authored-by: Karl Möller <93589605+karl-dau@users.noreply.github.com> Co-authored-by: Chris Rodwell Co-authored-by: Casey Marshall Co-authored-by: general-kroll-4-life <82620104+general-kroll-4-life@users.noreply.github.com> Co-authored-by: Andreas Paul Co-authored-by: Sergey Vilgelm Co-authored-by: Clifton Kaznocha Co-authored-by: Vasiliy Tsybenko Co-authored-by: Anthony Clerc <21290922+Cr4psy@users.noreply.github.com> Co-authored-by: Nathan Cochran Co-authored-by: Nathaniel J Cochran Co-authored-by: Ole Petersen <56505957+peteole@users.noreply.github.com> Co-authored-by: Ole Petersen Co-authored-by: K Zhang Co-authored-by: slessard Co-authored-by: Steve Lessard Co-authored-by: Yarne Decuyper Co-authored-by: yarne Co-authored-by: Kanda Co-authored-by: Anthony Fok Co-authored-by: Nicko Guyer Co-authored-by: Christoph Petrausch <263448+hikhvar@users.noreply.github.com> Co-authored-by: Nic Co-authored-by: Idan Frimark <40820488+FrimIdan@users.noreply.github.com> Co-authored-by: Nir <35661734+nirhaas@users.noreply.github.com> Co-authored-by: Masumi Kanai Co-authored-by: wtertius Co-authored-by: Dmitriy Lukiyanchuk Co-authored-by: Christian Boitel <40855349+cboitel@users.noreply.github.com> Co-authored-by: Christian Boitel Co-authored-by: Amarjeet Rai Co-authored-by: Tristan Cartledge <108070248+TristanSpeakEasy@users.noreply.github.com> Co-authored-by: danicc097 <71724149+danicc097@users.noreply.github.com> Co-authored-by: sorintm <112782063+sorintm@users.noreply.github.com> Co-authored-by: Praneet Loke <1466314+praneetloke@users.noreply.github.com> Co-authored-by: Davor Sauer Co-authored-by: Yannick Clybouw Co-authored-by: sorintm Co-authored-by: Nicholas Wiersma Co-authored-by: Steven Hartland Co-authored-by: Stepan I <2688692+micronull@users.noreply.github.com> Co-authored-by: Omar Ramadan Co-authored-by: nk2ge5k Co-authored-by: Derbylock Co-authored-by: Anton Tolokan Co-authored-by: lgtm-com[bot] <43144390+lgtm-com[bot]@users.noreply.github.com> Co-authored-by: LGTM Migrator Co-authored-by: Chris Reeves Co-authored-by: Andriy Borodiychuk Co-authored-by: orensolo <46680749+orensolo@users.noreply.github.com> Co-authored-by: Stepan I Co-authored-by: Eloy Coto Co-authored-by: tomato0111 <119634480+tomato0111@users.noreply.github.com> Co-authored-by: ShouheiNishi <96609867+ShouheiNishi@users.noreply.github.com> Co-authored-by: Cosmos Nicolaou Co-authored-by: Greg Ward Co-authored-by: Vincent Le Goff Co-authored-by: Katsumi Kato Co-authored-by: Graham Crowell Co-authored-by: Jeffrey Ying Co-authored-by: Ori Shalom Co-authored-by: Andrew Yang Co-authored-by: Nodar Jarrar <36896519+nodar963@users.noreply.github.com> Co-authored-by: orshlom <44160965+orshlom@users.noreply.github.com> Co-authored-by: Vincent Le Goff --- .gitattributes | 1 + .github/FUNDING.yml | 12 + .github/docs/openapi2.txt | 9 + .github/docs/openapi2conv.txt | 25 + .github/docs/openapi3.txt | 155 ++ .github/docs/openapi3filter.txt | 54 + .github/docs/openapi3filter_fixtures.txt | 0 .github/docs/openapi3gen.txt | 11 + .github/docs/routers.txt | 5 + .github/docs/routers_gorillamux.txt | 2 + .github/docs/routers_legacy.txt | 3 + .github/docs/routers_legacy_pathpattern.txt | 9 + .github/sponsors/speakeasy.png | Bin 0 -> 12425 bytes .github/workflows/codeql.yml | 41 + .github/workflows/go.yml | 196 +++ .github/workflows/shellcheck.yml | 18 + .gitignore | 2 +- .travis.yml | 12 - README.md | 250 ++- cmd/validate/main.go | 102 ++ docs.sh | 25 + go.mod | 13 +- go.sum | 53 +- jsoninfo/doc.go | 2 - jsoninfo/marshal.go | 162 -- jsoninfo/marshal_ref.go | 30 - jsoninfo/marshal_test.go | 189 -- jsoninfo/strict_struct.go | 6 - jsoninfo/type_info.go | 68 - jsoninfo/unmarshal.go | 121 -- jsoninfo/unmarshal_test.go | 157 -- jsoninfo/unsupported_properties_error.go | 45 - openapi2/doc.go | 7 + openapi2/header.go | 15 + openapi2/openapi2.go | 259 +-- openapi2/openapi2_test.go | 54 +- openapi2/operation.go | 91 + openapi2/parameter.go | 176 ++ openapi2/path_item.go | 150 ++ openapi2/response.go | 60 + openapi2/security_scheme.go | 87 + openapi2/testdata/swagger.json | 2 +- openapi2conv/doc.go | 2 + openapi2conv/issue187_test.go | 194 ++ openapi2conv/issue440_test.go | 49 + openapi2conv/issue558_test.go | 37 + openapi2conv/issue573_test.go | 48 + openapi2conv/openapi2_conv.go | 960 +++++++--- openapi2conv/openapi2_conv_test.go | 1212 +++++++++---- openapi2conv/testdata/swagger.json | 1 + openapi3/callback.go | 43 +- openapi3/components.go | 230 ++- openapi3/content.go | 57 +- openapi3/discriminator.go | 45 +- openapi3/discriminator_test.go | 27 +- openapi3/doc.go | 5 +- openapi3/encoding.go | 77 +- openapi3/encoding_test.go | 37 +- openapi3/errors.go | 59 + openapi3/example.go | 93 + openapi3/example_test.go | 9 +- openapi3/example_validation.go | 16 + openapi3/example_validation_test.go | 527 ++++++ openapi3/examples.go | 29 - openapi3/extension.go | 40 +- openapi3/extension_test.go | 100 -- openapi3/external_docs.go | 56 +- openapi3/external_docs_test.go | 42 + openapi3/header.go | 104 +- openapi3/info.go | 169 +- openapi3/internalize_refs.go | 434 +++++ openapi3/internalize_refs_test.go | 65 + openapi3/issue136_test.go | 53 + openapi3/issue241_test.go | 29 + openapi3/issue301_test.go | 28 + openapi3/issue341_test.go | 63 + openapi3/issue344_test.go | 20 + openapi3/issue376_test.go | 165 ++ openapi3/issue382_test.go | 15 + openapi3/issue513_test.go | 173 ++ openapi3/issue542_test.go | 37 + openapi3/issue570_test.go | 15 + openapi3/issue601_test.go | 34 + openapi3/issue615_test.go | 34 + openapi3/issue618_test.go | 39 + openapi3/issue638_test.go | 21 + openapi3/issue652_test.go | 29 + openapi3/issue657_test.go | 79 + openapi3/issue689_test.go | 107 ++ openapi3/issue697_test.go | 15 + openapi3/issue735_test.go | 278 +++ openapi3/issue741_test.go | 43 + openapi3/issue746_test.go | 26 + openapi3/issue753_test.go | 20 + openapi3/issue759_test.go | 34 + openapi3/issue767_test.go | 90 + openapi3/link.go | 89 +- ...oad_cicular_ref_with_external_file_test.go | 70 + openapi3/load_with_go_embed_test.go | 35 + openapi3/loader.go | 1039 +++++++++++ ...loader_empty_response_description_test.go} | 19 +- openapi3/loader_http_error_test.go | 99 ++ ...sue212_test.go => loader_issue212_test.go} | 6 +- openapi3/loader_issue220_test.go | 27 + openapi3/loader_issue235_test.go | 25 + openapi3/loader_outside_refs_test.go | 20 + ...der_paths_test.go => loader_paths_test.go} | 10 +- openapi3/loader_read_from_uri_func_test.go | 73 + openapi3/loader_recursive_ref_test.go | 51 + ...s_test.go => loader_relative_refs_test.go} | 250 +-- ...{swagger_loader_test.go => loader_test.go} | 334 ++-- openapi3/loader_uri_reader.go | 112 ++ openapi3/media_type.go | 118 +- openapi3/media_type_test.go | 21 +- openapi3/openapi3.go | 156 ++ .../{swagger_test.go => openapi3_test.go} | 294 ++-- openapi3/operation.go | 152 +- openapi3/operation_test.go | 2 +- openapi3/parameter.go | 290 ++- openapi3/parameter_issue223_test.go | 116 ++ openapi3/path_item.go | 113 +- openapi3/paths.go | 221 ++- openapi3/paths_test.go | 94 + openapi3/race_test.go | 28 + openapi3/ref.go | 7 + openapi3/refs.go | 674 ++++++- openapi3/refs_test.go | 275 +++ openapi3/request_body.go | 101 +- openapi3/response.go | 130 +- openapi3/response_issue224_test.go | 461 +++++ openapi3/schema.go | 1566 +++++++++++++---- openapi3/schema_formats.go | 86 +- openapi3/schema_formats_test.go | 156 ++ openapi3/schema_issue289_test.go | 39 + openapi3/schema_issue492_test.go | 41 + openapi3/schema_oneOf_test.go | 184 ++ openapi3/schema_test.go | 517 ++++-- openapi3/schema_validation_settings.go | 79 + openapi3/schema_validation_settings_test.go | 36 + openapi3/security_requirements.go | 16 +- openapi3/security_scheme.go | 302 +++- openapi3/security_scheme_test.go | 150 +- openapi3/server.go | 184 +- openapi3/server_test.go | 108 +- openapi3/swagger.go | 81 - openapi3/swagger_loader.go | 875 --------- openapi3/tag.go | 67 + .../testdata/303bis/common/properties.yaml | 16 + openapi3/testdata/303bis/service.yaml | 28 + openapi3/testdata/Test_param_override.yml | 40 + openapi3/testdata/callback-transactioned.yml | 10 + openapi3/testdata/callbacks.yml | 71 + .../testdata/callbacks.yml.internalized.yml | 131 ++ openapi3/testdata/circularRef/base.yml | 16 + openapi3/testdata/circularRef/other.yml | 10 + openapi3/testdata/ext.json | 17 + openapi3/testdata/issue235.spec0-typo.yml | 24 + openapi3/testdata/issue235.spec0.yml | 24 + openapi3/testdata/issue235.spec1.yml | 12 + openapi3/testdata/issue235.spec2.yml | 7 + openapi3/testdata/issue241.yml | 15 + openapi3/testdata/issue409.yml | 21 + openapi3/testdata/issue570.json | 155 ++ openapi3/testdata/issue638/test1.yaml | 15 + openapi3/testdata/issue638/test2.yaml | 13 + openapi3/testdata/issue652/definitions.yml | 4 + openapi3/testdata/issue652/nested/schema.yml | 4 + openapi3/testdata/issue697.yml | 14 + openapi3/testdata/issue753.yml | 53 + openapi3/testdata/link-example.yaml | 203 +++ openapi3/testdata/lxkns.yaml | 988 +++++++++++ openapi3/testdata/main.yaml | 7 + openapi3/testdata/my-openapi.json | 18 + openapi3/testdata/my-other-openapi.json | 34 + .../testdata/recursiveRef/components/Bar.yml | 2 + .../testdata/recursiveRef/components/Cat.yml | 4 + .../testdata/recursiveRef/components/Foo.yml | 4 + .../recursiveRef/components/Foo/Foo2.yml | 4 + .../recursiveRef/components/models/error.yaml | 2 + openapi3/testdata/recursiveRef/issue615.yml | 60 + openapi3/testdata/recursiveRef/openapi.yml | 33 + .../recursiveRef/openapi.yml.internalized.yml | 110 ++ .../recursiveRef/parameters/number.yml | 4 + openapi3/testdata/recursiveRef/paths/foo.yml | 15 + .../testdata/refInLocalRef/messages/data.json | 12 + .../refInLocalRef/messages/dataPart.json | 9 + .../refInLocalRef/messages/request.json | 11 + .../refInLocalRef/messages/response.json | 9 + openapi3/testdata/refInLocalRef/openapi.json | 46 + .../messages/data.json | 12 + .../messages/dataPart.json | 9 + .../messages/request.json | 11 + .../messages/response.json | 9 + .../spec/openapi.json | 46 + .../refInRef/messages/definitions.json | 7 + .../testdata/refInRef/messages/request.json | 11 + .../testdata/refInRef/messages/response.json | 9 + openapi3/testdata/refInRef/openapi.json | 34 + .../problem-details-0.0.1.schema.json | 93 + .../refInRefInProperty/components/errors.yaml | 66 + .../testdata/refInRefInProperty/openapi.yaml | 29 + .../CustomTestHeader.yml | 2 +- .../CustomTestHeader1.yml | 1 + .../CustomTestHeader1bis.yml | 2 + .../CustomTestHeader2.yml | 1 + .../CustomTestHeader2bis.yml | 2 + .../paths/nesteddir/CustomTestPath.yml | 4 + .../nesteddir/morenested/CustomTestPath.yml | 4 + openapi3/testdata/schema618.yml | 62 + openapi3/testdata/spec.yaml | 14 + openapi3/testdata/spec.yaml.internalized.yml | 36 + openapi3/testdata/testpath.yaml | 15 + openapi3/testdata/testref.openapi.json | 3 +- openapi3/testdata/testref.openapi.yml | 3 +- .../testref.openapi.yml.internalized.yml | 19 + openapi3/unique_items_checker_test.go | 37 + openapi3/validation_issue409_test.go | 50 + openapi3/validation_options.go | 112 ++ openapi3/visited.go | 41 + openapi3/xml.go | 66 + openapi3filter/authentication_input.go | 13 +- openapi3filter/csv_file_upload_test.go | 127 ++ openapi3filter/errors.go | 62 +- openapi3filter/internal.go | 12 + openapi3filter/issue201_test.go | 142 ++ openapi3filter/issue436_test.go | 135 ++ openapi3filter/issue624_test.go | 69 + openapi3filter/issue625_test.go | 123 ++ openapi3filter/issue639_test.go | 100 ++ openapi3filter/issue641_test.go | 109 ++ openapi3filter/issue689_test.go | 168 ++ openapi3filter/issue707_test.go | 90 + openapi3filter/issue722_test.go | 133 ++ openapi3filter/issue733_test.go | 109 ++ openapi3filter/middleware.go | 283 +++ openapi3filter/middleware_test.go | 533 ++++++ openapi3filter/options.go | 45 +- openapi3filter/options_test.go | 82 + openapi3filter/req_resp_decoder.go | 680 +++++-- openapi3filter/req_resp_decoder_test.go | 274 ++- openapi3filter/req_resp_encoder.go | 49 + openapi3filter/req_resp_encoder_test.go | 43 + openapi3filter/router.go | 217 --- openapi3filter/router_test.go | 172 -- .../{ => testdata}/fixtures/petstore.json | 132 +- openapi3filter/testdata/petstore.yaml | 106 ++ openapi3filter/unpack_errors_test.go | 166 ++ openapi3filter/validate_readonly_test.go | 230 +++ openapi3filter/validate_request.go | 234 ++- openapi3filter/validate_request_input.go | 5 +- openapi3filter/validate_request_test.go | 223 +++ openapi3filter/validate_response.go | 112 +- openapi3filter/validate_response_test.go | 215 +++ openapi3filter/validate_set_default_test.go | 803 +++++++++ .../validation_discriminator_test.go | 101 ++ openapi3filter/validation_enum_test.go | 175 ++ openapi3filter/validation_error.go | 16 +- openapi3filter/validation_error_encoder.go | 139 +- openapi3filter/validation_error_test.go | 250 +-- openapi3filter/validation_handler.go | 63 +- openapi3filter/validation_test.go | 230 ++- openapi3filter/zip_file_upload_test.go | 116 ++ {jsoninfo => openapi3gen}/field_info.go | 44 +- openapi3gen/openapi3gen.go | 303 +++- openapi3gen/openapi3gen_test.go | 651 ++++++- openapi3gen/simple_test.go | 105 ++ openapi3gen/type_info.go | 54 + refs.sh | 124 ++ routers/gorillamux/example_test.go | 64 + routers/gorillamux/router.go | 263 +++ routers/gorillamux/router_test.go | 509 ++++++ routers/issue356_test.go | 145 ++ routers/legacy/issue444_test.go | 59 + .../legacy/pathpattern}/node.go | 18 +- .../legacy/pathpattern}/node_test.go | 22 +- routers/legacy/router.go | 167 ++ routers/legacy/router_test.go | 213 +++ routers/legacy/validate_request_test.go | 112 ++ routers/types.go | 42 + 279 files changed, 26381 insertions(+), 5577 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/docs/openapi2.txt create mode 100644 .github/docs/openapi2conv.txt create mode 100644 .github/docs/openapi3.txt create mode 100644 .github/docs/openapi3filter.txt create mode 100644 .github/docs/openapi3filter_fixtures.txt create mode 100644 .github/docs/openapi3gen.txt create mode 100644 .github/docs/routers.txt create mode 100644 .github/docs/routers_gorillamux.txt create mode 100644 .github/docs/routers_legacy.txt create mode 100644 .github/docs/routers_legacy_pathpattern.txt create mode 100644 .github/sponsors/speakeasy.png create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/go.yml create mode 100644 .github/workflows/shellcheck.yml delete mode 100644 .travis.yml create mode 100644 cmd/validate/main.go create mode 100755 docs.sh delete mode 100644 jsoninfo/doc.go delete mode 100644 jsoninfo/marshal.go delete mode 100644 jsoninfo/marshal_ref.go delete mode 100644 jsoninfo/marshal_test.go delete mode 100644 jsoninfo/strict_struct.go delete mode 100644 jsoninfo/type_info.go delete mode 100644 jsoninfo/unmarshal.go delete mode 100644 jsoninfo/unmarshal_test.go delete mode 100644 jsoninfo/unsupported_properties_error.go create mode 100644 openapi2/doc.go create mode 100644 openapi2/header.go create mode 100644 openapi2/operation.go create mode 100644 openapi2/parameter.go create mode 100644 openapi2/path_item.go create mode 100644 openapi2/response.go create mode 100644 openapi2/security_scheme.go create mode 100644 openapi2conv/doc.go create mode 100644 openapi2conv/issue187_test.go create mode 100644 openapi2conv/issue440_test.go create mode 100644 openapi2conv/issue558_test.go create mode 100644 openapi2conv/issue573_test.go create mode 120000 openapi2conv/testdata/swagger.json create mode 100644 openapi3/errors.go create mode 100644 openapi3/example.go create mode 100644 openapi3/example_validation.go create mode 100644 openapi3/example_validation_test.go delete mode 100644 openapi3/examples.go delete mode 100644 openapi3/extension_test.go create mode 100644 openapi3/external_docs_test.go create mode 100644 openapi3/internalize_refs.go create mode 100644 openapi3/internalize_refs_test.go create mode 100644 openapi3/issue136_test.go create mode 100644 openapi3/issue241_test.go create mode 100644 openapi3/issue301_test.go create mode 100644 openapi3/issue341_test.go create mode 100644 openapi3/issue344_test.go create mode 100644 openapi3/issue376_test.go create mode 100644 openapi3/issue382_test.go create mode 100644 openapi3/issue513_test.go create mode 100644 openapi3/issue542_test.go create mode 100644 openapi3/issue570_test.go create mode 100644 openapi3/issue601_test.go create mode 100644 openapi3/issue615_test.go create mode 100644 openapi3/issue618_test.go create mode 100644 openapi3/issue638_test.go create mode 100644 openapi3/issue652_test.go create mode 100644 openapi3/issue657_test.go create mode 100644 openapi3/issue689_test.go create mode 100644 openapi3/issue697_test.go create mode 100644 openapi3/issue735_test.go create mode 100644 openapi3/issue741_test.go create mode 100644 openapi3/issue746_test.go create mode 100644 openapi3/issue753_test.go create mode 100644 openapi3/issue759_test.go create mode 100644 openapi3/issue767_test.go create mode 100644 openapi3/load_cicular_ref_with_external_file_test.go create mode 100644 openapi3/load_with_go_embed_test.go create mode 100644 openapi3/loader.go rename openapi3/{swagger_loader_empty_response_description_test.go => loader_empty_response_description_test.go} (79%) create mode 100644 openapi3/loader_http_error_test.go rename openapi3/{swagger_loader_issue212_test.go => loader_issue212_test.go} (94%) create mode 100644 openapi3/loader_issue220_test.go create mode 100644 openapi3/loader_issue235_test.go create mode 100644 openapi3/loader_outside_refs_test.go rename openapi3/{swagger_loader_paths_test.go => loader_paths_test.go} (64%) create mode 100644 openapi3/loader_read_from_uri_func_test.go create mode 100644 openapi3/loader_recursive_ref_test.go rename openapi3/{swagger_loader_relative_refs_test.go => loader_relative_refs_test.go} (68%) rename openapi3/{swagger_loader_test.go => loader_test.go} (51%) create mode 100644 openapi3/loader_uri_reader.go create mode 100644 openapi3/openapi3.go rename openapi3/{swagger_test.go => openapi3_test.go} (61%) create mode 100644 openapi3/parameter_issue223_test.go create mode 100644 openapi3/paths_test.go create mode 100644 openapi3/race_test.go create mode 100644 openapi3/ref.go create mode 100644 openapi3/refs_test.go create mode 100644 openapi3/response_issue224_test.go create mode 100644 openapi3/schema_formats_test.go create mode 100644 openapi3/schema_issue289_test.go create mode 100644 openapi3/schema_issue492_test.go create mode 100644 openapi3/schema_oneOf_test.go create mode 100644 openapi3/schema_validation_settings.go create mode 100644 openapi3/schema_validation_settings_test.go delete mode 100644 openapi3/swagger.go delete mode 100644 openapi3/swagger_loader.go create mode 100644 openapi3/testdata/303bis/common/properties.yaml create mode 100644 openapi3/testdata/303bis/service.yaml create mode 100644 openapi3/testdata/Test_param_override.yml create mode 100644 openapi3/testdata/callback-transactioned.yml create mode 100644 openapi3/testdata/callbacks.yml create mode 100644 openapi3/testdata/callbacks.yml.internalized.yml create mode 100644 openapi3/testdata/circularRef/base.yml create mode 100644 openapi3/testdata/circularRef/other.yml create mode 100644 openapi3/testdata/ext.json create mode 100644 openapi3/testdata/issue235.spec0-typo.yml create mode 100644 openapi3/testdata/issue235.spec0.yml create mode 100644 openapi3/testdata/issue235.spec1.yml create mode 100644 openapi3/testdata/issue235.spec2.yml create mode 100644 openapi3/testdata/issue241.yml create mode 100644 openapi3/testdata/issue409.yml create mode 100644 openapi3/testdata/issue570.json create mode 100644 openapi3/testdata/issue638/test1.yaml create mode 100644 openapi3/testdata/issue638/test2.yaml create mode 100644 openapi3/testdata/issue652/definitions.yml create mode 100644 openapi3/testdata/issue652/nested/schema.yml create mode 100644 openapi3/testdata/issue697.yml create mode 100644 openapi3/testdata/issue753.yml create mode 100644 openapi3/testdata/link-example.yaml create mode 100644 openapi3/testdata/lxkns.yaml create mode 100644 openapi3/testdata/main.yaml create mode 100644 openapi3/testdata/my-openapi.json create mode 100644 openapi3/testdata/my-other-openapi.json create mode 100644 openapi3/testdata/recursiveRef/components/Bar.yml create mode 100644 openapi3/testdata/recursiveRef/components/Cat.yml create mode 100644 openapi3/testdata/recursiveRef/components/Foo.yml create mode 100644 openapi3/testdata/recursiveRef/components/Foo/Foo2.yml create mode 100644 openapi3/testdata/recursiveRef/components/models/error.yaml create mode 100644 openapi3/testdata/recursiveRef/issue615.yml create mode 100644 openapi3/testdata/recursiveRef/openapi.yml create mode 100644 openapi3/testdata/recursiveRef/openapi.yml.internalized.yml create mode 100644 openapi3/testdata/recursiveRef/parameters/number.yml create mode 100644 openapi3/testdata/recursiveRef/paths/foo.yml create mode 100644 openapi3/testdata/refInLocalRef/messages/data.json create mode 100644 openapi3/testdata/refInLocalRef/messages/dataPart.json create mode 100644 openapi3/testdata/refInLocalRef/messages/request.json create mode 100644 openapi3/testdata/refInLocalRef/messages/response.json create mode 100644 openapi3/testdata/refInLocalRef/openapi.json create mode 100644 openapi3/testdata/refInLocalRefInParentsSubdir/messages/data.json create mode 100644 openapi3/testdata/refInLocalRefInParentsSubdir/messages/dataPart.json create mode 100644 openapi3/testdata/refInLocalRefInParentsSubdir/messages/request.json create mode 100644 openapi3/testdata/refInLocalRefInParentsSubdir/messages/response.json create mode 100644 openapi3/testdata/refInLocalRefInParentsSubdir/spec/openapi.json create mode 100644 openapi3/testdata/refInRef/messages/definitions.json create mode 100644 openapi3/testdata/refInRef/messages/request.json create mode 100644 openapi3/testdata/refInRef/messages/response.json create mode 100644 openapi3/testdata/refInRef/openapi.json create mode 100644 openapi3/testdata/refInRefInProperty/common-data-objects/problem-details-0.0.1.schema.json create mode 100644 openapi3/testdata/refInRefInProperty/components/errors.yaml create mode 100644 openapi3/testdata/refInRefInProperty/openapi.yaml create mode 100644 openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader1.yml create mode 100644 openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader1bis.yml create mode 100644 openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader2.yml create mode 100644 openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader2bis.yml create mode 100644 openapi3/testdata/schema618.yml create mode 100644 openapi3/testdata/spec.yaml create mode 100644 openapi3/testdata/spec.yaml.internalized.yml create mode 100644 openapi3/testdata/testpath.yaml create mode 100644 openapi3/testdata/testref.openapi.yml.internalized.yml create mode 100644 openapi3/unique_items_checker_test.go create mode 100644 openapi3/validation_issue409_test.go create mode 100644 openapi3/validation_options.go create mode 100644 openapi3/visited.go create mode 100644 openapi3/xml.go create mode 100644 openapi3filter/csv_file_upload_test.go create mode 100644 openapi3filter/issue201_test.go create mode 100644 openapi3filter/issue436_test.go create mode 100644 openapi3filter/issue624_test.go create mode 100644 openapi3filter/issue625_test.go create mode 100644 openapi3filter/issue639_test.go create mode 100644 openapi3filter/issue641_test.go create mode 100644 openapi3filter/issue689_test.go create mode 100644 openapi3filter/issue707_test.go create mode 100644 openapi3filter/issue722_test.go create mode 100644 openapi3filter/issue733_test.go create mode 100644 openapi3filter/middleware.go create mode 100644 openapi3filter/middleware_test.go create mode 100644 openapi3filter/options_test.go create mode 100644 openapi3filter/req_resp_encoder.go create mode 100644 openapi3filter/req_resp_encoder_test.go delete mode 100644 openapi3filter/router.go delete mode 100644 openapi3filter/router_test.go rename openapi3filter/{ => testdata}/fixtures/petstore.json (89%) create mode 100644 openapi3filter/testdata/petstore.yaml create mode 100644 openapi3filter/unpack_errors_test.go create mode 100644 openapi3filter/validate_readonly_test.go create mode 100644 openapi3filter/validate_request_test.go create mode 100644 openapi3filter/validate_response_test.go create mode 100644 openapi3filter/validate_set_default_test.go create mode 100644 openapi3filter/validation_discriminator_test.go create mode 100644 openapi3filter/validation_enum_test.go create mode 100644 openapi3filter/zip_file_upload_test.go rename {jsoninfo => openapi3gen}/field_info.go (66%) create mode 100644 openapi3gen/simple_test.go create mode 100644 openapi3gen/type_info.go create mode 100755 refs.sh create mode 100644 routers/gorillamux/example_test.go create mode 100644 routers/gorillamux/router.go create mode 100644 routers/gorillamux/router_test.go create mode 100644 routers/issue356_test.go create mode 100644 routers/legacy/issue444_test.go rename {pathpattern => routers/legacy/pathpattern}/node.go (94%) rename {pathpattern => routers/legacy/pathpattern}/node_test.go (68%) create mode 100644 routers/legacy/router.go create mode 100644 routers/legacy/router_test.go create mode 100644 routers/legacy/validate_request_test.go create mode 100644 routers/types.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..c68529d34 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.yml text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..c828e2ff3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [fenollp] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +#patreon: # Replace with a single Patreon username +#open_collective: # Replace with a single Open Collective username +#ko_fi: # Replace with a single Ko-fi username +#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +#liberapay: # Replace with a single Liberapay username +#issuehunt: # Replace with a single IssueHunt username +#otechie: # Replace with a single Otechie username +#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/docs/openapi2.txt b/.github/docs/openapi2.txt new file mode 100644 index 000000000..e2ac28b20 --- /dev/null +++ b/.github/docs/openapi2.txt @@ -0,0 +1,9 @@ +type Header struct{ ... } +type Operation struct{ ... } +type Parameter struct{ ... } +type Parameters []*Parameter +type PathItem struct{ ... } +type Response struct{ ... } +type SecurityRequirements []map[string][]string +type SecurityScheme struct{ ... } +type T struct{ ... } diff --git a/.github/docs/openapi2conv.txt b/.github/docs/openapi2conv.txt new file mode 100644 index 000000000..e7c1db1bc --- /dev/null +++ b/.github/docs/openapi2conv.txt @@ -0,0 +1,25 @@ +func FromV3(doc3 *openapi3.T) (*openapi2.T, error) +func FromV3Headers(defs openapi3.Headers, components *openapi3.Components) (map[string]*openapi2.Header, error) +func FromV3Operation(doc3 *openapi3.T, operation *openapi3.Operation) (*openapi2.Operation, error) +func FromV3Parameter(ref *openapi3.ParameterRef, components *openapi3.Components) (*openapi2.Parameter, error) +func FromV3PathItem(doc3 *openapi3.T, pathItem *openapi3.PathItem) (*openapi2.PathItem, error) +func FromV3Ref(ref string) string +func FromV3RequestBody(name string, requestBodyRef *openapi3.RequestBodyRef, ...) (*openapi2.Parameter, error) +func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameters +func FromV3Response(ref *openapi3.ResponseRef, components *openapi3.Components) (*openapi2.Response, error) +func FromV3Responses(responses map[string]*openapi3.ResponseRef, components *openapi3.Components) (map[string]*openapi2.Response, error) +func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components) (*openapi3.SchemaRef, *openapi2.Parameter) +func FromV3Schemas(schemas map[string]*openapi3.SchemaRef, components *openapi3.Components) (map[string]*openapi3.SchemaRef, map[string]*openapi2.Parameter) +func FromV3SecurityRequirements(requirements openapi3.SecurityRequirements) openapi2.SecurityRequirements +func FromV3SecurityScheme(doc3 *openapi3.T, ref *openapi3.SecuritySchemeRef) (*openapi2.SecurityScheme, error) +func ToV3(doc2 *openapi2.T) (*openapi3.T, error) +func ToV3Headers(defs map[string]*openapi2.Header) openapi3.Headers +func ToV3Operation(doc2 *openapi2.T, components *openapi3.Components, pathItem *openapi2.PathItem, ...) (*openapi3.Operation, error) +func ToV3Parameter(components *openapi3.Components, parameter *openapi2.Parameter, ...) (*openapi3.ParameterRef, *openapi3.RequestBodyRef, ...) +func ToV3PathItem(doc2 *openapi2.T, components *openapi3.Components, pathItem *openapi2.PathItem, ...) (*openapi3.PathItem, error) +func ToV3Ref(ref string) string +func ToV3Response(response *openapi2.Response, produces []string) (*openapi3.ResponseRef, error) +func ToV3SchemaRef(schema *openapi3.SchemaRef) *openapi3.SchemaRef +func ToV3Schemas(defs map[string]*openapi3.SchemaRef) map[string]*openapi3.SchemaRef +func ToV3SecurityRequirements(requirements openapi2.SecurityRequirements) openapi3.SecurityRequirements +func ToV3SecurityScheme(securityScheme *openapi2.SecurityScheme) (*openapi3.SecuritySchemeRef, error) diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt new file mode 100644 index 000000000..82a5e0bc8 --- /dev/null +++ b/.github/docs/openapi3.txt @@ -0,0 +1,155 @@ +const ParameterInPath = "path" ... +const TypeArray = "array" ... +const FormatOfStringForUUIDOfRFC4122 = `^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$` ... +const SerializationSimple = "simple" ... +var SchemaErrorDetailsDisabled = false ... +var CircularReferenceCounter = 3 +var CircularReferenceError = "kin-openapi bug found: circular schema reference not handled" +var DefaultReadFromURI = URIMapCache(ReadFromURIs(ReadFromHTTP(http.DefaultClient), ReadFromFile)) +var ErrURINotSupported = errors.New("unsupported URI") +var IdentifierRegExp = regexp.MustCompile(identifierPattern) +var SchemaStringFormats = make(map[string]Format, 4) +func BoolPtr(value bool) *bool +func DefaultRefNameResolver(ref string) string +func DefineIPv4Format() +func DefineIPv6Format() +func DefineStringFormat(name string, pattern string) +func DefineStringFormatCallback(name string, callback FormatCallback) +func Float64Ptr(value float64) *float64 +func Int64Ptr(value int64) *int64 +func ReadFromFile(loader *Loader, location *url.URL) ([]byte, error) +func RegisterArrayUniqueItemsChecker(fn SliceUniqueItemsChecker) +func Uint64Ptr(value uint64) *uint64 +func ValidateIdentifier(value string) error +func WithValidationOptions(ctx context.Context, opts ...ValidationOption) context.Context +type AdditionalProperties struct{ ... } +type Callback map[string]*PathItem +type CallbackRef struct{ ... } +type Callbacks map[string]*CallbackRef +type Components struct{ ... } + func NewComponents() Components +type Contact struct{ ... } +type Content map[string]*MediaType + func NewContent() Content + func NewContentWithFormDataSchema(schema *Schema) Content + func NewContentWithFormDataSchemaRef(schema *SchemaRef) Content + func NewContentWithJSONSchema(schema *Schema) Content + func NewContentWithJSONSchemaRef(schema *SchemaRef) Content + func NewContentWithSchema(schema *Schema, consumes []string) Content + func NewContentWithSchemaRef(schema *SchemaRef, consumes []string) Content +type Discriminator struct{ ... } +type Encoding struct{ ... } + func NewEncoding() *Encoding +type Example struct{ ... } + func NewExample(value interface{}) *Example +type ExampleRef struct{ ... } +type Examples map[string]*ExampleRef +type ExternalDocs struct{ ... } +type Format struct{ ... } +type FormatCallback func(value string) error +type Header struct{ ... } +type HeaderRef struct{ ... } +type Headers map[string]*HeaderRef +type Info struct{ ... } +type License struct{ ... } +type Link struct{ ... } +type LinkRef struct{ ... } +type Links map[string]*LinkRef +type Loader struct{ ... } + func NewLoader() *Loader +type MediaType struct{ ... } + func NewMediaType() *MediaType +type MultiError []error +type OAuthFlow struct{ ... } +type OAuthFlows struct{ ... } +type Operation struct{ ... } + func NewOperation() *Operation +type Parameter struct{ ... } + func NewCookieParameter(name string) *Parameter + func NewHeaderParameter(name string) *Parameter + func NewPathParameter(name string) *Parameter + func NewQueryParameter(name string) *Parameter +type ParameterRef struct{ ... } +type Parameters []*ParameterRef + func NewParameters() Parameters +type ParametersMap map[string]*ParameterRef +type PathItem struct{ ... } +type Paths map[string]*PathItem +type ReadFromURIFunc func(loader *Loader, url *url.URL) ([]byte, error) + func ReadFromHTTP(cl *http.Client) ReadFromURIFunc + func ReadFromURIs(readers ...ReadFromURIFunc) ReadFromURIFunc + func URIMapCache(reader ReadFromURIFunc) ReadFromURIFunc +type Ref struct{ ... } +type RefNameResolver func(string) string +type RequestBodies map[string]*RequestBodyRef +type RequestBody struct{ ... } + func NewRequestBody() *RequestBody +type RequestBodyRef struct{ ... } +type Response struct{ ... } + func NewResponse() *Response +type ResponseRef struct{ ... } +type Responses map[string]*ResponseRef + func NewResponses() Responses +type Schema struct{ ... } + func NewAllOfSchema(schemas ...*Schema) *Schema + func NewAnyOfSchema(schemas ...*Schema) *Schema + func NewArraySchema() *Schema + func NewBoolSchema() *Schema + func NewBytesSchema() *Schema + func NewDateTimeSchema() *Schema + func NewFloat64Schema() *Schema + func NewInt32Schema() *Schema + func NewInt64Schema() *Schema + func NewIntegerSchema() *Schema + func NewObjectSchema() *Schema + func NewOneOfSchema(schemas ...*Schema) *Schema + func NewSchema() *Schema + func NewStringSchema() *Schema + func NewUUIDSchema() *Schema +type SchemaError struct{ ... } +type SchemaRef struct{ ... } + func NewSchemaRef(ref string, value *Schema) *SchemaRef +type SchemaRefs []*SchemaRef +type SchemaValidationOption func(*schemaValidationSettings) + func DefaultsSet(f func()) SchemaValidationOption + func DisablePatternValidation() SchemaValidationOption + func DisableReadOnlyValidation() SchemaValidationOption + func DisableWriteOnlyValidation() SchemaValidationOption + func EnableFormatValidation() SchemaValidationOption + func FailFast() SchemaValidationOption + func MultiErrors() SchemaValidationOption + func SetSchemaErrorMessageCustomizer(f func(err *SchemaError) string) SchemaValidationOption + func VisitAsRequest() SchemaValidationOption + func VisitAsResponse() SchemaValidationOption +type Schemas map[string]*SchemaRef +type SecurityRequirement map[string][]string + func NewSecurityRequirement() SecurityRequirement +type SecurityRequirements []SecurityRequirement + func NewSecurityRequirements() *SecurityRequirements +type SecurityScheme struct{ ... } + func NewCSRFSecurityScheme() *SecurityScheme + func NewJWTSecurityScheme() *SecurityScheme + func NewOIDCSecurityScheme(oidcUrl string) *SecurityScheme + func NewSecurityScheme() *SecurityScheme +type SecuritySchemeRef struct{ ... } +type SecuritySchemes map[string]*SecuritySchemeRef +type SerializationMethod struct{ ... } +type Server struct{ ... } +type ServerVariable struct{ ... } +type Servers []*Server +type SliceUniqueItemsChecker func(items []interface{}) bool +type T struct{ ... } +type Tag struct{ ... } +type Tags []*Tag +type ValidationOption func(options *ValidationOptions) + func AllowExtraSiblingFields(fields ...string) ValidationOption + func DisableExamplesValidation() ValidationOption + func DisableSchemaDefaultsValidation() ValidationOption + func DisableSchemaFormatValidation() ValidationOption + func DisableSchemaPatternValidation() ValidationOption + func EnableExamplesValidation() ValidationOption + func EnableSchemaDefaultsValidation() ValidationOption + func EnableSchemaFormatValidation() ValidationOption + func EnableSchemaPatternValidation() ValidationOption +type ValidationOptions struct{ ... } +type XML struct{ ... } diff --git a/.github/docs/openapi3filter.txt b/.github/docs/openapi3filter.txt new file mode 100644 index 000000000..ac738e75a --- /dev/null +++ b/.github/docs/openapi3filter.txt @@ -0,0 +1,54 @@ +const ErrCodeOK = 0 ... +var DefaultOptions = &Options{} +var ErrAuthenticationServiceMissing = errors.New("missing AuthenticationFunc") +var ErrInvalidEmptyValue = errors.New("empty value is not allowed") +var ErrInvalidRequired = errors.New("value is required but missing") +var JSONPrefixes = []string{ ... } +func DefaultErrorEncoder(_ context.Context, err error, w http.ResponseWriter) +func FileBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, ...) (interface{}, error) +func NoopAuthenticationFunc(context.Context, *AuthenticationInput) error +func RegisterBodyDecoder(contentType string, decoder BodyDecoder) +func RegisterBodyEncoder(contentType string, encoder BodyEncoder) +func TrimJSONPrefix(data []byte) []byte +func UnregisterBodyDecoder(contentType string) +func UnregisterBodyEncoder(contentType string) +func ValidateParameter(ctx context.Context, input *RequestValidationInput, ...) error +func ValidateRequest(ctx context.Context, input *RequestValidationInput) (err error) +func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, ...) error +func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error +func ValidateSecurityRequirements(ctx context.Context, input *RequestValidationInput, ...) error +type AuthenticationFunc func(context.Context, *AuthenticationInput) error +type AuthenticationInput struct{ ... } +type BodyDecoder func(io.Reader, http.Header, *openapi3.SchemaRef, EncodingFn) (interface{}, error) + func RegisteredBodyDecoder(contentType string) BodyDecoder +type BodyEncoder func(body interface{}) ([]byte, error) + func RegisteredBodyEncoder(contentType string) BodyEncoder +type ContentParameterDecoder func(param *openapi3.Parameter, values []string) (interface{}, *openapi3.Schema, error) +type CustomSchemaErrorFunc func(err *openapi3.SchemaError) string +type EncodingFn func(partName string) *openapi3.Encoding +type ErrCode int +type ErrFunc func(w http.ResponseWriter, status int, code ErrCode, err error) +type ErrorEncoder func(ctx context.Context, err error, w http.ResponseWriter) +type Headerer interface{ ... } +type LogFunc func(message string, err error) +type Options struct{ ... } +type ParseError struct{ ... } +type ParseErrorKind int + const KindOther ParseErrorKind = iota ... +type RequestError struct{ ... } +type RequestValidationInput struct{ ... } +type ResponseError struct{ ... } +type ResponseValidationInput struct{ ... } +type SecurityRequirementsError struct{ ... } +type StatusCoder interface{ ... } +type ValidationError struct{ ... } +type ValidationErrorEncoder struct{ ... } +type ValidationErrorSource struct{ ... } +type ValidationHandler struct{ ... } +type Validator struct{ ... } + func NewValidator(router routers.Router, options ...ValidatorOption) *Validator +type ValidatorOption func(*Validator) + func OnErr(f ErrFunc) ValidatorOption + func OnLog(f LogFunc) ValidatorOption + func Strict(strict bool) ValidatorOption + func ValidationOptions(options Options) ValidatorOption diff --git a/.github/docs/openapi3filter_fixtures.txt b/.github/docs/openapi3filter_fixtures.txt new file mode 100644 index 000000000..e69de29bb diff --git a/.github/docs/openapi3gen.txt b/.github/docs/openapi3gen.txt new file mode 100644 index 000000000..741a5043f --- /dev/null +++ b/.github/docs/openapi3gen.txt @@ -0,0 +1,11 @@ +var RefSchemaRef = openapi3.NewSchemaRef("Ref", ...) +func NewSchemaRefForValue(value interface{}, schemas openapi3.Schemas, opts ...Option) (*openapi3.SchemaRef, error) +type CycleError struct{} +type ExcludeSchemaSentinel struct{} +type Generator struct{ ... } + func NewGenerator(opts ...Option) *Generator +type Option func(*generatorOpt) + func SchemaCustomizer(sc SchemaCustomizerFn) Option + func ThrowErrorOnCycle() Option + func UseAllExportedFields() Option +type SchemaCustomizerFn func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error diff --git a/.github/docs/routers.txt b/.github/docs/routers.txt new file mode 100644 index 000000000..fdd5a9dda --- /dev/null +++ b/.github/docs/routers.txt @@ -0,0 +1,5 @@ +var ErrMethodNotAllowed error = &RouteError{ ... } +var ErrPathNotFound error = &RouteError{ ... } +type Route struct{ ... } +type RouteError struct{ ... } +type Router interface{ ... } diff --git a/.github/docs/routers_gorillamux.txt b/.github/docs/routers_gorillamux.txt new file mode 100644 index 000000000..82aad106d --- /dev/null +++ b/.github/docs/routers_gorillamux.txt @@ -0,0 +1,2 @@ +func NewRouter(doc *openapi3.T) (routers.Router, error) +type Router struct{ ... } diff --git a/.github/docs/routers_legacy.txt b/.github/docs/routers_legacy.txt new file mode 100644 index 000000000..71017a6c1 --- /dev/null +++ b/.github/docs/routers_legacy.txt @@ -0,0 +1,3 @@ +func NewRouter(doc *openapi3.T, opts ...openapi3.ValidationOption) (routers.Router, error) +type Router struct{ ... } +type Routers []*Router diff --git a/.github/docs/routers_legacy_pathpattern.txt b/.github/docs/routers_legacy_pathpattern.txt new file mode 100644 index 000000000..7ad87f2b5 --- /dev/null +++ b/.github/docs/routers_legacy_pathpattern.txt @@ -0,0 +1,9 @@ +const SuffixKindConstant = SuffixKind(iota) ... +var DefaultOptions = &Options{ ... } +func EqualSuffix(a, b Suffix) bool +func PathFromHost(host string, specialDashes bool) string +type Node struct{ ... } +type Options struct{ ... } +type Suffix struct{ ... } +type SuffixKind int +type SuffixList []Suffix diff --git a/.github/sponsors/speakeasy.png b/.github/sponsors/speakeasy.png new file mode 100644 index 0000000000000000000000000000000000000000..a7d84210765503fc7a7f107f4b5f6da5f3b0e8c1 GIT binary patch literal 12425 zcmd72_dnaw7e8*bN{!a4T_g4?YHzg(sz$A75i|DQimF+}o>6Ad|r=7l5?Kt-t)Szdy?FH&v{1cYO4|x(h_1}VG*mVDd}Nh;riXl zUgIEaG0Y2ix?0jBLQ*>yRZ|Iy&rKJp)4}5KD&1 zH)*kj@bFIH_mhV-TaRC*D_YuF%T4@z)XB6(WVl~bf_FEryP>d_P>K7V?aLmw=)5QecfVgJ>>nm(}_q)|7LXJf6b3hH9%cq90Z_n z8voBnbEG;C{u=Tl%8ZVV__BxxPR^H+3X1<)vnge-F-T*+AdC32Y~NnG!~Vetqac{{Unx{n z0gC@udh{6ZpFrJRkNHTP4H=|93k0D1lH89$!tZ?*F=o<$D>Pi7LTtPIO~bDg(Ul)#_;d zb%#~$Z3*=`*jWlOl)t1Xpd9LMvI<^xBU}4#% z@WM^?uAmPBgztLMzTeCX3gB29R2J5jBWH%=V%br99qg>KXMOk=fIbDmOyYl!8Oq5i z6qx+)_DqB^;`6e;|CDERo@D<*6ZHW4-)KcK-TC^@6*WkP{ndzP0VKq}1=`Rp&~bbw zUD9~|(qEjm1g{jj-KzMUHa?Oytb4;dV4?R)R#X2&+ZPnptkva3=_pMn$>CMFYE<~j zI{>Lkxo_)PhG#-*t1}Uri|&V~5hGz?@xTFBNwDSVQQK87`!k!enT|1&;>gLLB>uP# z?^dUih|n?pKfcE*Ogs+i)Mja#=1z4~&iVi=6TXtBZ7vu)dhcA@qSPiAl>2e#TC_FS zrUNw&_j=y({HGOd^Pw`&bmPRQJlgGulnW%aM#dM%u7_G$+#$Bj(^|){$(Oy&Ac3+3 z;vCGt0DKd*M3P!6b#^ru!ays;DtcwIhl;hLZ3MFb$sS2D{}MjSL@X^J8=P-ftdB&_ zF5}iNXP$oCp}w%4TO0=GlHs$t3$u{K;69bb*gXidE%Wd?-E+`*CPwpfN7E~L=E~CL znlWj6sNRMNck#8rZr}46us|e<(`Uw8lQA!?%#qM9#}2o!UWS;yHAybvE_;i-@q|e) zw$&X;hXudK+rtJp(sYRs{SzWpw@ygt>Bo&YgZz73R(m_~i_YXf`p_5^GC7-T$su$+ zP$`A>_L7glI3h@Z&R?)(G(VnP#;Z?3h~|?zapK9v2S6=z;@XHnw!-@1i-2}?xhyN4 z?jA36kfJ9?81f_hdTj2MTy_V&ELRX>G-r{+M<7yt`MRuJ;7#k2NQ!&`OB^r4H|DOeS)WPFXMO5 zhn5z2ID{?hRCCn|^6Z3A9rS{?Hn8i55C5v!1Q8C+q_&U|mUFm7DPqgGXMQ&I^ux#G z_U_NqcJ1ow8f9qHiJH}jQuX)U;`{!s!6fl7TWm59KXSpdH44bx1!T@Y2c&Y>|Jrq; zW?bq$?#}Su+5Gl0mA;P0d=676<}>&%b*~9%(>y&fEG!c1v6_Dc zIuQQdt7VGW_j#IR<(OVeOnm6&E|BU&P~j+wQ>u0_l8*0CarGo)MR7m#yNS=uCB3+?=ycz88$^V3?mLY_quJw=P)Qg)GQ#X-nbHF?X*Xy3 z6t;y(#zg$1nj}XR5+pqALmpWN-1=<{pcZ?Fs5dm9f`BBwK69BVxqW}oo0jmk8y^@; zyG$kd$-8T5?Usfg8iovpwN-K?zCypHzI$D03yd67pq_at5O3Rn?b zDyH9)kIb!3(@L)1jIDvdgC3t-ub_#RNZpPsg+4|eEar7M{tDi5(tQE!P7h?Gi2V#7 z%;d?yFI?cLuTGQxJpBXdD{z=%PQ1wS6k;h&vy`|d5-Q5QVahL%Z_KBrpssy|PhK^|Uzx1>H zCGjyhdy>F|K6N($O7!Z579o|$+f!%7QH8V%NqbV3d*z&t6U%ALTku~t6;T~aF}^(m zzDbPqJj>w#fj9jdPbx!h-C~#ZU!~5FIedI$bf~C(P%?H*tNdIKC47jZqkd9H3wF7rxCIgB0t8G>wviEKo4 zWx6G1mKQ4;y3HiQ;BLL>O4{vhS=%^(!0SJoTQ0YC>aG>#G|&iIz@&lnNF%ZcPbJLR z!qkDi64;*sjL~$0Ym(!Vw;JN5mKQ$49(gN7Ggf2(h1N$i!lFUP>_fi2HGATYu+L?Y z+MdJQi$A868l0jcxt;vnX{CP28G9Dbp(Ly%=6uJ`BLM=uY8A#vd5pP8)1 zFL)mY=7l0w4uK9Cftv9crTwS@1fB0qNh9yuv)7RP@^=LKsv7OnFQeWV0&}%;6sC;| zI84o1b2uw0k6$-au}?CM3t29r+%hMr$cf>dJFPJEPDRcf+Hdr{(O`@%tou#LC(%Et z*T27l3R0wB8ynLcGN@!nKmqc0E6tfdc^pKO0RG-=L+ps&Cu{QF_Xl*XDyvh$jls^i zot|kb^T+aG_}^u8k*(>%>daxJ?H(?~KM0K zR22tmptKJ?0Cl``?j|J=xE4s~bfgYeKhdiE`2GA3O1d;h_~yW7yAh@W6&tbsAo-MU z>G~5FC9P?t-5&Cp%_Dc^5%%P2N>K&BS6*S`^(fak@I!LcFskRddz)tW#fb!sKhx#fWx)CK6p z`Y>x%Y8p@2PNUaDU5kagUN1(UN?LDKgZx85p!7;b4424a!To-P*s2v^@@{m$5i?cE z0GO`WRx0Ew&_V?~O2Z20p&s}7S&>hZ)fOYB$;Ootn~SVsS2%22U=i>5UGUcbRmi|KFM*}>|)XG2TD)Q|8;D}7x>_cX(VhJVE*5Av1=MYFF>4=!@z7N6@B+80T2 zuh;F>$EOlyZhPlLzdazh_lThV#G0=<;+}E zt|@vml=4>7v7He4l2zna^i{+@syk!3P2=H^q0@CjA>XsTY1a_`stSV+`{5D~%%D#9 zcR1iEx*hi@nptjjju#b|v}QcZiMLGrDWfQ-P7G2MlT0%dYlP#1S%m?E(~3+xJlj#X zj7Y{4>~~K^73y&JMmvNfjh{I*)~4aGCZ|Ri790AnH<;^Qc3q3;5r{DRw0&=`ZpFj^ zE4l0=90OM>>YR2KSdY%L@+P|&Y8t@bUkXNpa=6+5NFUk=xtuL^ z^L3g;0TA~OE4$xj+q|Mas#j(XIIKzZcVE;{=?au<769$RsC?TqI>Us$4D#}S%zsej zy&zQCh0NC*nl@CWslUxcC}krx-=73w0_Itbj+{(&o-BaE-;lA#K59 z75iNJNnYOV^V^(u(^%=-qtT?BkTq@eL=Gb4Rf56uclB@z37ZVijQB<*eE_FNf(De$ z?Pt%%t`i-;c^W#qs!S(%QI#G5GmsVYW7>^mM;*r;I+gS}?#ZE3B2l*X*BXosm-0U! z>;#{2XR{fv(U0G7W-lO`mJO!16l~6RJH3Yu z;5JT1ggcO%`5rsNPm_o$VZ1UfVg!eD)k-`SP^nb?jib@a`EeX7Gn6PM#mcYT6iUQJ;6?vOnv z@<}kG4CVf`E!T-wzsa`4H`;}4G73{4Uj$L~f(KW#FL0zom`uC`Nc8_{n-CO{ zVl^RNa?>5-yiQ9{*&KI{m^xp)sXO-j)Z-1bBkKKQ>rQCuqp6WfTw(24k(=7p2AKrY zy{WsJsL@+U%yh^^4#obO+%6c;26z7|JN#x!jC?p3Y~&STf4XBc6hMP9Xz_WYSM z?x6fE4)m_2E#9L*|CvhOo7${a{`{qU{9^RsPynVQ^KnOhH_%_XnV;Plo<$|rBC{aV2fD<2N(~S( z&jlYfso+C3oL2vfSpgEV$~;JWP7}iF<;!G;x8c$+a#aW9JltIJJZtq9&<FkqO{fDSYV#tVVj+Xz-S|zIS84_>e1yacTlq9#}&E3d*#q%hCdDOh_(~cQa zV``G~_ptpXAEL)3k2D~6f6cT;w4h_G^Qi%z25=7uAtse+9CMvY7-N?U%1QzrI735> zU&e-y&6yrARtn7_@<6G5;iWP`i-E6=xK z`n|z`_lO}X!pt;EqPp&8PRof11Kt*O>TJ#xCN8Z%xZy)e5AHosXZ$J_ZBF1P zQJ#gDlIzuKCwZ+*o+2YPY|x>Wi^pus)wH0NG`ID-UuRr55XMcaZ?jNutS<^2EL2zi zqP#!l0?)(rp47pI+5@;7S%?vpvo{;Tv7dsK8O;D}b3K(h`a}0`c7h6?U+d{}7vBR< z(QIeO5veUcYDa%KRk^Rrp4WDv6mlMemJAGD3%CXdJ>AYl!Z8sloPSV<hcAlHtArAX z(#(4*MWZfN!cbFt{?o(37iQ?6>|CB2Qtwyj)fGv+Bqz78)=*&kfyP?^{s$wuiPwfw zEyID;)74p#J|uD-NEZB<1_pBhpf_EOIx(VjJ=rgdo|eJe&T5@Qr>>sHwt6 zHAW`Z$et(0{wSagqWKCtS}w4v9_;oL8XumK&ig z1JDQ0pr0HGjuK)x9)jhiOqM2M+3Vn~hM;%`&pOvqD{s7XrGGzs7P$|Xa zQ>6BlV_XcF7br3P(u6FfM_rC%1RTFAbX&!0_n_v>Bzj17T>AU{tDUZVppVRr3p;WP*tI&-uvBgbj;8(Mj~p+77=L^v z6f;{Ept7BQ_Aa=^+wVFdquIysW%Zu)5ysI1{E@F(2bLsqnttJe=Tsy&aRIPr*Lhuz zFAFLW;!>!VtMm}!GFdyO2Td95WB_5yW?F%x#|ThfKzcQ23Aj>FB!^ipfkLs6=}Ls8 z&2zmwFM<}I4bnnlqTZ>f8EMBzd{`NNl1QLiOCUCow zACa9Jeox#yp$yw{r5m-L(eX0|hPD+Il|U+ve#F%F z_k5N(y$~2(^)ohiUx=|Z5o{%&JS3uXETUkF4hL*vsPL=hh`30gf@a>>i68b$%^q@r zUSP{ZRgn&${ex~fma&I9N3T09v6GJjYQxz3*}Qak=)XNaxp!1#T5%pz zT4d}m3RV4Zv88Z@a?{Lm@XbpM*i=@Xa}RQ&NsxD+)uE>4SbK+1SNmPh_q~INOMu+; zp~hOEiJhBn^TV%3zr}jdyKAbuGDb;jz-J3`{>@L!!1U)f0!t+m8BC7Q9eA8JD^%u1 zZQwpD;#OLq6JocY!gNmo?cl_B$ya+4e(_(G94V_w`E_Ls7W{P z)EUHGB9|(#(|jD48mRhebtPYyTW9D(+G5UCWShe)K~U|egj&e-1LAD)cO2*oEsZsj zh9UEiq?4kKI3&rM0t`{4kiEWq43Fp|q>g0GcX;?5FwG7X#@9%MFCoFOYck(^S{i zTo|#}36FKpSqn6Y^~fia6wM4+aDgLDEU0T1&4J}?)`q)Ljry&QCa!tBbvIV!?Ke!> z>V!XMUI~=0TGQ2dn|_n-<(Lo9RG4yDrB5! zV_OCfuljv~k5(4Vr_bb+D*wjbpl)Ww*&IFMSP}bMOz&+r&RYn5pCA3?^Ks4NgeI83 zPhf7TY&2lVo4%b7_I^HL9nK+=w)&>r6L!%fp@mn@Ttm-k`pXtUq*d}meOYoq7; z?eUAm_v#e@Qk03$b$QT*n1>_Zgq74oZESY`!ZwW?5R*p2U}rKJMP?=mnt+rPcC09U z;v@5_g22{^ZnpLNv~90PIg9VX%`TM$TP21Xdq{Hkq6N_#0>#L+H=p+>(nL}`9BQBK zf2>0{`>U9TQbNEhAUmM&trHJaIXiAnwqF=o@6c`GvK58-Y%WX^BvT?c>t5m-0-*f$ z4Jj)qoW1ehl?k1Z;-Ht)e&?3j{fbqIe=k<`r7O zS)UJrRfxQZlZk+jDlMVMv|g)~A17w>v>4=)gDVz&^flhW=i16lC+Ee>+WRiS3#b)R z8GhOsgI-kEZAj9ZvI<$JD8Fy_PETr^Ea3Upp_PDiwms`$@;RV9;%sO^>+@bRns$ef~h{$q-foy>H&%r zEAqIIB#$fMseYXXwBOxQ@?nAQ3>hd-92cH3&_pjB^JyC8r)w^(=lwI4vH-iO?>8q; zLucoBXEXdq7vnsktA$P7*2b;J{9BNqt9%}k29w9WE8Qc#z8tKvS4=@r_#?AtKBlu@ zmb+MJ(`0XIMaKmU9wFd5KZrg7l2WR0$dovLA^ezvXR&6cktE)99{oG%n1m2=sZZ`a zC@hftOf+w%?iE0kgvFRZr|faPIOm}siBEO=%}?K*(^jJi8Y_TblBHpFj?)c4l2vL@ z5Y6SvP#mr{m^vO*@!KQLe*XAnp2@4yv~Uar|NJf!%;11c(BFBIWOW>SjUhkcT{2t? zR%EdjJk7A&D(ZK$P|fT~=!}Xh9ZHYhS9%M-$7g^IIqvRDPx)6xoO>1s4 zX7KYO3+dM0Y4mQ^BR2Ij5)~I00O8fy6}HxF-@S>M>b z-M&wX{S>`D1FD+zO@8A}vLWiHdy?TUv?_a+lB0X2ca*P&*8$CC2kyp7FyMntC2&86 zLvp;6r4JUij}JQa_p3;u(otD>tjX`3k!8a|l@%FzRN>~FU`%q`@PjA*U8M5ET7{)g zHjGCWUxf_~#>PvZ7d56=i;^REYTr`ea`pYRs-Py%>B6UoFNCcfdMzRsT^13a=|s*X zN53$kTOsQ?eF_i`yONcB`Ew+hslJHLU?-D*kOnlJO#b1ZC@X>1+2a0Ude7c+@7}tA z%EE)UU*{fLH&3InH&(8S@c&E>S=A&%B`d#8^{^{)`7vR`5b($CF)hjm3`;U4^puuv zS!+#i*(_cS7=Z_BDh(ErtGoi}>VP|H^o??~ds^QWc-<0re4*k|Kqmvq_L_%$a0zMT zJw?Wd@$CtAzhE14Y()ULrxd@Vt=R`u|l|s$$g_JBWtMCZRx*Z<*s@|)O#t| z6xydyzpB2He?7JruTHQ3;#+zmtK)(rUF3DnMY4yl@o**<=d0e|UZJ``m;E1SbuDhi zb6rXTo&nmp$ydt_Fl;00Aph~vk47w8RfI+fqoIL7zj<)|1DIQ4t!=x*T>%ArPkbqg)Z(ZPq?G9>%o=>HoZkQIHIE0zK5Ex`$x>vo$q&za{@qJ5r zto^K`0wYfrRs!%vws zUhc%cEA&VcfQ_VUb&T2ar{kTI_YKkCt9pkQ2+Ar5sjbX}%^fcP=~z{0_Afn=38>Y0 z^XQhUJw~MIWn0|ivkRZsc`%F++yJ1c{U!J&RWMlQe|&#GMS1Yr{4S4+SfGj@ zU$Qn;ezI((r+8O3u3d0_9D$j+e_O7Ujn@pXcat*q&QfCc-9hV)j>}vtI=F(odIOIH z0~W=0Y@O%5XRj6{8Sux_fZk#Y-xc&IzF!1L7ZYm9kjnNSoh)BFe@2OI$8>Oe{HFwt z>8^{O8rT;+K08|C=acTG$y)gx<*ox?kWrJ98JQhMz$CC z(%U-oMvXijIHng8)hV;(;temFNOwTFi5?6MRp*1=eD2iqbB8CR(yN;qK0&cEY!?ln zhU6+FV%uLY3<=rq4l&&f@N@T`Jp8rtDd@%} z+H4IfBM-An?x0~@ob1xYhk2nkS*|st8NTsT%6~{-yXW$!uMf0Jx+cDy9`V9xl2c>} zWXC9_ePBbP$c{sJPRp7G(DA*x5uk^_8$45VnA(DFc(hKW*?ff;aM{QniQD`-^oq2V zjSZ)g8C~HZ5eg3{b#WOB3#1y&1e3M$$FMM0GZB@WmXXocUh#r9ku(>xBW&$ZCSl$5 znycyLK+*t-agNdZoB}~aRNkRaAL(n`2-&Obd4XADXQn z#&7;O-DV;II;8MdZiDhdkd_YNC&~dyL&y7-;y-;OSFA4Z-p@IFcWQ=qi$S3lc@%Tg zOX~Chi+jnzM)&P4L-vk@OA5gagIugp z8s3z5ni0-+z}fnx*LE7PW>Kw&d;;F$a`@UObv#=1XgmdXB$%o#vvFeRBVX!h!=-D| zWb$3osSmJb=pEVD<-3nWek~Z2zE$F}I(y=j_R`4&xyAL{A)|TWfDn!Y^z{@Yu{W zrlC3w!a=>8lG~@C%pxnvM^Z01F>+N^028;XdQv%PM!#-sS5W%ASL(ylMaWBBr3Q14 z+20Kb^#=Jmd{lGZz|2!K!nHu1?<&J8=6uIt1KuM?I}f+EI-;Y0q|BlQ5W|*3Cu`w* zWuBL)$(b%sq?rJetrj&vC9@x~YuXTP-~LDGSLFLmLBFpW3$nG(CXEAgMx-)nI7MC7 zerz0~x~;lSVj8T2j&83IB59VD-hSW~$6u$;M{oWVQVvv`cZr@%9`Es6iRf?i^DWV4 z7L3QMi0+zA>k$^K%_kFnk2d=tPJhh5KU@r(D{AoYRRZ~EiE{OjkLAF#o884pf?>QV z>HEwH9xASj4ce|1jWLc6TBm?F6$St{+={5{&gS?}&A|!d zz36sReddeB#&dt1^<<76E~Ef$F=CI|bpwMwU~@Ty4+ z#J8)eVSzI~96%cOr`iPSQqpEXmJdm*-YQa}|LEOfLcIa6nEQ4eqdh*pP4MbWwU@zV z?mUB0V7%gua;0dEQgP&$dV?R9MtIHL{7k$CBNW98BQx zA|&G_4GJEdw0QxWt=xx+wdhZpYDY|Jk4HiGmgJL(MQlo%)YL@b!blscbx31M)``;jRYD-ZmmInw0^Tk`MAs(wQQ@nv zG^)|-R}FHkEzU>V+1*7TaYwVX8QCyIb5|_I9l8Ib@#9R!oL86cu_MKZL@tEK#N1Yi zj!fK^Ag|I1;)H650grLYe7d)W=ss+kniMexc6x32X=Vi?c3}bGid~T(WBf)~YQ&>_ z__MS_7R5QGKA_6S+hRS+z3!-X3efb?o(~I^@gbQuod%1dEIru|M5E!!>vT1UxYVQ; zT-}y0@Kl!AZDa6UwBNlP@jC{~e6pqV8Y*DPpvr=OoYQ;|Q+|Pv+vlA_b=L%#-puRP zeyUn-<^5w|_}negyj6y}vsK3B{ZP|h6EO^RU`e~5w5ISgOL7p{%ocG_kfHiU{Rd}= zdPFMkuq`sAyS-cymUL68TWk9|+M^C90T0os?nZv}Q)lGS>BmXm-%hT{sP1>zzTEB1 zR0M66WXmEyT59flQ&rZ%^B7)$zk4U;viN=xdbkff-r#0X_u2&_=rZB0^Ke_R^*;z75Yd7r`1M5vbFN3)Vo5Y zfXeF&dUeL<8lr>9cdQR_j%jXFa1sp&8sEL$Ab?9dJh1FyXRVK~Aa8MRZ(|NS$ zYBpG-%rW^Zj57tyL-(o(x&r`g+ zBewwPUXN5ABKd4p?HyP(TMYvF`0ETWo7uea_UtUqANp7_8e38us=aoPu|G&^@W$ck zpzOu^>7pmFrnnQ;OfR#V?0CA?!K5?l&fq=hG}RbN=GTku&uVKb&tu8X1x;s&;n6zdzHRI1LHW?aC&p3k+(|O zKK^)>h(CfgyNGEEe(dsmA?IxlujVKcS@eD>kXa;Zmwwaw+mEgQOws0L&h-8^b*q+x z)F0cM*Ywu6#`a;{({6VAP1B+d)O|O5&XRYT6bDDBkC`YPK)@(Fic>k7_;X0wEkr<9 z=oEak>Uksp*%_C~Ole@jx+=X}*riK;vtdNMx~LVkj*6KR@bGy4<>s`=|O02Ib z2lgxmM0&EIOUC}ljhqxIxg}7ct-O~Gr!miSF!j#L>Q@nxDP?uqN4HrrC>p~n0lL=- zuANK(-y4*eU+r99E6+3nl+MqCu?iqwM{yuJk{DQu^qe@{XRj7B~Fkr8Rk#-Dxb<``DgkjbXVXj4B3$Z6vC_t82`~p@xUd3{bl3;uyBOxUo-hc8;zrk zbLRhQpEWrA>SO*t`fcc&RPCdTIN0d%zy8l!j^;!%L)iam4@@oqi2SU7n*4^1^QV6} z)^ElC|9s*nT!kZae_64izwUC1T)nvr|J96HqDX{YYOkC`*#6$fjsf6TC-{84i1G1X zy^##skuig}AN$Ev?kdutgSw8n6prE8A|m*YnKofK`1jqeZ+}OQEKJ_*QaO80?&=yJ zZ|iSgzR1*$5-VCeV6CCibhFXk!9OB*{*+1f8jVoP&TX&;akKxEIibJvv>d<(f}3h2 z0sd<8ke?i+f4wlVs2%+kuvo$X|F1*Uz#~IL64if<_D7lH>Uqrmz_I0&eKVE+MsJKO zca5vihm-8bbEdzK75d=r>yQ?LNnkL>E_`5D_}___kFb$UwpCmD6e&C_ZE~09Ap6_; z4%Rp_+`ke3+`WP=lIGWTiGJGhkLb>%Cu}rrcF`>-{kG6W%wfCaQ_}Tz&(Olt{r_^h z`!c9p@3_(>*Tlh11p0^@ZF9nCfOkw}f5tQqkEmd0Z&}ITsQ#O6EUb~k?oFn-;O7qN Q>i>b%m9>>>6yJpXA3sR6Jpcdz literal 0 HcmV?d00001 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..a09546a40 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "4 8 * * 4" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ go ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 000000000..9d9ff726f --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,196 @@ +name: go +on: + pull_request: + push: + +jobs: + build-and-test: + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GO111MODULE: 'on' + CGO_ENABLED: '0' + strategy: + fail-fast: true + matrix: + go: ['1.16', '1.x'] + os: + - ubuntu-latest + - windows-latest + - macos-latest + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + name: ${{ matrix.go }} on ${{ matrix.os }} + steps: + + - uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go }} + + - id: go-cache-paths + run: | + echo "::set-output name=go-build::$(go env GOCACHE)" + echo "::set-output name=go-mod::$(go env GOMODCACHE)" + - run: echo ${{ steps.go-cache-paths.outputs.go-build }} + - run: echo ${{ steps.go-cache-paths.outputs.go-mod }} + + - name: Go Build Cache + uses: actions/cache@v3 + with: + path: ${{ steps.go-cache-paths.outputs.go-build }} + key: ${{ runner.os }}-go-${{ matrix.go }}-build-${{ hashFiles('**/go.sum') }} + + - name: Go Mod Cache (go>=1.15) + uses: actions/cache@v3 + with: + path: ${{ steps.go-cache-paths.outputs.go-mod }} + key: ${{ runner.os }}-go-${{ matrix.go }}-mod-${{ hashFiles('**/go.sum') }} + + - if: runner.os == 'Linux' + run: sudo apt install silversearcher-ag + + - uses: actions/checkout@v2 + + - name: Check codegen + run: | + ./refs.sh | tee openapi3/refs.go + git --no-pager diff --exit-code + + - name: Check docsgen + run: ./docs.sh + + - run: go mod download && go mod tidy && go mod verify + - run: git --no-pager diff --exit-code + + - run: go vet ./... + - run: git --no-pager diff --exit-code + + - run: go fmt ./... + - run: git --no-pager diff --exit-code + + - run: go test ./... + - if: runner.os == 'Linux' + run: go test -count=10 ./... + env: + GOARCH: '386' + - run: go test -count=10 ./... + - run: go test -count=2 -covermode=atomic ./... + - run: go test -v -run TestRaceyPatternSchema -race ./... + env: + CGO_ENABLED: '1' + - run: go test -v -run TestIssue741 -race ./... + env: + CGO_ENABLED: '1' + - run: git --no-pager diff --exit-code + + - if: runner.os == 'Linux' + name: Errors must not be capitalized https://github.com/golang/go/wiki/CodeReviewComments#error-strings + run: | + ! git grep -E '(fmt|errors)[^(]+\(.[A-Z]' + + - if: runner.os == 'Linux' + name: Did you mean %q + run: | + ! git grep -E "'[%].'" + + - if: runner.os == 'Linux' + name: Also add yaml tags + run: | + ! git grep -InE 'json:"' | grep -v _test.go | grep -v yaml: + + - if: runner.os == 'Linux' && matrix.go != '1.16' + name: nilness + run: go run golang.org/x/tools/go/analysis/passes/nilness/cmd/nilness@latest ./... + + - if: runner.os == 'Linux' + name: Check for superfluous trailing whitespace + run: | + ! grep -IErn '\s$' --exclude-dir={.git,target,pgdata} + + - if: runner.os == 'Linux' + name: Ensure use of unmarshal + run: | + [[ "$(git grep -F yaml. -- openapi3/ | grep -v _test.go | wc -l)" = 1 ]] + + - if: runner.os == 'Linux' + name: Ensure non-pointer MarshalJSON + run: | + ! git grep -InE 'func.+[*].+[)].MarshalJSON[(][)]' + + - if: runner.os == 'Linux' + name: Missing specification object link to definition + run: | + [[ 31 -eq $(git grep -InE '^// See https:.+OpenAPI-Specification.+3[.]0[.]3[.]md#.+bject$' openapi3/*.go | grep -v _test.go | grep -v doc.go | wc -l) ]] + + - if: runner.os == 'Linux' + name: Missing validation of unknown fields in extensions + run: | + [[ $(git grep -InF 'return validateExtensions' -- openapi3 | wc -l) -eq $(git grep -InE '^\s+Extensions.+`' -- openapi3 | wc -l) ]] + + - if: runner.os == 'Linux' + name: Style around Extensions embedding + run: | + ! ag -B2 -A2 'type.[A-Z].+struct..\n.+Extensions\n[^\n]' openapi3/*.go + + - if: runner.os == 'Linux' + name: Ensure all exported fields are mentioned in Validate() impls + run: | + for ty in $TYPES; do + # Ensure definition + if ! ag 'type.[A-Z].+struct..\n.+Extensions' openapi3/*.go | grep -F "type $ty struct"; then + echo "OAI type $ty is not defined" && exit 1 + fi + + # Ensure impl Validate() + if ! git grep -InE 'func [(].+'"$ty"'[)] Validate[(]ctx context.Context, opts [.][.][.]ValidationOption[)].+error.+[{]'; then + echo "OAI type $ty does not implement Validate()" && exit 1 + fi + + # TODO: $ty mention all its exported fields within Validate() + done + env: + TYPES: > + Components + Contact + Discriminator + Encoding + Example + ExternalDocs + Info + License + Link + MediaType + OAuthFlow + OAuthFlows + Operation + Parameter + PathItem + RequestBody + Response + Schema + SecurityScheme + Server + ServerVariable + T + Tag + XML + + - if: runner.os == 'Linux' + name: Ensure readableType() covers all possible values of resolved var + run: | + [[ "$(git grep -F 'var resolved ' -- openapi3/loader.go | awk '{print $4}' | sort | tr '\n' ' ')" = "$RESOLVEDS" ]] + env: + RESOLVEDS: 'Callback CallbackRef ExampleRef HeaderRef LinkRef ParameterRef PathItem RequestBodyRef ResponseRef SchemaRef SecuritySchemeRef ' + + check-goimports: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v3 + with: + go-version: '>=1.17.0' + - run: go install github.com/incu6us/goimports-reviser/v2@latest + - run: which goimports-reviser + - run: find . -type f -iname '*.go' ! -iname '*.pb.go' -exec goimports-reviser -file-path {} \; + - run: git --no-pager diff --exit-code diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 000000000..e1f8d1242 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,18 @@ +name: ShellCheck + +on: + push: + pull_request: + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Run shellcheck + uses: ludeeus/action-shellcheck@1.1.0 + with: + check_together: 'yes' + severity: error diff --git a/.gitignore b/.gitignore index a5bf17cb5..40b671156 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ # IntelliJ / GoLand .idea - +.vscode diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5dd1e0251..000000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: go -go: -- 1.11.x -env: - global: - - GO111MODULE: 'on' - - CGO_ENABLED: '0' -after_success: -- go mod tidy && git --no-pager diff && [[ 0 -eq $(git status --porcelain | wc -l) ]] -notifications: - email: - on_success: never diff --git a/README.md b/README.md index fd3db0fbe..b85af9864 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,45 @@ -[![Build Status](https://travis-ci.com/getkin/kin-openapi.svg?branch=master)](https://travis-ci.com/getkin/kin-openapi) +[![CI](https://github.com/getkin/kin-openapi/workflows/go/badge.svg)](https://github.com/getkin/kin-openapi/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/getkin/kin-openapi)](https://goreportcard.com/report/github.com/getkin/kin-openapi) [![GoDoc](https://godoc.org/github.com/getkin/kin-openapi?status.svg)](https://godoc.org/github.com/getkin/kin-openapi) [![Join Gitter Chat Channel -](https://badges.gitter.im/getkin/kin.svg)](https://gitter.im/getkin/kin?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) # Introduction -A [Go](https://golang.org) project for handling [OpenAPI](https://www.openapis.org/) files. We target the latest OpenAPI version (currently 3), but the project contains support for older OpenAPI versions too. +A [Go](https://golang.org) project for handling [OpenAPI](https://www.openapis.org/) files. We target: +* [OpenAPI `v2.0`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md) (formerly known as Swagger) +* [OpenAPI `v3.0`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md) +* [OpenAPI `v3.1`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md) Soon! [Tracking issue here.](https://github.com/getkin/kin-openapi/issues/230) -Licensed under the [MIT License](LICENSE). +Licensed under the [MIT License](./LICENSE). -## Contributors and users -The project has received pull requests from many people. Thanks to everyone! +## Contributors, users and sponsors +The project has received pull requests [from many people](https://github.com/getkin/kin-openapi/graphs/contributors). Thanks to everyone! + +Be sure to [give back to this project](https://github.com/sponsors/fenollp) like our sponsors: + +

+ Speakeasy +

Here's some projects that depend on _kin-openapi_: - * [github.com/getkin/kin](https://github.com/getkin/kin) - "A configurable backend" + * [github.com/Tufin/oasdiff](https://github.com/Tufin/oasdiff) - "A diff tool for OpenAPI Specification 3" * [github.com/danielgtaylor/apisprout](https://github.com/danielgtaylor/apisprout) - "Lightweight, blazing fast, cross-platform OpenAPI 3 mock server with validation" - * [github.com/deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen) - Generate Go server boilerplate from an OpenAPI 3 spec + * [github.com/deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen) - "Generate Go client and server boilerplate from OpenAPI 3 specifications" * [github.com/dunglas/vulcain](https://github.com/dunglas/vulcain) - "Use HTTP/2 Server Push to create fast and idiomatic client-driven REST APIs" + * [github.com/danielgtaylor/restish](https://github.com/danielgtaylor/restish) - "...a CLI for interacting with REST-ish HTTP APIs with some nice features built-in" + * [github.com/goadesign/goa](https://github.com/goadesign/goa) - "Design-based APIs and microservices in Go" + * [github.com/hashicorp/nomad-openapi](https://github.com/hashicorp/nomad-openapi) - "Nomad is an easy-to-use, flexible, and performant workload orchestrator that can deploy a mix of microservice, batch, containerized, and non-containerized applications. Nomad is easy to operate and scale and has native Consul and Vault integrations." + * [gitlab.com/jamietanna/httptest-openapi](https://gitlab.com/jamietanna/httptest-openapi) ([*blog post*](https://www.jvt.me/posts/2022/05/22/go-openapi-contract-test/)) - "Go OpenAPI Contract Verification for use with `net/http`" + * [github.com/SIMITGROUP/openapigenerator](https://github.com/SIMITGROUP/openapigenerator) - "Openapi v3 microservices generator" * (Feel free to add your project by [creating an issue](https://github.com/getkin/kin-openapi/issues/new) or a pull request) -## Alternative projects - * [go-openapi](https://github.com/go-openapi) - * Supports OpenAPI version 2. - * See [this list](https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/IMPLEMENTATIONS.md). +## Alternatives +* [go-swagger](https://github.com/go-swagger/go-swagger) stated [*OpenAPIv3 won't be supported*](https://github.com/go-swagger/go-swagger/issues/1122#issuecomment-575968499) +* [swaggo](https://github.com/swaggo/swag) has an [open issue on OpenAPIv3](https://github.com/swaggo/swag/issues/386) +* [go-openapi](https://github.com/go-openapi)'s [spec3](https://github.com/go-openapi/spec3) + * an iteration on [spec](https://github.com/go-openapi/spec) (for OpenAPIv2) + * see [README](https://github.com/go-openapi/spec3/tree/3fab9faa9094e06ebd19ded7ea96d156c2283dca#oai-object-model---) for the missing parts + +Be sure to check [OpenAPI Initiative](https://github.com/OAI)'s [great tooling list](https://github.com/OAI/OpenAPI-Specification/blob/master/IMPLEMENTATIONS.md) as well as [OpenAPI.Tools](https://openapi.tools/). # Structure * _openapi2_ ([godoc](https://godoc.org/github.com/getkin/kin-openapi/openapi2)) @@ -32,33 +50,30 @@ Here's some projects that depend on _kin-openapi_: * Support for OpenAPI 3 files, including serialization, deserialization, and validation. * _openapi3filter_ ([godoc](https://godoc.org/github.com/getkin/kin-openapi/openapi3filter)) * Validates HTTP requests and responses + * Provides a [gorilla/mux](https://github.com/gorilla/mux) router for OpenAPI operations * _openapi3gen_ ([godoc](https://godoc.org/github.com/getkin/kin-openapi/openapi3gen)) * Generates `*openapi3.Schema` values for Go types. - * _pathpattern_ ([godoc](https://godoc.org/github.com/getkin/kin-openapi/pathpattern)) - * Matches strings with OpenAPI path patterns ("/path/{parameter}") # Some recipes +## Validating an OpenAPI document +```shell +go run github.com/getkin/kin-openapi/cmd/validate@latest [--defaults] [--examples] [--ext] [--patterns] -- +``` + ## Loading OpenAPI document -Use `SwaggerLoader`, which resolves all JSON references: +Use `openapi3.Loader`, which resolves all references: ```go -swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile("swagger.json") +doc, err := openapi3.NewLoader().LoadFromFile("swagger.json") ``` ## Getting OpenAPI operation that matches request ```go -func GetOperation(httpRequest *http.Request) (*openapi3.Operation, error) { - // Load Swagger file - router := openapi3filter.NewRouter().WithSwaggerFromFile("swagger.json") - - // Find route - route, _, err := router.FindRoute("GET", req.URL) - if err != nil { - return nil, err - } - - // Get OpenAPI 3 operation - return route.Operation -} +loader := openapi3.NewLoader() +doc, _ := loader.LoadFromData([]byte(`...`)) +_ := doc.Validate(loader.Context) +router, _ := gorillamux.NewRouter(doc) +route, pathParams, _ := router.FindRoute(httpRequest) +// Do something with route.Operation ``` ## Validating HTTP requests/responses @@ -66,22 +81,26 @@ func GetOperation(httpRequest *http.Request) (*openapi3.Operation, error) { package main import ( - "bytes" "context" - "encoding/json" - "log" + "fmt" "net/http" + "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" ) func main() { - router := openapi3filter.NewRouter().WithSwaggerFromFile("swagger.json") - ctx := context.TODO() + ctx := context.Background() + loader := &openapi3.Loader{Context: ctx, IsExternalRefsAllowed: true} + doc, _ := loader.LoadFromFile(".../My-OpenAPIv3-API.yml") + // Validate document + _ := doc.Validate(ctx) + router, _ := gorillamux.NewRouter(doc) httpReq, _ := http.NewRequest(http.MethodGet, "/items", nil) // Find route - route, pathParams, _ := router.FindRoute(httpReq.Method, httpReq.URL) + route, pathParams, _ := router.FindRoute(httpReq) // Validate request requestValidationInput := &openapi3filter.RequestValidationInput{ @@ -89,35 +108,22 @@ func main() { PathParams: pathParams, Route: route, } - if err := openapi3filter.ValidateRequest(ctx, requestValidationInput); err != nil { - panic(err) - } + _ := openapi3filter.ValidateRequest(ctx, requestValidationInput) - var ( - respStatus = 200 - respContentType = "application/json" - respBody = bytes.NewBufferString(`{}`) - ) + // Handle that request + // --> YOUR CODE GOES HERE <-- + responseHeaders := http.Header{"Content-Type": []string{"application/json"}} + responseCode := 200 + responseBody := []byte(`{}`) - log.Println("Response:", respStatus) + // Validate response responseValidationInput := &openapi3filter.ResponseValidationInput{ RequestValidationInput: requestValidationInput, - Status: respStatus, - Header: http.Header{ - "Content-Type": []string{ - respContentType, - }, - }, - } - if respBody != nil { - data, _ := json.Marshal(respBody) - responseValidationInput.SetBodyBytes(data) - } - - // Validate response. - if err := openapi3filter.ValidateResponse(ctx, responseValidationInput); err != nil { - panic(err) + Status: responseCode, + Header: responseHeaders, } + responseValidationInput.SetBodyBytes(responseBody) + _ := openapi3filter.ValidateResponse(ctx, responseValidationInput) } ``` @@ -152,12 +158,12 @@ func main() { } } -func xmlBodyDecoder(body []byte) (interface{}, error) { +func xmlBodyDecoder(body io.Reader, h http.Header, schema *openapi3.SchemaRef, encFn openapi3filter.EncodingFn) (decoded interface{}, err error) { // Decode body to a primitive, []inteface{}, or map[string]interface{}. } ``` -## Custom function for check uniqueness of JSON array +## Custom function to check uniqueness of array items By defaut, the library check unique items by below predefined function @@ -166,8 +172,6 @@ func isSliceOfUniqueItems(xs []interface{}) bool { s := len(xs) m := make(map[string]struct{}, s) for _, x := range xs { - // The input slice is coverted from a JSON string, there shall - // have no error when covert it back. key, _ := json.Marshal(&x) m[string(key)] = struct{}{} } @@ -177,7 +181,7 @@ func isSliceOfUniqueItems(xs []interface{}) bool { In the predefined function using `json.Marshal` to generate a string can be used as a map key which is to support check the uniqueness of an array -when the array items are JSON objects or JSON arraies. You can register +when the array items are objects or arrays. You can register you own function according to your input data to get better performance: ```go @@ -191,6 +195,124 @@ func main() { } func arrayUniqueItemsChecker(items []interface{}) bool { - // Check the uniqueness of the input slice(array in JSON) + // Check the uniqueness of the input slice } ``` + +## Custom function to change schema error messages + +By default, the error message returned when validating a value includes the error reason, the schema, and the input value. + +For example, given the following schema: + +```json +{ + "type": "string", + "allOf": [ + { "pattern": "[A-Z]" }, + { "pattern": "[a-z]" }, + { "pattern": "[0-9]" }, + { "pattern": "[!@#$%^&*()_+=-?~]" } + ] +} +``` + +Passing the input value `"secret"` to this schema will produce the following error message: + +``` +string doesn't match the regular expression "[A-Z]" +Schema: + { + "pattern": "[A-Z]" + } + +Value: + "secret" +``` + +Including the original value in the error message can be helpful for debugging, but it may not be appropriate for sensitive information such as secrets. + +To disable the extra details in the schema error message, you can set the `openapi3.SchemaErrorDetailsDisabled` option to `true`: + +```go +func main() { + // ... + + // Disable schema error detailed error messages + openapi3.SchemaErrorDetailsDisabled = true + + // ... other validate codes +} +``` + +This will shorten the error message to present only the reason: + +``` +string doesn't match the regular expression "[A-Z]" +``` + +For more fine-grained control over the error message, you can pass a custom `openapi3filter.Options` object to `openapi3filter.RequestValidationInput` that includes a `openapi3filter.CustomSchemaErrorFunc`. + +```go +func validationOptions() *openapi3filter.Options { + options := openapi3filter.DefaultOptions + options.WithCustomSchemaErrorFunc(safeErrorMessage) + return options +} + +func safeErrorMessage(err *openapi3.SchemaError) string { + return err.Reason +} +``` + +This will change the schema validation errors to return only the `Reason` field, which is guaranteed to not include the original value. + +## Sub-v0 breaking API changes + +### v0.113.0 +* The string format `email` has been removed by default. To use it please call `openapi3.DefineStringFormat("email", openapi3.FormatOfStringForEmail)`. +* Field `openapi3.T.Components` is now a pointer. +* Fields `openapi3.Schema.AdditionalProperties` and `openapi3.Schema.AdditionalPropertiesAllowed` are replaced by `openapi3.Schema.AdditionalProperties.Schema` and `openapi3.Schema.AdditionalProperties.Has` respectively. +* Type `openapi3.ExtensionProps` is now just `map[string]interface{}` and extensions are accessible through the `Extensions` field. + +### v0.112.0 +* `(openapi3.ValidationOptions).ExamplesValidationDisabled` has been unexported. +* `(openapi3.ValidationOptions).SchemaFormatValidationEnabled` has been unexported. +* `(openapi3.ValidationOptions).SchemaPatternValidationDisabled` has been unexported. + +### v0.111.0 +* Changed `func (*_) Validate(ctx context.Context) error` to `func (*_) Validate(ctx context.Context, opts ...ValidationOption) error`. +* `openapi3.WithValidationOptions(ctx context.Context, opts *ValidationOptions) context.Context` prototype changed to `openapi3.WithValidationOptions(ctx context.Context, opts ...ValidationOption) context.Context`. + +### v0.101.0 +* `openapi3.SchemaFormatValidationDisabled` has been removed in favour of an option `openapi3.EnableSchemaFormatValidation()` passed to `openapi3.T.Validate`. The default behaviour is also now to not validate formats, as the OpenAPI spec mentions the `format` is an open value. + +### v0.84.0 +* The prototype of `openapi3gen.NewSchemaRefForValue` changed: + * It no longer returns a map but that is still accessible under the field `(*Generator).SchemaRefs`. + * It now takes in an additional argument (basically `doc.Components.Schemas`) which gets written to so `$ref` cycles can be properly handled. + +### v0.61.0 +* Renamed `openapi2.Swagger` to `openapi2.T`. +* Renamed `openapi2conv.FromV3Swagger` to `openapi2conv.FromV3`. +* Renamed `openapi2conv.ToV3Swagger` to `openapi2conv.ToV3`. +* Renamed `openapi3.LoadSwaggerFromData` to `openapi3.LoadFromData`. +* Renamed `openapi3.LoadSwaggerFromDataWithPath` to `openapi3.LoadFromDataWithPath`. +* Renamed `openapi3.LoadSwaggerFromFile` to `openapi3.LoadFromFile`. +* Renamed `openapi3.LoadSwaggerFromURI` to `openapi3.LoadFromURI`. +* Renamed `openapi3.NewSwaggerLoader` to `openapi3.NewLoader`. +* Renamed `openapi3.Swagger` to `openapi3.T`. +* Renamed `openapi3.SwaggerLoader` to `openapi3.Loader`. +* Renamed `openapi3filter.ValidationHandler.SwaggerFile` to `openapi3filter.ValidationHandler.File`. +* Renamed `routers.Route.Swagger` to `routers.Route.Spec`. + +### v0.51.0 +* Type `openapi3filter.Route` moved to `routers` (and `Route.Handler` was dropped. See https://github.com/getkin/kin-openapi/issues/329) +* Type `openapi3filter.RouteError` moved to `routers` (so did `ErrPathNotFound` and `ErrMethodNotAllowed` which are now `RouteError`s) +* Routers' `FindRoute(...)` method now takes only one argument: `*http.Request` +* `getkin/kin-openapi/openapi3filter.Router` moved to `getkin/kin-openapi/routers/legacy` +* `openapi3filter.NewRouter()` and its related `WithSwaggerFromFile(string)`, `WithSwagger(*openapi3.Swagger)`, `AddSwaggerFromFile(string)` and `AddSwagger(*openapi3.Swagger)` are all replaced with a single `.NewRouter(*openapi3.Swagger)` + * NOTE: the `NewRouter(doc)` call now requires that the user ensures `doc` is valid (`doc.Validate() != nil`). This used to be asserted. + +### v0.47.0 +Field `(*openapi3.SwaggerLoader).LoadSwaggerFromURIFunc` of type `func(*openapi3.SwaggerLoader, *url.URL) (*openapi3.Swagger, error)` was removed after the addition of the field `(*openapi3.SwaggerLoader).ReadFromURIFunc` of type `func(*openapi3.SwaggerLoader, *url.URL) ([]byte, error)`. diff --git a/cmd/validate/main.go b/cmd/validate/main.go new file mode 100644 index 000000000..d8c0fe6ad --- /dev/null +++ b/cmd/validate/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "flag" + "log" + "os" + "strings" + + "github.com/invopop/yaml" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi3" +) + +var ( + defaultDefaults = true + defaults = flag.Bool("defaults", defaultDefaults, "when false, disables schemas' default field validation") +) + +var ( + defaultExamples = true + examples = flag.Bool("examples", defaultExamples, "when false, disables all example schema validation") +) + +var ( + defaultExt = false + ext = flag.Bool("ext", defaultExt, "enables visiting other files") +) + +var ( + defaultPatterns = true + patterns = flag.Bool("patterns", defaultPatterns, "when false, allows schema patterns unsupported by the Go regexp engine") +) + +func main() { + flag.Parse() + filename := flag.Arg(0) + if len(flag.Args()) != 1 || filename == "" { + log.Fatalf("Usage: go run github.com/getkin/kin-openapi/cmd/validate@latest [--defaults] [--examples] [--ext] [--patterns] -- \nGot: %+v\n", os.Args) + } + + data, err := os.ReadFile(filename) + if err != nil { + log.Fatal(err) + } + + var vd struct { + OpenAPI string `json:"openapi" yaml:"openapi"` + Swagger string `json:"swagger" yaml:"swagger"` + } + if err := yaml.Unmarshal(data, &vd); err != nil { + log.Fatal(err) + } + + switch { + case vd.OpenAPI == "3" || strings.HasPrefix(vd.OpenAPI, "3."): + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = *ext + + doc, err := loader.LoadFromFile(filename) + if err != nil { + log.Fatalln("Loading error:", err) + } + + var opts []openapi3.ValidationOption + if !*defaults { + opts = append(opts, openapi3.DisableSchemaDefaultsValidation()) + } + if !*examples { + opts = append(opts, openapi3.DisableExamplesValidation()) + } + if !*patterns { + opts = append(opts, openapi3.DisableSchemaPatternValidation()) + } + + if err = doc.Validate(loader.Context, opts...); err != nil { + log.Fatalln("Validation error:", err) + } + + case vd.Swagger == "2" || strings.HasPrefix(vd.Swagger, "2."): + if *defaults != defaultDefaults { + log.Fatal("Flag --defaults is only for OpenAPIv3") + } + if *examples != defaultExamples { + log.Fatal("Flag --examples is only for OpenAPIv3") + } + if *ext != defaultExt { + log.Fatal("Flag --ext is only for OpenAPIv3") + } + if *patterns != defaultPatterns { + log.Fatal("Flag --patterns is only for OpenAPIv3") + } + + var doc openapi2.T + if err := yaml.Unmarshal(data, &doc); err != nil { + log.Fatalln("Loading error:", err) + } + + default: + log.Fatal("Missing or incorrect 'openapi' or 'swagger' field") + } +} diff --git a/docs.sh b/docs.sh new file mode 100755 index 000000000..5485feb2f --- /dev/null +++ b/docs.sh @@ -0,0 +1,25 @@ +#!/bin/bash -eux +set -o pipefail + +outdir=.github/docs +mkdir -p "$outdir" +for pkgpath in $(git ls-files | grep / | while read -r path; do dirname "$path"; done | sort -u | grep -vE '[.]git|testdata|cmd/'); do + go doc -short "./$pkgpath" | tee "$outdir/${pkgpath////_}.txt" +done + +git --no-pager diff -- .github/docs/ + +count_missing_mentions() { + local errors=0 + for thing in $(git --no-pager diff -- .github/docs/ \ + | grep -vE '[-]{3}' \ + | grep -Eo '^-[^ ]+ ([^ (]+)[ (]' \ + | sed 's%(% %' \ + | cut -d' ' -f2); do + if ! grep -A999999 '## Sub-v0 breaking API changes' README.md | grep -F "$thing"; then + ((errors++)) || true + fi + done + return $errors +} +count_missing_mentions diff --git a/go.mod b/go.mod index 230138307..12a2f1af7 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,14 @@ module github.com/getkin/kin-openapi -go 1.14 +go 1.16 require ( - github.com/ghodss/yaml v1.0.0 - github.com/stretchr/testify v1.5.1 - gopkg.in/yaml.v2 v2.2.8 // indirect + github.com/go-openapi/jsonpointer v0.19.5 + github.com/gorilla/mux v1.8.0 + github.com/invopop/yaml v0.1.0 + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 + github.com/perimeterx/marshmallow v1.1.4 + github.com/stretchr/testify v1.8.1 + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 22c1b575c..4d05787e4 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,52 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= +github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw= +github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jsoninfo/doc.go b/jsoninfo/doc.go deleted file mode 100644 index e59ec2c34..000000000 --- a/jsoninfo/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package jsoninfo provides information and functions for marshalling/unmarshalling JSON. -package jsoninfo diff --git a/jsoninfo/marshal.go b/jsoninfo/marshal.go deleted file mode 100644 index 93de99a56..000000000 --- a/jsoninfo/marshal.go +++ /dev/null @@ -1,162 +0,0 @@ -package jsoninfo - -import ( - "encoding/json" - "fmt" - "reflect" -) - -// MarshalStrictStruct function: -// * Marshals struct fields, ignoring MarshalJSON() and fields without 'json' tag. -// * Correctly handles StrictStruct semantics. -func MarshalStrictStruct(value StrictStruct) ([]byte, error) { - encoder := NewObjectEncoder() - if err := value.EncodeWith(encoder, value); err != nil { - return nil, err - } - return encoder.Bytes() -} - -type ObjectEncoder struct { - result map[string]json.RawMessage -} - -func NewObjectEncoder() *ObjectEncoder { - return &ObjectEncoder{ - result: make(map[string]json.RawMessage, 8), - } -} - -// Bytes returns the result of encoding. -func (encoder *ObjectEncoder) Bytes() ([]byte, error) { - return json.Marshal(encoder.result) -} - -// EncodeExtension adds a key/value to the current JSON object. -func (encoder *ObjectEncoder) EncodeExtension(key string, value interface{}) error { - data, err := json.Marshal(value) - if err != nil { - return err - } - encoder.result[key] = data - return nil -} - -// EncodeExtensionMap adds all properties to the result. -func (encoder *ObjectEncoder) EncodeExtensionMap(value map[string]json.RawMessage) error { - if value != nil { - result := encoder.result - for k, v := range value { - result[k] = v - } - } - return nil -} - -func (encoder *ObjectEncoder) EncodeStructFieldsAndExtensions(value interface{}) error { - reflection := reflect.ValueOf(value) - - // Follow "encoding/json" semantics - if reflection.Kind() != reflect.Ptr { - // Panic because this is a clear programming error - panic(fmt.Errorf("Value %s is not a pointer", reflection.Type().String())) - } - if reflection.IsNil() { - // Panic because this is a clear programming error - panic(fmt.Errorf("Value %s is nil", reflection.Type().String())) - } - - // Take the element - reflection = reflection.Elem() - - // Obtain typeInfo - typeInfo := GetTypeInfo(reflection.Type()) - - // Declare result - result := encoder.result - - // Supported fields -iteration: - for _, field := range typeInfo.Fields { - // Fields without JSON tag are ignored - if !field.HasJSONTag { - continue - } - - // Marshal - fieldValue := reflection.FieldByIndex(field.Index) - if v, ok := fieldValue.Interface().(json.Marshaler); ok { - if fieldValue.Kind() == reflect.Ptr && fieldValue.IsNil() { - if field.JSONOmitEmpty { - continue iteration - } - result[field.JSONName] = []byte("null") - continue - } - fieldData, err := v.MarshalJSON() - if err != nil { - return err - } - result[field.JSONName] = fieldData - continue - } - switch fieldValue.Kind() { - case reflect.Ptr, reflect.Interface: - if fieldValue.IsNil() { - if field.JSONOmitEmpty { - continue iteration - } - result[field.JSONName] = []byte("null") - continue - } - case reflect.Struct: - case reflect.Map: - if field.JSONOmitEmpty && (fieldValue.IsNil() || fieldValue.Len() == 0) { - continue iteration - } - case reflect.Slice: - if field.JSONOmitEmpty && fieldValue.Len() == 0 { - continue iteration - } - case reflect.Bool: - x := fieldValue.Bool() - if field.JSONOmitEmpty && !x { - continue iteration - } - s := "false" - if x { - s = "true" - } - result[field.JSONName] = []byte(s) - continue iteration - case reflect.Int64, reflect.Int, reflect.Int32: - if field.JSONOmitEmpty && fieldValue.Int() == 0 { - continue iteration - } - case reflect.Uint64, reflect.Uint, reflect.Uint32: - if field.JSONOmitEmpty && fieldValue.Uint() == 0 { - continue iteration - } - case reflect.Float64: - if field.JSONOmitEmpty && fieldValue.Float() == 0.0 { - continue iteration - } - case reflect.String: - if field.JSONOmitEmpty && len(fieldValue.String()) == 0 { - continue iteration - } - default: - panic(fmt.Errorf("Field '%s' has unsupported type %s", field.JSONName, field.Type.String())) - } - - // No special treament is needed - // Use plain old "encoding/json".Marshal - fieldData, err := json.Marshal(fieldValue.Addr().Interface()) - if err != nil { - return err - } - result[field.JSONName] = fieldData - } - - return nil -} diff --git a/jsoninfo/marshal_ref.go b/jsoninfo/marshal_ref.go deleted file mode 100644 index 9738bf08f..000000000 --- a/jsoninfo/marshal_ref.go +++ /dev/null @@ -1,30 +0,0 @@ -package jsoninfo - -import ( - "encoding/json" -) - -func MarshalRef(value string, otherwise interface{}) ([]byte, error) { - if len(value) > 0 { - return json.Marshal(&refProps{ - Ref: value, - }) - } - return json.Marshal(otherwise) -} - -func UnmarshalRef(data []byte, destRef *string, destOtherwise interface{}) error { - refProps := &refProps{} - if err := json.Unmarshal(data, refProps); err == nil { - ref := refProps.Ref - if len(ref) > 0 { - *destRef = ref - return nil - } - } - return json.Unmarshal(data, destOtherwise) -} - -type refProps struct { - Ref string `json:"$ref,omitempty"` -} diff --git a/jsoninfo/marshal_test.go b/jsoninfo/marshal_test.go deleted file mode 100644 index 05a6ac31b..000000000 --- a/jsoninfo/marshal_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package jsoninfo_test - -import ( - "encoding/json" - "testing" - "time" - - "github.com/getkin/kin-openapi/jsoninfo" - "github.com/getkin/kin-openapi/openapi3" -) - -type Simple struct { - openapi3.ExtensionProps - Bool bool `json:"bool"` - Int int `json:"int"` - Int64 int64 `json:"int64"` - Float64 float64 `json:"float64"` - Time time.Time `json:"time"` - String string `json:"string"` - Bytes []byte `json:"bytes"` -} - -type SimpleOmitEmpty struct { - openapi3.ExtensionProps - Bool bool `json:"bool,omitempty"` - Int int `json:"int,omitempty"` - Int64 int64 `json:"int64,omitempty"` - Float64 float64 `json:"float64,omitempty"` - Time time.Time `json:"time,omitempty"` - String string `json:"string,omitempty"` - Bytes []byte `json:"bytes,omitempty"` -} - -type SimplePtrOmitEmpty struct { - openapi3.ExtensionProps - Bool *bool `json:"bool,omitempty"` - Int *int `json:"int,omitempty"` - Int64 *int64 `json:"int64,omitempty"` - Float64 *float64 `json:"float64,omitempty"` - Time *time.Time `json:"time,omitempty"` - String *string `json:"string,omitempty"` - Bytes *[]byte `json:"bytes,omitempty"` -} - -type OriginalNameType struct { - openapi3.ExtensionProps - Field string `json:",omitempty"` -} - -type RootType struct { - openapi3.ExtensionProps - EmbeddedType0 - EmbeddedType1 -} - -type EmbeddedType0 struct { - openapi3.ExtensionProps - Field0 string `json:"embedded0,omitempty"` -} - -type EmbeddedType1 struct { - openapi3.ExtensionProps - Field1 string `json:"embedded1,omitempty"` -} - -// Example describes expected outcome of: -// 1.Marshal JSON -// 2.Unmarshal value -// 3.Marshal value -type Example struct { - NoMarshal bool - NoUnmarshal bool - Value jsoninfo.StrictStruct - JSON interface{} -} - -var Examples = []Example{ - // Primitives - { - Value: &SimpleOmitEmpty{}, - JSON: Object{ - "time": time.Unix(0, 0), - }, - }, - { - Value: &SimpleOmitEmpty{}, - JSON: Object{ - "bool": true, - "int": 42, - "int64": 42, - "float64": 3.14, - "string": "abc", - "bytes": []byte{1, 2, 3}, - "time": time.Unix(1, 0), - }, - }, - - // Pointers - { - Value: &SimplePtrOmitEmpty{}, - JSON: Object{}, - }, - { - Value: &SimplePtrOmitEmpty{}, - JSON: Object{ - "bool": true, - "int": 42, - "int64": 42, - "float64": 3.14, - "string": "abc", - "bytes": []byte{1, 2, 3}, - "time": time.Unix(1, 0), - }, - }, - - // JSON tag "fieldName" - { - Value: &Simple{}, - JSON: Object{ - "bool": false, - "int": 0, - "int64": 0, - "float64": 0, - "string": "", - "bytes": []byte{}, - "time": time.Unix(0, 0), - }, - }, - - // JSON tag ",omitempty" - { - Value: &OriginalNameType{}, - JSON: Object{ - "Field": "abc", - }, - }, - - // Embedding - { - Value: &RootType{}, - JSON: Object{}, - }, - { - Value: &RootType{}, - JSON: Object{ - "embedded0": "0", - "embedded1": "1", - "x-other": "abc", - }, - }, -} - -type Object map[string]interface{} - -func TestExtensions(t *testing.T) { - for _, example := range Examples { - // Define JSON that will be unmarshalled - expectedData, err := json.Marshal(example.JSON) - if err != nil { - panic(err) - } - expected := string(expectedData) - - // Define value that will marshalled - x := example.Value - - // Unmarshal - if !example.NoUnmarshal { - t.Logf("Unmarshalling %T", x) - if err := jsoninfo.UnmarshalStrictStruct(expectedData, x); err != nil { - t.Fatalf("Error unmarshalling %T: %v", x, err) - } - t.Logf("Marshalling %T", x) - } - - // Marshal - if !example.NoMarshal { - data, err := jsoninfo.MarshalStrictStruct(x) - if err != nil { - t.Fatalf("Error marshalling: %v", err) - } - actually := string(data) - - if actually != expected { - t.Fatalf("Error!\nExpected: %s\nActually: %s", expected, actually) - } - } - } -} diff --git a/jsoninfo/strict_struct.go b/jsoninfo/strict_struct.go deleted file mode 100644 index 6b4d83977..000000000 --- a/jsoninfo/strict_struct.go +++ /dev/null @@ -1,6 +0,0 @@ -package jsoninfo - -type StrictStruct interface { - EncodeWith(encoder *ObjectEncoder, value interface{}) error - DecodeWith(decoder *ObjectDecoder, value interface{}) error -} diff --git a/jsoninfo/type_info.go b/jsoninfo/type_info.go deleted file mode 100644 index 3dbb8d5d6..000000000 --- a/jsoninfo/type_info.go +++ /dev/null @@ -1,68 +0,0 @@ -package jsoninfo - -import ( - "reflect" - "sort" - "sync" -) - -var ( - typeInfos = map[reflect.Type]*TypeInfo{} - typeInfosMutex sync.RWMutex -) - -// TypeInfo contains information about JSON serialization of a type -type TypeInfo struct { - Type reflect.Type - Fields []FieldInfo -} - -func GetTypeInfoForValue(value interface{}) *TypeInfo { - return GetTypeInfo(reflect.TypeOf(value)) -} - -// GetTypeInfo returns TypeInfo for the given type. -func GetTypeInfo(t reflect.Type) *TypeInfo { - for t.Kind() == reflect.Ptr { - t = t.Elem() - } - typeInfosMutex.RLock() - typeInfo, exists := typeInfos[t] - typeInfosMutex.RUnlock() - if exists { - return typeInfo - } - if t.Kind() != reflect.Struct { - typeInfo = &TypeInfo{ - Type: t, - } - } else { - // Allocate - typeInfo = &TypeInfo{ - Type: t, - Fields: make([]FieldInfo, 0, 16), - } - - // Add fields - typeInfo.Fields = AppendFields(nil, nil, t) - - // Sort fields - sort.Sort(sortableFieldInfos(typeInfo.Fields)) - } - - // Publish - typeInfosMutex.Lock() - typeInfos[t] = typeInfo - typeInfosMutex.Unlock() - return typeInfo -} - -// FieldNames returns all field names -func (typeInfo *TypeInfo) FieldNames() []string { - fields := typeInfo.Fields - names := make([]string, 0, len(fields)) - for _, field := range fields { - names = append(names, field.JSONName) - } - return names -} diff --git a/jsoninfo/unmarshal.go b/jsoninfo/unmarshal.go deleted file mode 100644 index 329718758..000000000 --- a/jsoninfo/unmarshal.go +++ /dev/null @@ -1,121 +0,0 @@ -package jsoninfo - -import ( - "encoding/json" - "fmt" - "reflect" -) - -// UnmarshalStrictStruct function: -// * Unmarshals struct fields, ignoring UnmarshalJSON(...) and fields without 'json' tag. -// * Correctly handles StrictStruct -func UnmarshalStrictStruct(data []byte, value StrictStruct) error { - decoder, err := NewObjectDecoder(data) - if err != nil { - return err - } - return value.DecodeWith(decoder, value) -} - -type ObjectDecoder struct { - Data []byte - remainingFields map[string]json.RawMessage -} - -func NewObjectDecoder(data []byte) (*ObjectDecoder, error) { - var remainingFields map[string]json.RawMessage - if err := json.Unmarshal(data, &remainingFields); err != nil { - return nil, fmt.Errorf("Failed to unmarshal extension properties: %v\nInput: %s", err, data) - } - return &ObjectDecoder{ - Data: data, - remainingFields: remainingFields, - }, nil -} - -// DecodeExtensionMap returns all properties that were not decoded previously. -func (decoder *ObjectDecoder) DecodeExtensionMap() map[string]json.RawMessage { - return decoder.remainingFields -} - -func (decoder *ObjectDecoder) DecodeStructFieldsAndExtensions(value interface{}) error { - reflection := reflect.ValueOf(value) - if reflection.Kind() != reflect.Ptr { - panic(fmt.Errorf("Value %T is not a pointer", value)) - } - if reflection.IsNil() { - panic(fmt.Errorf("Value %T is nil", value)) - } - reflection = reflection.Elem() - for (reflection.Kind() == reflect.Interface || reflection.Kind() == reflect.Ptr) && !reflection.IsNil() { - reflection = reflection.Elem() - } - reflectionType := reflection.Type() - if reflectionType.Kind() != reflect.Struct { - panic(fmt.Errorf("Value %T is not a struct", value)) - } - typeInfo := GetTypeInfo(reflectionType) - - // Supported fields - fields := typeInfo.Fields - remainingFields := decoder.remainingFields - for fieldIndex, field := range fields { - // Fields without JSON tag are ignored - if !field.HasJSONTag { - continue - } - - // Get data - fieldData, exists := remainingFields[field.JSONName] - if !exists { - continue - } - - // Unmarshal - if field.TypeIsUnmarshaller { - fieldType := field.Type - isPtr := false - if fieldType.Kind() == reflect.Ptr { - fieldType = fieldType.Elem() - isPtr = true - } - fieldValue := reflect.New(fieldType) - if err := fieldValue.Interface().(json.Unmarshaler).UnmarshalJSON(fieldData); err != nil { - if field.MultipleFields { - i := fieldIndex + 1 - if i < len(fields) && fields[i].JSONName == field.JSONName { - continue - } - } - return fmt.Errorf("Error while unmarshalling property '%s' (%s): %v", - field.JSONName, fieldValue.Type().String(), err) - } - if !isPtr { - fieldValue = fieldValue.Elem() - } - reflection.FieldByIndex(field.Index).Set(fieldValue) - - // Remove the field from remaining fields - delete(remainingFields, field.JSONName) - } else { - fieldPtr := reflection.FieldByIndex(field.Index) - if fieldPtr.Kind() != reflect.Ptr || fieldPtr.IsNil() { - fieldPtr = fieldPtr.Addr() - } - if err := json.Unmarshal(fieldData, fieldPtr.Interface()); err != nil { - if field.MultipleFields { - i := fieldIndex + 1 - if i < len(fields) && fields[i].JSONName == field.JSONName { - continue - } - } - return fmt.Errorf("Error while unmarshalling property '%s' (%s): %v", - field.JSONName, fieldPtr.Type().String(), err) - } - - // Remove the field from remaining fields - delete(remainingFields, field.JSONName) - } - } - return nil -} diff --git a/jsoninfo/unmarshal_test.go b/jsoninfo/unmarshal_test.go deleted file mode 100644 index ce448a5fb..000000000 --- a/jsoninfo/unmarshal_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package jsoninfo_test - -import ( - "errors" - "testing" - - "github.com/getkin/kin-openapi/jsoninfo" - "github.com/stretchr/testify/assert" -) - -func TestNewObjectDecoder(t *testing.T) { - data := []byte(` - { - "field1": 1, - "field2": 2 - } -`) - t.Run("test new object decoder", func(t *testing.T) { - decoder, err := jsoninfo.NewObjectDecoder(data) - assert.Nil(t, err) - assert.NotNil(t, decoder) - assert.Equal(t, data, decoder.Data) - assert.Equal(t, 2, len(decoder.DecodeExtensionMap())) - }) -} - -type mockStrictStruct struct { - EncodeWithFn func(encoder *jsoninfo.ObjectEncoder, value interface{}) error - DecodeWithFn func(decoder *jsoninfo.ObjectDecoder, value interface{}) error -} - -func (m *mockStrictStruct) EncodeWith(encoder *jsoninfo.ObjectEncoder, value interface{}) error { - return m.EncodeWithFn(encoder, value) -} - -func (m *mockStrictStruct) DecodeWith(decoder *jsoninfo.ObjectDecoder, value interface{}) error { - return m.DecodeWithFn(decoder, value) -} - -func TestUnmarshalStrictStruct(t *testing.T) { - data := []byte(` - { - "field1": 1, - "field2": 2 - } - `) - - t.Run("test unmarshal with StrictStruct without err", func(t *testing.T) { - decodeWithFnCalled := 0 - mockStruct := &mockStrictStruct{ - EncodeWithFn: func(encoder *jsoninfo.ObjectEncoder, value interface{}) error { - return nil - }, - DecodeWithFn: func(decoder *jsoninfo.ObjectDecoder, value interface{}) error { - decodeWithFnCalled++ - return nil - }, - } - err := jsoninfo.UnmarshalStrictStruct(data, mockStruct) - assert.Nil(t, err) - assert.Equal(t, 1, decodeWithFnCalled) - }) - - t.Run("test unmarshal with StrictStruct with err", func(t *testing.T) { - decodeWithFnCalled := 0 - mockStruct := &mockStrictStruct{ - EncodeWithFn: func(encoder *jsoninfo.ObjectEncoder, value interface{}) error { - return nil - }, - DecodeWithFn: func(decoder *jsoninfo.ObjectDecoder, value interface{}) error { - decodeWithFnCalled++ - return errors.New("unable to decode the value") - }, - } - err := jsoninfo.UnmarshalStrictStruct(data, mockStruct) - assert.NotNil(t, err) - assert.Equal(t, 1, decodeWithFnCalled) - }) -} - -func TestDecodeStructFieldsAndExtensions(t *testing.T) { - data := []byte(` - { - "field1": "field1", - "field2": "field2" - } -`) - decoder, err := jsoninfo.NewObjectDecoder(data) - assert.Nil(t, err) - assert.NotNil(t, decoder) - - t.Run("value is not pointer", func(t *testing.T) { - var value interface{} - assert.Panics(t, func() { - _ = decoder.DecodeStructFieldsAndExtensions(value) - }, "value is not a pointer") - }) - - t.Run("value is nil", func(t *testing.T) { - var value *string = nil - assert.Panics(t, func() { - _ = decoder.DecodeStructFieldsAndExtensions(value) - }, "value is nil") - }) - - t.Run("value is not struct", func(t *testing.T) { - var value = "simple string" - assert.Panics(t, func() { - _ = decoder.DecodeStructFieldsAndExtensions(&value) - }, "value is not struct") - }) - - t.Run("successfully decoded with all fields", func(t *testing.T) { - d, err := jsoninfo.NewObjectDecoder(data) - assert.Nil(t, err) - assert.NotNil(t, d) - - var value = struct { - Field1 string `json:"field1"` - Field2 string `json:"field2"` - }{} - err = d.DecodeStructFieldsAndExtensions(&value) - assert.Nil(t, err) - assert.Equal(t, "field1", value.Field1) - assert.Equal(t, "field2", value.Field2) - assert.Equal(t, 0, len(d.DecodeExtensionMap())) - }) - - t.Run("successfully decoded with renaming field", func(t *testing.T) { - d, err := jsoninfo.NewObjectDecoder(data) - assert.Nil(t, err) - assert.NotNil(t, d) - - var value = struct { - Field1 string `json:"field1"` - }{} - err = d.DecodeStructFieldsAndExtensions(&value) - assert.Nil(t, err) - assert.Equal(t, "field1", value.Field1) - assert.Equal(t, 1, len(d.DecodeExtensionMap())) - }) - - t.Run("un-successfully decoded due to data mismatch", func(t *testing.T) { - d, err := jsoninfo.NewObjectDecoder(data) - assert.Nil(t, err) - assert.NotNil(t, d) - - var value = struct { - Field1 int `json:"field1"` - }{} - err = d.DecodeStructFieldsAndExtensions(&value) - assert.NotNil(t, err) - assert.EqualError(t, err, "Error while unmarshalling property 'field1' (*int): json: cannot unmarshal string into Go value of type int") - assert.Equal(t, 0, value.Field1) - assert.Equal(t, 2, len(d.DecodeExtensionMap())) - }) -} diff --git a/jsoninfo/unsupported_properties_error.go b/jsoninfo/unsupported_properties_error.go deleted file mode 100644 index 258efef28..000000000 --- a/jsoninfo/unsupported_properties_error.go +++ /dev/null @@ -1,45 +0,0 @@ -package jsoninfo - -import ( - "encoding/json" - "fmt" - "sort" - "strings" -) - -// UnsupportedPropertiesError is a helper for extensions that want to refuse -// unsupported JSON object properties. -// -// It produces a helpful error message. -type UnsupportedPropertiesError struct { - Value interface{} - UnsupportedProperties map[string]json.RawMessage -} - -func NewUnsupportedPropertiesError(v interface{}, m map[string]json.RawMessage) error { - return &UnsupportedPropertiesError{ - Value: v, - UnsupportedProperties: m, - } -} - -func (err *UnsupportedPropertiesError) Error() string { - m := err.UnsupportedProperties - typeInfo := GetTypeInfoForValue(err.Value) - if m == nil || typeInfo == nil { - return "Invalid UnsupportedPropertiesError" - } - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - supported := typeInfo.FieldNames() - if len(supported) == 0 { - return fmt.Sprintf("Type '%T' doesn't take any properties. Unsupported properties: '%s'\n", - err.Value, strings.Join(keys, "', '")) - } - return fmt.Sprintf("Unsupported properties: '%s'\nSupported properties are: '%s'", - strings.Join(keys, "', '"), - strings.Join(supported, "', '")) -} diff --git a/openapi2/doc.go b/openapi2/doc.go new file mode 100644 index 000000000..b4762d597 --- /dev/null +++ b/openapi2/doc.go @@ -0,0 +1,7 @@ +// Package openapi2 parses and writes OpenAPIv2 specification documents. +// +// Does not cover all elements of OpenAPIv2. +// When OpenAPI version 3 is backwards-compatible with version 2, version 3 elements have been used. +// +// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md +package openapi2 diff --git a/openapi2/header.go b/openapi2/header.go new file mode 100644 index 000000000..a51f99dee --- /dev/null +++ b/openapi2/header.go @@ -0,0 +1,15 @@ +package openapi2 + +type Header struct { + Parameter +} + +// MarshalJSON returns the JSON encoding of Header. +func (header Header) MarshalJSON() ([]byte, error) { + return header.Parameter.MarshalJSON() +} + +// UnmarshalJSON sets Header to a copy of data. +func (header *Header) UnmarshalJSON(data []byte) error { + return header.Parameter.UnmarshalJSON(data) +} diff --git a/openapi2/openapi2.go b/openapi2/openapi2.go index 3da171ddd..88835db95 100644 --- a/openapi2/openapi2.go +++ b/openapi2/openapi2.go @@ -1,192 +1,117 @@ -// Package openapi2 parses and writes OpenAPI 2 specifications. -// -// Does not cover all elements of OpenAPI 2. -// When OpenAPI version 3 is backwards-compatible with version 2, version 3 elements have been used. -// -// The specification: -// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md package openapi2 import ( - "fmt" - "net/http" + "encoding/json" "github.com/getkin/kin-openapi/openapi3" ) -type Swagger struct { - Info openapi3.Info `json:"info"` - ExternalDocs *openapi3.ExternalDocs `json:"externalDocs,omitempty"` - Schemes []string `json:"schemes,omitempty"` - Host string `json:"host,omitempty"` - BasePath string `json:"basePath,omitempty"` - Paths map[string]*PathItem `json:"paths,omitempty"` - Definitions map[string]*openapi3.SchemaRef `json:"definitions,omitempty,noref"` - Parameters map[string]*Parameter `json:"parameters,omitempty,noref"` - Responses map[string]*Response `json:"responses,omitempty,noref"` - SecurityDefinitions map[string]*SecurityScheme `json:"securityDefinitions,omitempty"` - Security SecurityRequirements `json:"security,omitempty"` - Tags openapi3.Tags `json:"tags,omitempty"` +// T is the root of an OpenAPI v2 document +type T struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Swagger string `json:"swagger" yaml:"swagger"` // required + Info openapi3.Info `json:"info" yaml:"info"` // required + ExternalDocs *openapi3.ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Schemes []string `json:"schemes,omitempty" yaml:"schemes,omitempty"` + Consumes []string `json:"consumes,omitempty" yaml:"consumes,omitempty"` + Produces []string `json:"produces,omitempty" yaml:"produces,omitempty"` + Host string `json:"host,omitempty" yaml:"host,omitempty"` + BasePath string `json:"basePath,omitempty" yaml:"basePath,omitempty"` + Paths map[string]*PathItem `json:"paths,omitempty" yaml:"paths,omitempty"` + Definitions map[string]*openapi3.SchemaRef `json:"definitions,omitempty" yaml:"definitions,omitempty"` + Parameters map[string]*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Responses map[string]*Response `json:"responses,omitempty" yaml:"responses,omitempty"` + SecurityDefinitions map[string]*SecurityScheme `json:"securityDefinitions,omitempty" yaml:"securityDefinitions,omitempty"` + Security SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"` + Tags openapi3.Tags `json:"tags,omitempty" yaml:"tags,omitempty"` } -func (swagger *Swagger) AddOperation(path string, method string, operation *Operation) { - paths := swagger.Paths - if paths == nil { - paths = make(map[string]*PathItem, 8) - swagger.Paths = paths +// MarshalJSON returns the JSON encoding of T. +func (doc T) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 15+len(doc.Extensions)) + for k, v := range doc.Extensions { + m[k] = v } - pathItem := paths[path] - if pathItem == nil { - pathItem = &PathItem{} - paths[path] = pathItem + m["swagger"] = doc.Swagger + m["info"] = doc.Info + if x := doc.ExternalDocs; x != nil { + m["externalDocs"] = x } - pathItem.SetOperation(method, operation) -} - -type PathItem struct { - Ref string `json:"$ref,omitempty"` - Delete *Operation `json:"delete,omitempty"` - Get *Operation `json:"get,omitempty"` - Head *Operation `json:"head,omitempty"` - Options *Operation `json:"options,omitempty"` - Patch *Operation `json:"patch,omitempty"` - Post *Operation `json:"post,omitempty"` - Put *Operation `json:"put,omitempty"` - Parameters Parameters `json:"parameters,omitempty"` -} - -func (pathItem *PathItem) Operations() map[string]*Operation { - operations := make(map[string]*Operation, 8) - if v := pathItem.Delete; v != nil { - operations[http.MethodDelete] = v + if x := doc.Schemes; len(x) != 0 { + m["schemes"] = x } - if v := pathItem.Get; v != nil { - operations[http.MethodGet] = v + if x := doc.Consumes; len(x) != 0 { + m["consumes"] = x } - if v := pathItem.Head; v != nil { - operations[http.MethodHead] = v + if x := doc.Produces; len(x) != 0 { + m["produces"] = x } - if v := pathItem.Options; v != nil { - operations[http.MethodOptions] = v + if x := doc.Host; x != "" { + m["host"] = x } - if v := pathItem.Patch; v != nil { - operations[http.MethodPatch] = v + if x := doc.BasePath; x != "" { + m["basePath"] = x } - if v := pathItem.Post; v != nil { - operations[http.MethodPost] = v + if x := doc.Paths; len(x) != 0 { + m["paths"] = x } - if v := pathItem.Put; v != nil { - operations[http.MethodPut] = v + if x := doc.Definitions; len(x) != 0 { + m["definitions"] = x } - return operations -} - -func (pathItem *PathItem) GetOperation(method string) *Operation { - switch method { - case http.MethodDelete: - return pathItem.Delete - case http.MethodGet: - return pathItem.Get - case http.MethodHead: - return pathItem.Head - case http.MethodOptions: - return pathItem.Options - case http.MethodPatch: - return pathItem.Patch - case http.MethodPost: - return pathItem.Post - case http.MethodPut: - return pathItem.Put - default: - panic(fmt.Errorf("Unsupported HTTP method '%s'", method)) + if x := doc.Parameters; len(x) != 0 { + m["parameters"] = x } -} - -func (pathItem *PathItem) SetOperation(method string, operation *Operation) { - switch method { - case http.MethodDelete: - pathItem.Delete = operation - case http.MethodGet: - pathItem.Get = operation - case http.MethodHead: - pathItem.Head = operation - case http.MethodOptions: - pathItem.Options = operation - case http.MethodPatch: - pathItem.Patch = operation - case http.MethodPost: - pathItem.Post = operation - case http.MethodPut: - pathItem.Put = operation - default: - panic(fmt.Errorf("Unsupported HTTP method '%s'", method)) + if x := doc.Responses; len(x) != 0 { + m["responses"] = x } + if x := doc.SecurityDefinitions; len(x) != 0 { + m["securityDefinitions"] = x + } + if x := doc.Security; len(x) != 0 { + m["security"] = x + } + if x := doc.Tags; len(x) != 0 { + m["tags"] = x + } + return json.Marshal(m) } -type Operation struct { - Summary string `json:"summary,omitempty"` - Description string `json:"description,omitempty"` - ExternalDocs *openapi3.ExternalDocs `json:"externalDocs,omitempty"` - Tags []string `json:"tags,omitempty"` - OperationID string `json:"operationId,omitempty"` - Parameters Parameters `json:"parameters,omitempty"` - Responses map[string]*Response `json:"responses"` - Consumes []string `json:"consumes,omitempty"` - Produces []string `json:"produces,omitempty"` - Security *SecurityRequirements `json:"security,omitempty"` -} - -type Parameters []*Parameter - -type Parameter struct { - Ref string `json:"$ref,omitempty"` - In string `json:"in,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Required bool `json:"required,omitempty"` - UniqueItems bool `json:"uniqueItems,omitempty"` - ExclusiveMin bool `json:"exclusiveMinimum,omitempty"` - ExclusiveMax bool `json:"exclusiveMaximum,omitempty"` - Schema *openapi3.SchemaRef `json:"schema,omitempty"` - Type string `json:"type,omitempty"` - Format string `json:"format,omitempty"` - Enum []interface{} `json:"enum,omitempty"` - Minimum *float64 `json:"minimum,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - MinLength uint64 `json:"minLength,omitempty"` - MaxLength *uint64 `json:"maxLength,omitempty"` - Pattern string `json:"pattern,omitempty"` - Items *openapi3.SchemaRef `json:"items,omitempty"` - MinItems uint64 `json:"minItems,omitempty"` - MaxItems *uint64 `json:"maxItems,omitempty"` - Default interface{} `json:"default,omitempty"` -} - -type Response struct { - Ref string `json:"$ref,omitempty"` - Description string `json:"description,omitempty"` - Schema *openapi3.SchemaRef `json:"schema,omitempty"` - Headers map[string]*Header `json:"headers,omitempty"` - Examples map[string]interface{} `json:"examples,omitempty"` -} - -type Header struct { - Ref string `json:"$ref,omitempty"` - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` +// UnmarshalJSON sets T to a copy of data. +func (doc *T) UnmarshalJSON(data []byte) error { + type TBis T + var x TBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "swagger") + delete(x.Extensions, "info") + delete(x.Extensions, "externalDocs") + delete(x.Extensions, "schemes") + delete(x.Extensions, "consumes") + delete(x.Extensions, "produces") + delete(x.Extensions, "host") + delete(x.Extensions, "basePath") + delete(x.Extensions, "paths") + delete(x.Extensions, "definitions") + delete(x.Extensions, "parameters") + delete(x.Extensions, "responses") + delete(x.Extensions, "securityDefinitions") + delete(x.Extensions, "security") + delete(x.Extensions, "tags") + *doc = T(x) + return nil } -type SecurityRequirements []map[string][]string - -type SecurityScheme struct { - Ref string `json:"$ref,omitempty"` - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` - In string `json:"in,omitempty"` - Name string `json:"name,omitempty"` - Flow string `json:"flow,omitempty"` - AuthorizationURL string `json:"authorizationUrl,omitempty"` - TokenURL string `json:"tokenUrl,omitempty"` - Scopes map[string]string `json:"scopes,omitempty"` - Tags openapi3.Tags `json:"tags,omitempty"` +func (doc *T) AddOperation(path string, method string, operation *Operation) { + if doc.Paths == nil { + doc.Paths = make(map[string]*PathItem) + } + pathItem := doc.Paths[path] + if pathItem == nil { + pathItem = &PathItem{} + doc.Paths[path] = pathItem + } + pathItem.SetOperation(method, operation) } diff --git a/openapi2/openapi2_test.go b/openapi2/openapi2_test.go index 78194850a..65e92d601 100644 --- a/openapi2/openapi2_test.go +++ b/openapi2/openapi2_test.go @@ -1,25 +1,53 @@ -package openapi2 +package openapi2_test import ( "encoding/json" + "fmt" "io/ioutil" - "testing" + "reflect" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) + "github.com/invopop/yaml" -func TestReadingSwagger(t *testing.T) { - var swagger Swagger + "github.com/getkin/kin-openapi/openapi2" +) +func Example() { input, err := ioutil.ReadFile("testdata/swagger.json") - require.NoError(t, err) + if err != nil { + panic(err) + } + + var doc openapi2.T + if err = json.Unmarshal(input, &doc); err != nil { + panic(err) + } + if doc.ExternalDocs.Description != "Find out more about Swagger" { + panic(`doc.ExternalDocs was parsed incorrectly!`) + } - err = json.Unmarshal(input, &swagger) - require.NoError(t, err) + outputJSON, err := json.Marshal(doc) + if err != nil { + panic(err) + } + var docAgainFromJSON openapi2.T + if err = json.Unmarshal(outputJSON, &docAgainFromJSON); err != nil { + panic(err) + } + if !reflect.DeepEqual(doc, docAgainFromJSON) { + fmt.Println("objects doc & docAgainFromJSON should be the same") + } - output, err := json.Marshal(swagger) - require.NoError(t, err) + outputYAML, err := yaml.Marshal(doc) + if err != nil { + panic(err) + } + var docAgainFromYAML openapi2.T + if err = yaml.Unmarshal(outputYAML, &docAgainFromYAML); err != nil { + panic(err) + } + if !reflect.DeepEqual(doc, docAgainFromYAML) { + fmt.Println("objects doc & docAgainFromYAML should be the same") + } - assert.JSONEq(t, string(input), string(output)) + // Output: } diff --git a/openapi2/operation.go b/openapi2/operation.go new file mode 100644 index 000000000..b29f67de3 --- /dev/null +++ b/openapi2/operation.go @@ -0,0 +1,91 @@ +package openapi2 + +import ( + "encoding/json" + + "github.com/getkin/kin-openapi/openapi3" +) + +type Operation struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + ExternalDocs *openapi3.ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` + Parameters Parameters `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Responses map[string]*Response `json:"responses" yaml:"responses"` + Consumes []string `json:"consumes,omitempty" yaml:"consumes,omitempty"` + Produces []string `json:"produces,omitempty" yaml:"produces,omitempty"` + Schemes []string `json:"schemes,omitempty" yaml:"schemes,omitempty"` + Security *SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"` +} + +// MarshalJSON returns the JSON encoding of Operation. +func (operation Operation) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 12+len(operation.Extensions)) + for k, v := range operation.Extensions { + m[k] = v + } + if x := operation.Summary; x != "" { + m["summary"] = x + } + if x := operation.Description; x != "" { + m["description"] = x + } + if x := operation.Deprecated; x { + m["deprecated"] = x + } + if x := operation.ExternalDocs; x != nil { + m["externalDocs"] = x + } + if x := operation.Tags; len(x) != 0 { + m["tags"] = x + } + if x := operation.OperationID; x != "" { + m["operationId"] = x + } + if x := operation.Parameters; len(x) != 0 { + m["parameters"] = x + } + m["responses"] = operation.Responses + if x := operation.Consumes; len(x) != 0 { + m["consumes"] = x + } + if x := operation.Produces; len(x) != 0 { + m["produces"] = x + } + if x := operation.Schemes; len(x) != 0 { + m["schemes"] = x + } + if x := operation.Security; x != nil { + m["security"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets Operation to a copy of data. +func (operation *Operation) UnmarshalJSON(data []byte) error { + type OperationBis Operation + var x OperationBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "summary") + delete(x.Extensions, "description") + delete(x.Extensions, "deprecated") + delete(x.Extensions, "externalDocs") + delete(x.Extensions, "tags") + delete(x.Extensions, "operationId") + delete(x.Extensions, "parameters") + delete(x.Extensions, "responses") + delete(x.Extensions, "consumes") + delete(x.Extensions, "produces") + delete(x.Extensions, "schemes") + delete(x.Extensions, "security") + *operation = Operation(x) + return nil +} diff --git a/openapi2/parameter.go b/openapi2/parameter.go new file mode 100644 index 000000000..d2c71c64f --- /dev/null +++ b/openapi2/parameter.go @@ -0,0 +1,176 @@ +package openapi2 + +import ( + "encoding/json" + "sort" + + "github.com/getkin/kin-openapi/openapi3" +) + +type Parameters []*Parameter + +var _ sort.Interface = Parameters{} + +func (ps Parameters) Len() int { return len(ps) } +func (ps Parameters) Swap(i, j int) { ps[i], ps[j] = ps[j], ps[i] } +func (ps Parameters) Less(i, j int) bool { + if ps[i].Name != ps[j].Name { + return ps[i].Name < ps[j].Name + } + if ps[i].In != ps[j].In { + return ps[i].In < ps[j].In + } + return ps[i].Ref < ps[j].Ref +} + +type Parameter struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + + In string `json:"in,omitempty" yaml:"in,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + CollectionFormat string `json:"collectionFormat,omitempty" yaml:"collectionFormat,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` + Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"` + AllowEmptyValue bool `json:"allowEmptyValue,omitempty" yaml:"allowEmptyValue,omitempty"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` + ExclusiveMin bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` + ExclusiveMax bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + Schema *openapi3.SchemaRef `json:"schema,omitempty" yaml:"schema,omitempty"` + Items *openapi3.SchemaRef `json:"items,omitempty" yaml:"items,omitempty"` + Enum []interface{} `json:"enum,omitempty" yaml:"enum,omitempty"` + MultipleOf *float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` + Minimum *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"` + MaxLength *uint64 `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` + MaxItems *uint64 `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` + MinLength uint64 `json:"minLength,omitempty" yaml:"minLength,omitempty"` + MinItems uint64 `json:"minItems,omitempty" yaml:"minItems,omitempty"` + Default interface{} `json:"default,omitempty" yaml:"default,omitempty"` +} + +// MarshalJSON returns the JSON encoding of Parameter. +func (parameter Parameter) MarshalJSON() ([]byte, error) { + if ref := parameter.Ref; ref != "" { + return json.Marshal(openapi3.Ref{Ref: ref}) + } + + m := make(map[string]interface{}, 24+len(parameter.Extensions)) + for k, v := range parameter.Extensions { + m[k] = v + } + + if x := parameter.In; x != "" { + m["in"] = x + } + if x := parameter.Name; x != "" { + m["name"] = x + } + if x := parameter.Description; x != "" { + m["description"] = x + } + if x := parameter.CollectionFormat; x != "" { + m["collectionFormat"] = x + } + if x := parameter.Type; x != "" { + m["type"] = x + } + if x := parameter.Format; x != "" { + m["format"] = x + } + if x := parameter.Pattern; x != "" { + m["pattern"] = x + } + if x := parameter.AllowEmptyValue; x { + m["allowEmptyValue"] = x + } + if x := parameter.Required; x { + m["required"] = x + } + if x := parameter.UniqueItems; x { + m["uniqueItems"] = x + } + if x := parameter.ExclusiveMin; x { + m["exclusiveMinimum"] = x + } + if x := parameter.ExclusiveMax; x { + m["exclusiveMaximum"] = x + } + if x := parameter.Schema; x != nil { + m["schema"] = x + } + if x := parameter.Items; x != nil { + m["items"] = x + } + if x := parameter.Enum; x != nil { + m["enum"] = x + } + if x := parameter.MultipleOf; x != nil { + m["multipleOf"] = x + } + if x := parameter.Minimum; x != nil { + m["minimum"] = x + } + if x := parameter.Maximum; x != nil { + m["maximum"] = x + } + if x := parameter.MaxLength; x != nil { + m["maxLength"] = x + } + if x := parameter.MaxItems; x != nil { + m["maxItems"] = x + } + if x := parameter.MinLength; x != 0 { + m["minLength"] = x + } + if x := parameter.MinItems; x != 0 { + m["minItems"] = x + } + if x := parameter.Default; x != nil { + m["default"] = x + } + + return json.Marshal(m) +} + +// UnmarshalJSON sets Parameter to a copy of data. +func (parameter *Parameter) UnmarshalJSON(data []byte) error { + type ParameterBis Parameter + var x ParameterBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "$ref") + + delete(x.Extensions, "in") + delete(x.Extensions, "name") + delete(x.Extensions, "description") + delete(x.Extensions, "collectionFormat") + delete(x.Extensions, "type") + delete(x.Extensions, "format") + delete(x.Extensions, "pattern") + delete(x.Extensions, "allowEmptyValue") + delete(x.Extensions, "required") + delete(x.Extensions, "uniqueItems") + delete(x.Extensions, "exclusiveMinimum") + delete(x.Extensions, "exclusiveMaximum") + delete(x.Extensions, "schema") + delete(x.Extensions, "items") + delete(x.Extensions, "enum") + delete(x.Extensions, "multipleOf") + delete(x.Extensions, "minimum") + delete(x.Extensions, "maximum") + delete(x.Extensions, "maxLength") + delete(x.Extensions, "maxItems") + delete(x.Extensions, "minLength") + delete(x.Extensions, "minItems") + delete(x.Extensions, "default") + + *parameter = Parameter(x) + return nil +} diff --git a/openapi2/path_item.go b/openapi2/path_item.go new file mode 100644 index 000000000..95c060e7b --- /dev/null +++ b/openapi2/path_item.go @@ -0,0 +1,150 @@ +package openapi2 + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" +) + +type PathItem struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + + Delete *Operation `json:"delete,omitempty" yaml:"delete,omitempty"` + Get *Operation `json:"get,omitempty" yaml:"get,omitempty"` + Head *Operation `json:"head,omitempty" yaml:"head,omitempty"` + Options *Operation `json:"options,omitempty" yaml:"options,omitempty"` + Patch *Operation `json:"patch,omitempty" yaml:"patch,omitempty"` + Post *Operation `json:"post,omitempty" yaml:"post,omitempty"` + Put *Operation `json:"put,omitempty" yaml:"put,omitempty"` + Parameters Parameters `json:"parameters,omitempty" yaml:"parameters,omitempty"` +} + +// MarshalJSON returns the JSON encoding of PathItem. +func (pathItem PathItem) MarshalJSON() ([]byte, error) { + if ref := pathItem.Ref; ref != "" { + return json.Marshal(openapi3.Ref{Ref: ref}) + } + + m := make(map[string]interface{}, 8+len(pathItem.Extensions)) + for k, v := range pathItem.Extensions { + m[k] = v + } + if x := pathItem.Delete; x != nil { + m["delete"] = x + } + if x := pathItem.Get; x != nil { + m["get"] = x + } + if x := pathItem.Head; x != nil { + m["head"] = x + } + if x := pathItem.Options; x != nil { + m["options"] = x + } + if x := pathItem.Patch; x != nil { + m["patch"] = x + } + if x := pathItem.Post; x != nil { + m["post"] = x + } + if x := pathItem.Put; x != nil { + m["put"] = x + } + if x := pathItem.Parameters; len(x) != 0 { + m["parameters"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets PathItem to a copy of data. +func (pathItem *PathItem) UnmarshalJSON(data []byte) error { + type PathItemBis PathItem + var x PathItemBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "$ref") + delete(x.Extensions, "delete") + delete(x.Extensions, "get") + delete(x.Extensions, "head") + delete(x.Extensions, "options") + delete(x.Extensions, "patch") + delete(x.Extensions, "post") + delete(x.Extensions, "put") + delete(x.Extensions, "parameters") + *pathItem = PathItem(x) + return nil +} + +func (pathItem *PathItem) Operations() map[string]*Operation { + operations := make(map[string]*Operation) + if v := pathItem.Delete; v != nil { + operations[http.MethodDelete] = v + } + if v := pathItem.Get; v != nil { + operations[http.MethodGet] = v + } + if v := pathItem.Head; v != nil { + operations[http.MethodHead] = v + } + if v := pathItem.Options; v != nil { + operations[http.MethodOptions] = v + } + if v := pathItem.Patch; v != nil { + operations[http.MethodPatch] = v + } + if v := pathItem.Post; v != nil { + operations[http.MethodPost] = v + } + if v := pathItem.Put; v != nil { + operations[http.MethodPut] = v + } + return operations +} + +func (pathItem *PathItem) GetOperation(method string) *Operation { + switch method { + case http.MethodDelete: + return pathItem.Delete + case http.MethodGet: + return pathItem.Get + case http.MethodHead: + return pathItem.Head + case http.MethodOptions: + return pathItem.Options + case http.MethodPatch: + return pathItem.Patch + case http.MethodPost: + return pathItem.Post + case http.MethodPut: + return pathItem.Put + default: + panic(fmt.Errorf("unsupported HTTP method %q", method)) + } +} + +func (pathItem *PathItem) SetOperation(method string, operation *Operation) { + switch method { + case http.MethodDelete: + pathItem.Delete = operation + case http.MethodGet: + pathItem.Get = operation + case http.MethodHead: + pathItem.Head = operation + case http.MethodOptions: + pathItem.Options = operation + case http.MethodPatch: + pathItem.Patch = operation + case http.MethodPost: + pathItem.Post = operation + case http.MethodPut: + pathItem.Put = operation + default: + panic(fmt.Errorf("unsupported HTTP method %q", method)) + } +} diff --git a/openapi2/response.go b/openapi2/response.go new file mode 100644 index 000000000..bd18f882d --- /dev/null +++ b/openapi2/response.go @@ -0,0 +1,60 @@ +package openapi2 + +import ( + "encoding/json" + + "github.com/getkin/kin-openapi/openapi3" +) + +type Response struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Schema *openapi3.SchemaRef `json:"schema,omitempty" yaml:"schema,omitempty"` + Headers map[string]*Header `json:"headers,omitempty" yaml:"headers,omitempty"` + Examples map[string]interface{} `json:"examples,omitempty" yaml:"examples,omitempty"` +} + +// MarshalJSON returns the JSON encoding of Response. +func (response Response) MarshalJSON() ([]byte, error) { + if ref := response.Ref; ref != "" { + return json.Marshal(openapi3.Ref{Ref: ref}) + } + + m := make(map[string]interface{}, 4+len(response.Extensions)) + for k, v := range response.Extensions { + m[k] = v + } + if x := response.Description; x != "" { + m["description"] = x + } + if x := response.Schema; x != nil { + m["schema"] = x + } + if x := response.Headers; len(x) != 0 { + m["headers"] = x + } + if x := response.Examples; len(x) != 0 { + m["examples"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets Response to a copy of data. +func (response *Response) UnmarshalJSON(data []byte) error { + type ResponseBis Response + var x ResponseBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "$ref") + delete(x.Extensions, "description") + delete(x.Extensions, "schema") + delete(x.Extensions, "headers") + delete(x.Extensions, "examples") + *response = Response(x) + return nil +} diff --git a/openapi2/security_scheme.go b/openapi2/security_scheme.go new file mode 100644 index 000000000..5a8c278bd --- /dev/null +++ b/openapi2/security_scheme.go @@ -0,0 +1,87 @@ +package openapi2 + +import ( + "encoding/json" + + "github.com/getkin/kin-openapi/openapi3" +) + +type SecurityRequirements []map[string][]string + +type SecurityScheme struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Flow string `json:"flow,omitempty" yaml:"flow,omitempty"` + AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` + Scopes map[string]string `json:"scopes,omitempty" yaml:"scopes,omitempty"` + Tags openapi3.Tags `json:"tags,omitempty" yaml:"tags,omitempty"` +} + +// MarshalJSON returns the JSON encoding of SecurityScheme. +func (securityScheme SecurityScheme) MarshalJSON() ([]byte, error) { + if ref := securityScheme.Ref; ref != "" { + return json.Marshal(openapi3.Ref{Ref: ref}) + } + + m := make(map[string]interface{}, 10+len(securityScheme.Extensions)) + for k, v := range securityScheme.Extensions { + m[k] = v + } + if x := securityScheme.Description; x != "" { + m["description"] = x + } + if x := securityScheme.Type; x != "" { + m["type"] = x + } + if x := securityScheme.In; x != "" { + m["in"] = x + } + if x := securityScheme.Name; x != "" { + m["name"] = x + } + if x := securityScheme.Flow; x != "" { + m["flow"] = x + } + if x := securityScheme.AuthorizationURL; x != "" { + m["authorizationUrl"] = x + } + if x := securityScheme.TokenURL; x != "" { + m["tokenUrl"] = x + } + if x := securityScheme.Scopes; len(x) != 0 { + m["scopes"] = x + } + if x := securityScheme.Tags; len(x) != 0 { + m["tags"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets SecurityScheme to a copy of data. +func (securityScheme *SecurityScheme) UnmarshalJSON(data []byte) error { + type SecuritySchemeBis SecurityScheme + var x SecuritySchemeBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "$ref") + delete(x.Extensions, "description") + delete(x.Extensions, "type") + delete(x.Extensions, "in") + delete(x.Extensions, "name") + delete(x.Extensions, "flow") + delete(x.Extensions, "authorizationUrl") + delete(x.Extensions, "tokenUrl") + delete(x.Extensions, "scopes") + delete(x.Extensions, "tags") + *securityScheme = SecurityScheme(x) + return nil +} diff --git a/openapi2/testdata/swagger.json b/openapi2/testdata/swagger.json index 91484ff26..57f75d9a7 100644 --- a/openapi2/testdata/swagger.json +++ b/openapi2/testdata/swagger.json @@ -1 +1 @@ -{"info":{"title":"Swagger Petstore","description":"This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.","termsOfService":"http://swagger.io/terms/","contact":{"email":"apiteam@swagger.io"},"license":{"name":"Apache 2.0","url":"http://www.apache.org/licenses/LICENSE-2.0.html"},"version":"1.0.3"},"externalDocs":{"description":"Find out more about Swagger","url":"http://swagger.io"},"schemes":["https","http"],"host":"petstore.swagger.io","basePath":"/v2","paths":{"/pet":{"post":{"summary":"Add a new pet to the store","tags":["pet"],"operationId":"addPet","parameters":[{"in":"body","name":"body","description":"Pet object that needs to be added to the store","required":true,"schema":{"$ref":"#/definitions/Pet"}}],"responses":{"405":{"description":"Invalid input"}},"consumes":["application/json","application/xml"],"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]},"put":{"summary":"Update an existing pet","tags":["pet"],"operationId":"updatePet","parameters":[{"in":"body","name":"body","description":"Pet object that needs to be added to the store","required":true,"schema":{"$ref":"#/definitions/Pet"}}],"responses":{"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"},"405":{"description":"Validation exception"}},"consumes":["application/json","application/xml"],"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/findByStatus":{"get":{"summary":"Finds Pets by status","description":"Multiple status values can be provided with comma separated strings","tags":["pet"],"operationId":"findPetsByStatus","parameters":[{"in":"query","name":"status","description":"Status values that need to be considered for filter","required":true,"type":"array","items":{"default":"available","enum":["available","pending","sold"],"type":"string"}}],"responses":{"200":{"description":"successful operation","schema":{"items":{"$ref":"#/definitions/Pet"},"type":"array"}},"400":{"description":"Invalid status value"}},"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/findByTags":{"get":{"summary":"Finds Pets by tags","description":"Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.","tags":["pet"],"operationId":"findPetsByTags","parameters":[{"in":"query","name":"tags","description":"Tags to filter by","required":true,"type":"array","items":{"type":"string"}}],"responses":{"200":{"description":"successful operation","schema":{"items":{"$ref":"#/definitions/Pet"},"type":"array"}},"400":{"description":"Invalid tag value"}},"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/{petId}":{"delete":{"summary":"Deletes a pet","tags":["pet"],"operationId":"deletePet","parameters":[{"in":"header","name":"api_key","type":"string"},{"in":"path","name":"petId","description":"Pet id to delete","required":true,"type":"integer","format":"int64"}],"responses":{"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"}},"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]},"get":{"summary":"Find pet by ID","description":"Returns a single pet","tags":["pet"],"operationId":"getPetById","parameters":[{"in":"path","name":"petId","description":"ID of pet to return","required":true,"type":"integer","format":"int64"}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/Pet"}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"}},"produces":["application/json","application/xml"],"security":[{"api_key":[]}]},"post":{"summary":"Updates a pet in the store with form data","tags":["pet"],"operationId":"updatePetWithForm","parameters":[{"in":"path","name":"petId","description":"ID of pet that needs to be updated","required":true,"type":"integer","format":"int64"},{"in":"formData","name":"name","description":"Updated name of the pet","type":"string"},{"in":"formData","name":"status","description":"Updated status of the pet","type":"string"}],"responses":{"405":{"description":"Invalid input"}},"consumes":["application/x-www-form-urlencoded"],"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/{petId}/uploadImage":{"post":{"summary":"uploads an image","tags":["pet"],"operationId":"uploadFile","parameters":[{"in":"path","name":"petId","description":"ID of pet to update","required":true,"type":"integer","format":"int64"},{"in":"formData","name":"additionalMetadata","description":"Additional data to pass to server","type":"string"},{"in":"formData","name":"file","description":"file to upload","type":"file"}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/ApiResponse"}}},"consumes":["multipart/form-data"],"produces":["application/json"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/store/inventory":{"get":{"summary":"Returns pet inventories by status","description":"Returns a map of status codes to quantities","tags":["store"],"operationId":"getInventory","responses":{"200":{"description":"successful operation","schema":{"additionalProperties":{"format":"int32","type":"integer"},"type":"object"}}},"produces":["application/json"],"security":[{"api_key":[]}]}},"/store/order":{"post":{"summary":"Place an order for a pet","tags":["store"],"operationId":"placeOrder","parameters":[{"in":"body","name":"body","description":"order placed for purchasing the pet","required":true,"schema":{"$ref":"#/definitions/Order"}}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/Order"}},"400":{"description":"Invalid Order"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}},"/store/order/{orderId}":{"delete":{"summary":"Delete purchase order by ID","description":"For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors","tags":["store"],"operationId":"deleteOrder","parameters":[{"in":"path","name":"orderId","description":"ID of the order that needs to be deleted","required":true,"type":"integer","format":"int64","minimum":1}],"responses":{"400":{"description":"Invalid ID supplied"},"404":{"description":"Order not found"}},"produces":["application/json","application/xml"]},"get":{"summary":"Find purchase order by ID","description":"For valid response try integer IDs with value \u003e= 1 and \u003c= 10. Other values will generated exceptions","tags":["store"],"operationId":"getOrderById","parameters":[{"in":"path","name":"orderId","description":"ID of pet that needs to be fetched","required":true,"type":"integer","format":"int64","minimum":1,"maximum":10}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/Order"}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Order not found"}},"produces":["application/json","application/xml"]}},"/user":{"post":{"summary":"Create user","description":"This can only be done by the logged in user.","tags":["user"],"operationId":"createUser","parameters":[{"in":"body","name":"body","description":"Created user object","required":true,"schema":{"$ref":"#/definitions/User"}}],"responses":{"default":{"description":"successful operation"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}},"/user/createWithArray":{"post":{"summary":"Creates list of users with given input array","tags":["user"],"operationId":"createUsersWithArrayInput","parameters":[{"in":"body","name":"body","description":"List of user object","required":true,"schema":{"items":{"$ref":"#/definitions/User"},"type":"array"}}],"responses":{"default":{"description":"successful operation"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}},"/user/createWithList":{"post":{"summary":"Creates list of users with given input array","tags":["user"],"operationId":"createUsersWithListInput","parameters":[{"in":"body","name":"body","description":"List of user object","required":true,"schema":{"items":{"$ref":"#/definitions/User"},"type":"array"}}],"responses":{"default":{"description":"successful operation"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}},"/user/login":{"get":{"summary":"Logs user into the system","tags":["user"],"operationId":"loginUser","parameters":[{"in":"query","name":"username","description":"The user name for login","required":true,"type":"string"},{"in":"query","name":"password","description":"The password for login in clear text","required":true,"type":"string"}],"responses":{"200":{"description":"successful operation","schema":{"type":"string"},"headers":{"X-Expires-After":{"description":"date in UTC when token expires","type":"string"},"X-Rate-Limit":{"description":"calls per hour allowed by the user","type":"integer"}}},"400":{"description":"Invalid username/password supplied"}},"produces":["application/json","application/xml"]}},"/user/logout":{"get":{"summary":"Logs out current logged in user session","tags":["user"],"operationId":"logoutUser","responses":{"default":{"description":"successful operation"}},"produces":["application/json","application/xml"]}},"/user/{username}":{"delete":{"summary":"Delete user","description":"This can only be done by the logged in user.","tags":["user"],"operationId":"deleteUser","parameters":[{"in":"path","name":"username","description":"The name that needs to be deleted","required":true,"type":"string"}],"responses":{"400":{"description":"Invalid username supplied"},"404":{"description":"User not found"}},"produces":["application/json","application/xml"]},"get":{"summary":"Get user by user name","tags":["user"],"operationId":"getUserByName","parameters":[{"in":"path","name":"username","description":"The name that needs to be fetched. Use user1 for testing. ","required":true,"type":"string"}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/User"}},"400":{"description":"Invalid username supplied"},"404":{"description":"User not found"}},"produces":["application/json","application/xml"]},"put":{"summary":"Updated user","description":"This can only be done by the logged in user.","tags":["user"],"operationId":"updateUser","parameters":[{"in":"path","name":"username","description":"name that need to be updated","required":true,"type":"string"},{"in":"body","name":"body","description":"Updated user object","required":true,"schema":{"$ref":"#/definitions/User"}}],"responses":{"400":{"description":"Invalid user supplied"},"404":{"description":"User not found"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}}},"definitions":{"ApiResponse":{"properties":{"code":{"format":"int32","type":"integer"},"message":{"type":"string"},"type":{"type":"string"}},"type":"object"},"Category":{"properties":{"id":{"format":"int64","type":"integer"},"name":{"type":"string"}},"type":"object","xml":{"name":"Category"}},"Order":{"properties":{"complete":{"type":"boolean"},"id":{"format":"int64","type":"integer"},"petId":{"format":"int64","type":"integer"},"quantity":{"format":"int32","type":"integer"},"shipDate":{"format":"date-time","type":"string"},"status":{"description":"Order Status","enum":["placed","approved","delivered"],"type":"string"}},"type":"object","xml":{"name":"Order"}},"Pet":{"properties":{"category":{"$ref":"#/definitions/Category"},"id":{"format":"int64","type":"integer"},"name":{"example":"doggie","type":"string"},"photoUrls":{"items":{"type":"string","xml":{"name":"photoUrl"}},"type":"array","xml":{"wrapped":true}},"status":{"description":"pet status in the store","enum":["available","pending","sold"],"type":"string"},"tags":{"items":{"$ref":"#/definitions/Tag"},"type":"array","xml":{"wrapped":true}}},"required":["name","photoUrls"],"type":"object","xml":{"name":"Pet"}},"Tag":{"properties":{"id":{"format":"int64","type":"integer"},"name":{"type":"string"}},"type":"object","xml":{"name":"Tag"}},"User":{"properties":{"email":{"type":"string"},"firstName":{"type":"string"},"id":{"format":"int64","type":"integer"},"lastName":{"type":"string"},"password":{"type":"string"},"phone":{"type":"string"},"userStatus":{"description":"User Status","format":"int32","type":"integer"},"username":{"type":"string"}},"type":"object","xml":{"name":"User"}}},"securityDefinitions":{"api_key":{"type":"apiKey","in":"header","name":"api_key"},"petstore_auth":{"type":"oauth2","flow":"implicit","authorizationUrl":"https://petstore.swagger.io/oauth/authorize","scopes":{"read:pets":"read your pets","write:pets":"modify pets in your account"}}},"tags":[{"name":"pet","description":"Everything about your Pets","externalDocs":{"description":"Find out more","url":"http://swagger.io"}},{"name":"store","description":"Access to Petstore orders"},{"name":"user","description":"Operations about user","externalDocs":{"description":"Find out more about our store","url":"http://swagger.io"}}]} \ No newline at end of file +{"swagger":"2.0","info":{"title":"Swagger Petstore","description":"This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.","termsOfService":"http://swagger.io/terms/","contact":{"email":"apiteam@swagger.io"},"license":{"name":"Apache 2.0","url":"http://www.apache.org/licenses/LICENSE-2.0.html"},"version":"1.0.3"},"externalDocs":{"description":"Find out more about Swagger","url":"http://swagger.io"},"schemes":["https","http"],"host":"petstore.swagger.io","basePath":"/v2","paths":{"/pet":{"post":{"summary":"Add a new pet to the store","tags":["pet"],"operationId":"addPet","parameters":[{"in":"body","name":"body","description":"Pet object that needs to be added to the store","required":true,"schema":{"$ref":"#/definitions/Pet"}}],"responses":{"405":{"description":"Invalid input"}},"consumes":["application/json","application/xml"],"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]},"put":{"summary":"Update an existing pet","tags":["pet"],"operationId":"updatePet","parameters":[{"in":"body","name":"body","description":"Pet object that needs to be added to the store","required":true,"schema":{"$ref":"#/definitions/Pet"}}],"responses":{"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"},"405":{"description":"Validation exception"}},"consumes":["application/json","application/xml"],"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/findByStatus":{"get":{"summary":"Finds Pets by status","description":"Multiple status values can be provided with comma separated strings","tags":["pet"],"operationId":"findPetsByStatus","parameters":[{"in":"query","name":"status","description":"Status values that need to be considered for filter","required":true,"type":"array","items":{"default":"available","enum":["available","pending","sold"],"type":"string"}}],"responses":{"200":{"description":"successful operation","schema":{"items":{"$ref":"#/definitions/Pet"},"type":"array"}},"400":{"description":"Invalid status value"}},"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/findByTags":{"get":{"summary":"Finds Pets by tags","description":"Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.","tags":["pet"],"operationId":"findPetsByTags","parameters":[{"in":"query","name":"tags","description":"Tags to filter by","required":true,"type":"array","items":{"type":"string"}}],"responses":{"200":{"description":"successful operation","schema":{"items":{"$ref":"#/definitions/Pet"},"type":"array"}},"400":{"description":"Invalid tag value"}},"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/{petId}":{"delete":{"summary":"Deletes a pet","tags":["pet"],"operationId":"deletePet","parameters":[{"in":"header","name":"api_key","type":"string"},{"in":"path","name":"petId","description":"Pet id to delete","required":true,"type":"integer","format":"int64"}],"responses":{"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"}},"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]},"get":{"summary":"Find pet by ID","description":"Returns a single pet","tags":["pet"],"operationId":"getPetById","parameters":[{"in":"path","name":"petId","description":"ID of pet to return","required":true,"type":"integer","format":"int64"}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/Pet"}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"}},"produces":["application/json","application/xml"],"security":[{"api_key":[]}]},"post":{"summary":"Updates a pet in the store with form data","tags":["pet"],"operationId":"updatePetWithForm","parameters":[{"in":"path","name":"petId","description":"ID of pet that needs to be updated","required":true,"type":"integer","format":"int64"},{"in":"formData","name":"name","description":"Updated name of the pet","type":"string"},{"in":"formData","name":"status","description":"Updated status of the pet","type":"string"}],"responses":{"405":{"description":"Invalid input"}},"consumes":["application/x-www-form-urlencoded"],"produces":["application/json","application/xml"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/{petId}/uploadImage":{"post":{"summary":"uploads an image","tags":["pet"],"operationId":"uploadFile","parameters":[{"in":"path","name":"petId","description":"ID of pet to update","required":true,"type":"integer","format":"int64"},{"in":"formData","name":"additionalMetadata","description":"Additional data to pass to server","type":"string"},{"in":"formData","name":"file","description":"file to upload","type":"file"}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/ApiResponse"}}},"consumes":["multipart/form-data"],"produces":["application/json"],"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/store/inventory":{"get":{"summary":"Returns pet inventories by status","description":"Returns a map of status codes to quantities","tags":["store"],"operationId":"getInventory","responses":{"200":{"description":"successful operation","schema":{"additionalProperties":{"format":"int32","type":"integer"},"type":"object"}}},"produces":["application/json"],"security":[{"api_key":[]}]}},"/store/order":{"post":{"summary":"Place an order for a pet","tags":["store"],"operationId":"placeOrder","parameters":[{"in":"body","name":"body","description":"order placed for purchasing the pet","required":true,"schema":{"$ref":"#/definitions/Order"}}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/Order"}},"400":{"description":"Invalid Order"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}},"/store/order/{orderId}":{"delete":{"summary":"Delete purchase order by ID","description":"For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors","tags":["store"],"operationId":"deleteOrder","parameters":[{"in":"path","name":"orderId","description":"ID of the order that needs to be deleted","required":true,"type":"integer","format":"int64","minimum":1}],"responses":{"400":{"description":"Invalid ID supplied"},"404":{"description":"Order not found"}},"produces":["application/json","application/xml"]},"get":{"summary":"Find purchase order by ID","description":"For valid response try integer IDs with value \u003e= 1 and \u003c= 10. Other values will generated exceptions","tags":["store"],"operationId":"getOrderById","parameters":[{"in":"path","name":"orderId","description":"ID of pet that needs to be fetched","required":true,"type":"integer","format":"int64","minimum":1,"maximum":10}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/Order"}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Order not found"}},"produces":["application/json","application/xml"]}},"/user":{"post":{"summary":"Create user","description":"This can only be done by the logged in user.","tags":["user"],"operationId":"createUser","parameters":[{"in":"body","name":"body","description":"Created user object","required":true,"schema":{"$ref":"#/definitions/User"}}],"responses":{"default":{"description":"successful operation"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}},"/user/createWithArray":{"post":{"summary":"Creates list of users with given input array","tags":["user"],"operationId":"createUsersWithArrayInput","parameters":[{"in":"body","name":"body","description":"List of user object","required":true,"schema":{"items":{"$ref":"#/definitions/User"},"type":"array"}}],"responses":{"default":{"description":"successful operation"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}},"/user/createWithList":{"post":{"summary":"Creates list of users with given input array","tags":["user"],"operationId":"createUsersWithListInput","parameters":[{"in":"body","name":"body","description":"List of user object","required":true,"schema":{"items":{"$ref":"#/definitions/User"},"type":"array"}}],"responses":{"default":{"description":"successful operation"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}},"/user/login":{"get":{"summary":"Logs user into the system","tags":["user"],"operationId":"loginUser","parameters":[{"in":"query","name":"username","description":"The user name for login","required":true,"type":"string"},{"in":"query","name":"password","description":"The password for login in clear text","required":true,"type":"string"}],"responses":{"200":{"description":"successful operation","schema":{"type":"string"},"headers":{"X-Expires-After":{"description":"date in UTC when token expires","type":"string"},"X-Rate-Limit":{"description":"calls per hour allowed by the user","type":"integer"}}},"400":{"description":"Invalid username/password supplied"}},"produces":["application/json","application/xml"]}},"/user/logout":{"get":{"summary":"Logs out current logged in user session","tags":["user"],"operationId":"logoutUser","responses":{"default":{"description":"successful operation"}},"produces":["application/json","application/xml"]}},"/user/{username}":{"delete":{"summary":"Delete user","description":"This can only be done by the logged in user.","tags":["user"],"operationId":"deleteUser","parameters":[{"in":"path","name":"username","description":"The name that needs to be deleted","required":true,"type":"string"}],"responses":{"400":{"description":"Invalid username supplied"},"404":{"description":"User not found"}},"produces":["application/json","application/xml"]},"get":{"summary":"Get user by user name","tags":["user"],"operationId":"getUserByName","parameters":[{"in":"path","name":"username","description":"The name that needs to be fetched. Use user1 for testing. ","required":true,"type":"string"}],"responses":{"200":{"description":"successful operation","schema":{"$ref":"#/definitions/User"}},"400":{"description":"Invalid username supplied"},"404":{"description":"User not found"}},"produces":["application/json","application/xml"]},"put":{"summary":"Updated user","description":"This can only be done by the logged in user.","tags":["user"],"operationId":"updateUser","parameters":[{"in":"path","name":"username","description":"name that need to be updated","required":true,"type":"string"},{"in":"body","name":"body","description":"Updated user object","required":true,"schema":{"$ref":"#/definitions/User"}}],"responses":{"400":{"description":"Invalid user supplied"},"404":{"description":"User not found"}},"consumes":["application/json"],"produces":["application/json","application/xml"]}}},"definitions":{"ApiResponse":{"properties":{"code":{"format":"int32","type":"integer"},"message":{"type":"string"},"type":{"type":"string"}},"type":"object"},"Category":{"properties":{"id":{"format":"int64","type":"integer"},"name":{"type":"string"}},"type":"object","xml":{"name":"Category"}},"Order":{"properties":{"complete":{"type":"boolean"},"id":{"format":"int64","type":"integer"},"petId":{"format":"int64","type":"integer"},"quantity":{"format":"int32","type":"integer"},"shipDate":{"format":"date-time","type":"string"},"status":{"description":"Order Status","enum":["placed","approved","delivered"],"type":"string"}},"type":"object","xml":{"name":"Order"}},"Pet":{"properties":{"category":{"$ref":"#/definitions/Category"},"id":{"format":"int64","type":"integer"},"name":{"example":"doggie","type":"string"},"photoUrls":{"items":{"type":"string","xml":{"name":"photoUrl"}},"type":"array","xml":{"wrapped":true}},"status":{"description":"pet status in the store","enum":["available","pending","sold"],"type":"string"},"tags":{"items":{"$ref":"#/definitions/Tag"},"type":"array","xml":{"wrapped":true}}},"required":["name","photoUrls"],"type":"object","xml":{"name":"Pet"}},"Tag":{"properties":{"id":{"format":"int64","type":"integer"},"name":{"type":"string"}},"type":"object","xml":{"name":"Tag"}},"User":{"properties":{"email":{"type":"string"},"firstName":{"type":"string"},"id":{"format":"int64","type":"integer"},"lastName":{"type":"string"},"password":{"type":"string"},"phone":{"type":"string"},"userStatus":{"description":"User Status","format":"int32","type":"integer"},"username":{"type":"string"}},"type":"object","xml":{"name":"User"}}},"securityDefinitions":{"api_key":{"type":"apiKey","in":"header","name":"api_key"},"petstore_auth":{"type":"oauth2","flow":"implicit","authorizationUrl":"https://petstore.swagger.io/oauth/authorize","scopes":{"read:pets":"read your pets","write:pets":"modify pets in your account"}}},"tags":[{"name":"pet","description":"Everything about your Pets","externalDocs":{"description":"Find out more","url":"http://swagger.io"}},{"name":"store","description":"Access to Petstore orders"},{"name":"user","description":"Operations about user","externalDocs":{"description":"Find out more about our store","url":"http://swagger.io"}}]} \ No newline at end of file diff --git a/openapi2conv/doc.go b/openapi2conv/doc.go new file mode 100644 index 000000000..7b87ec224 --- /dev/null +++ b/openapi2conv/doc.go @@ -0,0 +1,2 @@ +// Package openapi2conv converts an OpenAPI v2 specification document to v3. +package openapi2conv diff --git a/openapi2conv/issue187_test.go b/openapi2conv/issue187_test.go new file mode 100644 index 000000000..93914d9f9 --- /dev/null +++ b/openapi2conv/issue187_test.go @@ -0,0 +1,194 @@ +package openapi2conv + +import ( + "context" + "encoding/json" + "testing" + + "github.com/invopop/yaml" + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi3" +) + +func v2v3JSON(spec2 []byte) (doc3 *openapi3.T, err error) { + var doc2 openapi2.T + if err = json.Unmarshal(spec2, &doc2); err != nil { + return + } + doc3, err = ToV3(&doc2) + return +} + +func v2v3YAML(spec2 []byte) (doc3 *openapi3.T, err error) { + var doc2 openapi2.T + if err = yaml.Unmarshal(spec2, &doc2); err != nil { + return + } + doc3, err = ToV3(&doc2) + return +} + +func TestIssue187(t *testing.T) { + spec := ` +{ + "swagger": "2.0", + "info": { + "description": "Test Golang Application", + "version": "1.0", + "title": "Test", + "contact": { + "name": "Test", + "email": "test@test.com" + } + }, + + "paths": { + "/me": { + "get": { + "description": "", + "operationId": "someTest", + "summary": "Some test", + "tags": ["probe"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "successful operation", + "schema": {"$ref": "#/definitions/model.ProductSearchAttributeRequest"} + } + } + } + } + }, + + "host": "", + "basePath": "/test", + "definitions": { + "model.ProductSearchAttributeRequest": { + "type": "object", + "properties": { + "filterField": { + "type": "string" + }, + "filterKey": { + "type": "string" + }, + "type": { + "type": "string" + }, + "values": { + "$ref": "#/definitions/model.ProductSearchAttributeValueRequest" + } + }, + "title": "model.ProductSearchAttributeRequest" + }, + "model.ProductSearchAttributeValueRequest": { + "type": "object", + "properties": { + "imageUrl": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "title": "model.ProductSearchAttributeValueRequest" + } + } +} +` + doc3, err := v2v3JSON([]byte(spec)) + require.NoError(t, err) + + spec3, err := json.Marshal(doc3) + require.NoError(t, err) + const expected = `{"components":{"schemas":{"model.ProductSearchAttributeRequest":{"properties":{"filterField":{"type":"string"},"filterKey":{"type":"string"},"type":{"type":"string"},"values":{"$ref":"#/components/schemas/model.ProductSearchAttributeValueRequest"}},"title":"model.ProductSearchAttributeRequest","type":"object"},"model.ProductSearchAttributeValueRequest":{"properties":{"imageUrl":{"type":"string"},"text":{"type":"string"}},"title":"model.ProductSearchAttributeValueRequest","type":"object"}}},"info":{"contact":{"email":"test@test.com","name":"Test"},"description":"Test Golang Application","title":"Test","version":"1.0"},"openapi":"3.0.3","paths":{"/me":{"get":{"operationId":"someTest","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/model.ProductSearchAttributeRequest"}}},"description":"successful operation"}},"summary":"Some test","tags":["probe"]}}}}` + require.JSONEq(t, string(spec3), expected) + + err = doc3.Validate(context.Background()) + require.NoError(t, err) +} + +func TestIssue237(t *testing.T) { + spec := ` +swagger: '2.0' +info: + version: 1.0.0 + title: title +paths: + /test: + get: + parameters: + - in: body + schema: + $ref: '#/definitions/TestRef' + responses: + '200': + description: description +definitions: + TestRef: + type: object + allOf: + - $ref: '#/definitions/TestRef2' + TestRef2: + type: object +` + doc3, err := v2v3YAML([]byte(spec)) + require.NoError(t, err) + + spec3, err := yaml.Marshal(doc3) + require.NoError(t, err) + const expected = `components: + schemas: + TestRef: + allOf: + - $ref: '#/components/schemas/TestRef2' + type: object + TestRef2: + type: object +info: + title: title + version: 1.0.0 +openapi: 3.0.3 +paths: + /test: + get: + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/TestRef' + responses: + "200": + description: description +` + require.YAMLEq(t, string(spec3), expected) + + err = doc3.Validate(context.Background()) + require.NoError(t, err) +} + +func TestPR449(t *testing.T) { + spec := ` +swagger: '2.0' +info: + version: 1.0.0 + title: title + +securityDefinitions: + OAuth2Application: + type: "oauth2" + flow: "application" + tokenUrl: "example.com/oauth2/token" +` + doc3, err := v2v3YAML([]byte(spec)) + require.NoError(t, err) + require.NotNil(t, doc3.Components.SecuritySchemes["OAuth2Application"].Value.Flows.ClientCredentials) + _, err = yaml.Marshal(doc3) + require.NoError(t, err) + + doc2, err := FromV3(doc3) + require.NoError(t, err) + require.Equal(t, doc2.SecurityDefinitions["OAuth2Application"].Flow, "application") +} diff --git a/openapi2conv/issue440_test.go b/openapi2conv/issue440_test.go new file mode 100644 index 000000000..2478384ff --- /dev/null +++ b/openapi2conv/issue440_test.go @@ -0,0 +1,49 @@ +package openapi2conv + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi3" +) + +func TestIssue440(t *testing.T) { + doc2file, err := os.Open("testdata/swagger.json") + require.NoError(t, err) + defer doc2file.Close() + var doc2 openapi2.T + err = json.NewDecoder(doc2file).Decode(&doc2) + require.NoError(t, err) + + doc3, err := ToV3(&doc2) + require.NoError(t, err) + err = doc3.Validate(context.Background()) + require.NoError(t, err) + require.Equal(t, openapi3.Servers{ + {URL: "https://petstore.swagger.io/v2"}, + {URL: "http://petstore.swagger.io/v2"}, + }, doc3.Servers) + + doc2.Host = "your-bot-domain.de" + doc2.Schemes = nil + doc2.BasePath = "" + doc3, err = ToV3(&doc2) + require.NoError(t, err) + err = doc3.Validate(context.Background()) + require.NoError(t, err) + require.Equal(t, openapi3.Servers{ + {URL: "https://your-bot-domain.de/"}, + }, doc3.Servers) + + doc2.Host = "https://your-bot-domain.de" + doc2.Schemes = nil + doc2.BasePath = "" + doc3, err = ToV3(&doc2) + require.Error(t, err) + require.Contains(t, err.Error(), `invalid host`) +} diff --git a/openapi2conv/issue558_test.go b/openapi2conv/issue558_test.go new file mode 100644 index 000000000..78661bf78 --- /dev/null +++ b/openapi2conv/issue558_test.go @@ -0,0 +1,37 @@ +package openapi2conv + +import ( + "testing" + + "github.com/invopop/yaml" + "github.com/stretchr/testify/require" +) + +func TestPR558(t *testing.T) { + spec := ` +swagger: '2.0' +info: + version: 1.0.0 + title: title +paths: + /test: + get: + deprecated: true + parameters: + - in: body + schema: + type: object + responses: + '200': + description: description +` + doc3, err := v2v3YAML([]byte(spec)) + require.NoError(t, err) + require.NotEmpty(t, doc3.Paths["/test"].Get.Deprecated) + _, err = yaml.Marshal(doc3) + require.NoError(t, err) + + doc2, err := FromV3(doc3) + require.NoError(t, err) + require.NotEmpty(t, doc2.Paths["/test"].Get.Deprecated) +} diff --git a/openapi2conv/issue573_test.go b/openapi2conv/issue573_test.go new file mode 100644 index 000000000..cefac409e --- /dev/null +++ b/openapi2conv/issue573_test.go @@ -0,0 +1,48 @@ +package openapi2conv + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue573(t *testing.T) { + spec := []byte(`paths: + /ping: + get: + produces: + - application/toml + - application/xml + responses: + 200: + schema: + type: object + properties: + username: + type: string + description: The user name. + post: + responses: + 200: + schema: + type: object + properties: + username: + type: string + description: The user name.`) + + v3, err := v2v3YAML(spec) + require.NoError(t, err) + + // Make sure the response content appears for each mime-type originally + // appeared in "produces". + pingGetContent := v3.Paths["/ping"].Get.Responses["200"].Value.Content + require.Len(t, pingGetContent, 2) + require.Contains(t, pingGetContent, "application/toml") + require.Contains(t, pingGetContent, "application/xml") + + // Is "produces" is not explicitly specified, default to "application/json". + pingPostContent := v3.Paths["/ping"].Post.Responses["200"].Value.Content + require.Len(t, pingPostContent, 1) + require.Contains(t, pingPostContent, "application/json") +} diff --git a/openapi2conv/openapi2_conv.go b/openapi2conv/openapi2_conv.go index 6e0b10e7e..c80e67201 100644 --- a/openapi2conv/openapi2_conv.go +++ b/openapi2conv/openapi2_conv.go @@ -1,228 +1,451 @@ -// Package openapi2conv converts an OpenAPI v2 specification to v3. package openapi2conv import ( "errors" "fmt" "net/url" + "sort" "strings" "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi3" ) -// ToV3Swagger converts an OpenAPIv2 spec to an OpenAPIv3 spec -func ToV3Swagger(swagger *openapi2.Swagger) (*openapi3.Swagger, error) { - result := &openapi3.Swagger{ - OpenAPI: "3.0.2", - Info: &swagger.Info, - Components: openapi3.Components{}, - Tags: swagger.Tags, - } - host := swagger.Host - if len(host) > 0 { - schemes := swagger.Schemes +// ToV3 converts an OpenAPIv2 spec to an OpenAPIv3 spec +func ToV3(doc2 *openapi2.T) (*openapi3.T, error) { + doc3 := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &doc2.Info, + Components: &openapi3.Components{}, + Tags: doc2.Tags, + Extensions: stripNonExtensions(doc2.Extensions), + ExternalDocs: doc2.ExternalDocs, + } + + if host := doc2.Host; host != "" { + if strings.Contains(host, "/") { + err := fmt.Errorf("invalid host %q. This MUST be the host only and does not include the scheme nor sub-paths.", host) + return nil, err + } + schemes := doc2.Schemes if len(schemes) == 0 { - schemes = []string{ - "https://", - } + schemes = []string{"https"} + } + basePath := doc2.BasePath + if basePath == "" { + basePath = "/" } - basePath := swagger.BasePath for _, scheme := range schemes { u := url.URL{ Scheme: scheme, Host: host, Path: basePath, } - result.AddServer(&openapi3.Server{ - URL: u.String(), - }) + doc3.AddServer(&openapi3.Server{URL: u.String()}) } } - if paths := swagger.Paths; paths != nil { - resultPaths := make(map[string]*openapi3.PathItem, len(paths)) - for path, pathItem := range paths { - r, err := ToV3PathItem(swagger, pathItem) - if err != nil { + + doc3.Components.Schemas = make(map[string]*openapi3.SchemaRef) + if parameters := doc2.Parameters; len(parameters) != 0 { + doc3.Components.Parameters = make(map[string]*openapi3.ParameterRef) + doc3.Components.RequestBodies = make(map[string]*openapi3.RequestBodyRef) + for k, parameter := range parameters { + v3Parameter, v3RequestBody, v3SchemaMap, err := ToV3Parameter(doc3.Components, parameter, doc2.Consumes) + switch { + case err != nil: return nil, err + case v3RequestBody != nil: + doc3.Components.RequestBodies[k] = v3RequestBody + case v3SchemaMap != nil: + for _, v3Schema := range v3SchemaMap { + doc3.Components.Schemas[k] = v3Schema + } + default: + doc3.Components.Parameters[k] = v3Parameter } - resultPaths[path] = r } - result.Paths = resultPaths } - if parameters := swagger.Parameters; parameters != nil { - result.Components.Parameters = make(map[string]*openapi3.ParameterRef) - result.Components.RequestBodies = make(map[string]*openapi3.RequestBodyRef) - for k, parameter := range parameters { - resultParameter, resultRequestBody, err := ToV3Parameter(parameter) + + if paths := doc2.Paths; len(paths) != 0 { + doc3Paths := make(map[string]*openapi3.PathItem, len(paths)) + for path, pathItem := range paths { + r, err := ToV3PathItem(doc2, doc3.Components, pathItem, doc2.Consumes) if err != nil { return nil, err } - if resultParameter != nil { - result.Components.Parameters[k] = resultParameter - } - if resultRequestBody != nil { - result.Components.RequestBodies[k] = resultRequestBody - } + doc3Paths[path] = r } + doc3.Paths = doc3Paths } - if responses := swagger.Responses; responses != nil { - result.Components.Responses = make(map[string]*openapi3.ResponseRef, len(responses)) + + if responses := doc2.Responses; len(responses) != 0 { + doc3.Components.Responses = make(map[string]*openapi3.ResponseRef, len(responses)) for k, response := range responses { - r, err := ToV3Response(response) + r, err := ToV3Response(response, doc2.Produces) if err != nil { return nil, err } - result.Components.Responses[k] = r + doc3.Components.Responses[k] = r } } - result.Components.Schemas = ToV3Schemas(swagger.Definitions) - if m := swagger.SecurityDefinitions; m != nil { - resultSecuritySchemes := make(map[string]*openapi3.SecuritySchemeRef) + + for key, schema := range ToV3Schemas(doc2.Definitions) { + doc3.Components.Schemas[key] = schema + } + + if m := doc2.SecurityDefinitions; len(m) != 0 { + doc3SecuritySchemes := make(map[string]*openapi3.SecuritySchemeRef) for k, v := range m { r, err := ToV3SecurityScheme(v) if err != nil { return nil, err } - resultSecuritySchemes[k] = r + doc3SecuritySchemes[k] = r } - result.Components.SecuritySchemes = resultSecuritySchemes + doc3.Components.SecuritySchemes = doc3SecuritySchemes } - result.Security = ToV3SecurityRequirements(swagger.Security) - return result, nil + + doc3.Security = ToV3SecurityRequirements(doc2.Security) + { + sl := openapi3.NewLoader() + if err := sl.ResolveRefsIn(doc3, nil); err != nil { + return nil, err + } + } + return doc3, nil } -func ToV3PathItem(swagger *openapi2.Swagger, pathItem *openapi2.PathItem) (*openapi3.PathItem, error) { - result := &openapi3.PathItem{} +func ToV3PathItem(doc2 *openapi2.T, components *openapi3.Components, pathItem *openapi2.PathItem, consumes []string) (*openapi3.PathItem, error) { + doc3 := &openapi3.PathItem{ + Extensions: stripNonExtensions(pathItem.Extensions), + } for method, operation := range pathItem.Operations() { - resultOperation, err := ToV3Operation(swagger, pathItem, operation) + doc3Operation, err := ToV3Operation(doc2, components, pathItem, operation, consumes) if err != nil { return nil, err } - result.SetOperation(method, resultOperation) + doc3.SetOperation(method, doc3Operation) } for _, parameter := range pathItem.Parameters { - v3Parameter, v3RequestBody, err := ToV3Parameter(parameter) - if err != nil { + v3Parameter, v3RequestBody, v3Schema, err := ToV3Parameter(components, parameter, consumes) + switch { + case err != nil: return nil, err + case v3RequestBody != nil: + return nil, errors.New("pathItem must not have a body parameter") + case v3Schema != nil: + return nil, errors.New("pathItem must not have a schema parameter") + default: + doc3.Parameters = append(doc3.Parameters, v3Parameter) } - if v3RequestBody != nil { - return nil, errors.New("PathItem shouldn't have a body parameter") - } - result.Parameters = append(result.Parameters, v3Parameter) } - return result, nil + return doc3, nil } -func ToV3Operation(swagger *openapi2.Swagger, pathItem *openapi2.PathItem, operation *openapi2.Operation) (*openapi3.Operation, error) { +func ToV3Operation(doc2 *openapi2.T, components *openapi3.Components, pathItem *openapi2.PathItem, operation *openapi2.Operation, consumes []string) (*openapi3.Operation, error) { if operation == nil { return nil, nil } - result := &openapi3.Operation{ + doc3 := &openapi3.Operation{ OperationID: operation.OperationID, Summary: operation.Summary, Description: operation.Description, + Deprecated: operation.Deprecated, Tags: operation.Tags, + Extensions: stripNonExtensions(operation.Extensions), } if v := operation.Security; v != nil { - resultSecurity := ToV3SecurityRequirements(*v) - result.Security = &resultSecurity + doc3Security := ToV3SecurityRequirements(*v) + doc3.Security = &doc3Security } + + if len(operation.Consumes) > 0 { + consumes = operation.Consumes + } + + var reqBodies []*openapi3.RequestBodyRef + formDataSchemas := make(map[string]*openapi3.SchemaRef) for _, parameter := range operation.Parameters { - v3Parameter, v3RequestBody, err := ToV3Parameter(parameter) - if err != nil { + v3Parameter, v3RequestBody, v3SchemaMap, err := ToV3Parameter(components, parameter, consumes) + switch { + case err != nil: return nil, err + case v3RequestBody != nil: + reqBodies = append(reqBodies, v3RequestBody) + case v3SchemaMap != nil: + for key, v3Schema := range v3SchemaMap { + formDataSchemas[key] = v3Schema + } + default: + doc3.Parameters = append(doc3.Parameters, v3Parameter) } - if v3RequestBody != nil { - result.RequestBody = v3RequestBody - } else if v3Parameter != nil { - result.Parameters = append(result.Parameters, v3Parameter) - } } + var err error + if doc3.RequestBody, err = onlyOneReqBodyParam(reqBodies, formDataSchemas, components, consumes); err != nil { + return nil, err + } + if responses := operation.Responses; responses != nil { - resultResponses := make(openapi3.Responses, len(responses)) + doc3Responses := make(openapi3.Responses, len(responses)) for k, response := range responses { - result, err := ToV3Response(response) + doc3, err := ToV3Response(response, operation.Produces) if err != nil { return nil, err } - resultResponses[k] = result + doc3Responses[k] = doc3 } - result.Responses = resultResponses + doc3.Responses = doc3Responses } - return result, nil + return doc3, nil } -func ToV3Parameter(parameter *openapi2.Parameter) (*openapi3.ParameterRef, *openapi3.RequestBodyRef, error) { - if parameter == nil { - return nil, nil, nil - } - if ref := parameter.Ref; len(ref) > 0 { - return &openapi3.ParameterRef{ - Ref: ToV3Ref(ref), - }, nil, nil +func getParameterNameFromOldRef(ref string) string { + cleanPath := strings.TrimPrefix(ref, "#/parameters/") + pathSections := strings.SplitN(cleanPath, "/", 1) + + return pathSections[0] +} + +func ToV3Parameter(components *openapi3.Components, parameter *openapi2.Parameter, consumes []string) (*openapi3.ParameterRef, *openapi3.RequestBodyRef, map[string]*openapi3.SchemaRef, error) { + if ref := parameter.Ref; ref != "" { + if strings.HasPrefix(ref, "#/parameters/") { + name := getParameterNameFromOldRef(ref) + if _, ok := components.RequestBodies[name]; ok { + v3Ref := strings.Replace(ref, "#/parameters/", "#/components/requestBodies/", 1) + return nil, &openapi3.RequestBodyRef{Ref: v3Ref}, nil, nil + } else if schema, ok := components.Schemas[name]; ok { + schemaRefMap := make(map[string]*openapi3.SchemaRef) + if val, ok := schema.Value.Extensions["x-formData-name"]; ok { + name = val.(string) + } + v3Ref := strings.Replace(ref, "#/parameters/", "#/components/schemas/", 1) + schemaRefMap[name] = &openapi3.SchemaRef{Ref: v3Ref} + return nil, nil, schemaRefMap, nil + } + } + return &openapi3.ParameterRef{Ref: ToV3Ref(ref)}, nil, nil, nil } - in := parameter.In - if in == "body" { + + switch parameter.In { + case "body": result := &openapi3.RequestBody{ Description: parameter.Description, Required: parameter.Required, + Extensions: stripNonExtensions(parameter.Extensions), } + if parameter.Name != "" { + if result.Extensions == nil { + result.Extensions = make(map[string]interface{}, 1) + } + result.Extensions["x-originalParamName"] = parameter.Name + } + if schemaRef := parameter.Schema; schemaRef != nil { - // Assume it's JSON - result.WithJSONSchemaRef(ToV3SchemaRef(schemaRef)) + result.WithSchemaRef(ToV3SchemaRef(schemaRef), consumes) + } + return nil, &openapi3.RequestBodyRef{Value: result}, nil, nil + + case "formData": + format, typ := parameter.Format, parameter.Type + if typ == "file" { + format, typ = "binary", "string" } - return nil, &openapi3.RequestBodyRef{ - Value: result, - }, nil + if parameter.Extensions == nil { + parameter.Extensions = make(map[string]interface{}, 1) + } + parameter.Extensions["x-formData-name"] = parameter.Name + var required []string + if parameter.Required { + required = []string{parameter.Name} + } + schemaRef := &openapi3.SchemaRef{Value: &openapi3.Schema{ + Description: parameter.Description, + Type: typ, + Extensions: stripNonExtensions(parameter.Extensions), + Format: format, + Enum: parameter.Enum, + Min: parameter.Minimum, + Max: parameter.Maximum, + ExclusiveMin: parameter.ExclusiveMin, + ExclusiveMax: parameter.ExclusiveMax, + MinLength: parameter.MinLength, + MaxLength: parameter.MaxLength, + Default: parameter.Default, + Items: parameter.Items, + MinItems: parameter.MinItems, + MaxItems: parameter.MaxItems, + Pattern: parameter.Pattern, + AllowEmptyValue: parameter.AllowEmptyValue, + UniqueItems: parameter.UniqueItems, + MultipleOf: parameter.MultipleOf, + Required: required, + }} + schemaRefMap := make(map[string]*openapi3.SchemaRef, 1) + schemaRefMap[parameter.Name] = schemaRef + return nil, nil, schemaRefMap, nil + + default: + required := parameter.Required + if parameter.In == openapi3.ParameterInPath { + required = true + } + + var schemaRefRef string + if schemaRef := parameter.Schema; schemaRef != nil && schemaRef.Ref != "" { + schemaRefRef = schemaRef.Ref + } + result := &openapi3.Parameter{ + In: parameter.In, + Name: parameter.Name, + Description: parameter.Description, + Required: required, + Extensions: stripNonExtensions(parameter.Extensions), + Schema: ToV3SchemaRef(&openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: parameter.Type, + Format: parameter.Format, + Enum: parameter.Enum, + Min: parameter.Minimum, + Max: parameter.Maximum, + ExclusiveMin: parameter.ExclusiveMin, + ExclusiveMax: parameter.ExclusiveMax, + MinLength: parameter.MinLength, + MaxLength: parameter.MaxLength, + Default: parameter.Default, + Items: parameter.Items, + MinItems: parameter.MinItems, + MaxItems: parameter.MaxItems, + Pattern: parameter.Pattern, + AllowEmptyValue: parameter.AllowEmptyValue, + UniqueItems: parameter.UniqueItems, + MultipleOf: parameter.MultipleOf, + }, + Ref: schemaRefRef, + }), + } + return &openapi3.ParameterRef{Value: result}, nil, nil, nil } - result := &openapi3.Parameter{ - In: in, - Name: parameter.Name, - Description: parameter.Description, - Required: parameter.Required, +} + +func formDataBody(bodies map[string]*openapi3.SchemaRef, reqs map[string]bool, consumes []string) *openapi3.RequestBodyRef { + if len(bodies) != len(reqs) { + panic(`request bodies and them being required must match`) + } + requireds := make([]string, 0, len(reqs)) + for propName, req := range reqs { + if _, ok := bodies[propName]; !ok { + panic(`request bodies and them being required must match`) + } + if req { + requireds = append(requireds, propName) + } + } + schema := &openapi3.Schema{ + Type: "object", + Properties: ToV3Schemas(bodies), + Required: requireds, } + return &openapi3.RequestBodyRef{ + Value: openapi3.NewRequestBody().WithSchema(schema, consumes), + } +} - if parameter.Type != "" { - schema := &openapi3.SchemaRef{ - Value: &openapi3.Schema{ - Type: parameter.Type, - Format: parameter.Format, - Enum: parameter.Enum, - Min: parameter.Minimum, - Max: parameter.Maximum, - ExclusiveMin: parameter.ExclusiveMin, - ExclusiveMax: parameter.ExclusiveMax, - MinLength: parameter.MinLength, - MaxLength: parameter.MaxLength, - Default: parameter.Default, - Items: parameter.Items, - MinItems: parameter.MinItems, - MaxItems: parameter.MaxItems, - }, +func getParameterNameFromNewRef(ref string) string { + cleanPath := strings.TrimPrefix(ref, "#/components/schemas/") + pathSections := strings.SplitN(cleanPath, "/", 1) + + return pathSections[0] +} + +func onlyOneReqBodyParam(bodies []*openapi3.RequestBodyRef, formDataSchemas map[string]*openapi3.SchemaRef, components *openapi3.Components, consumes []string) (*openapi3.RequestBodyRef, error) { + if len(bodies) > 1 { + return nil, errors.New("multiple body parameters cannot exist for the same operation") + } + + if len(bodies) != 0 && len(formDataSchemas) != 0 { + return nil, errors.New("body and form parameters cannot exist together for the same operation") + } + + for _, requestBodyRef := range bodies { + return requestBodyRef, nil + } + + if len(formDataSchemas) > 0 { + formDataParams := make(map[string]*openapi3.SchemaRef, len(formDataSchemas)) + formDataReqs := make(map[string]bool, len(formDataSchemas)) + for formDataName, formDataSchema := range formDataSchemas { + if formDataSchema.Ref != "" { + name := getParameterNameFromNewRef(formDataSchema.Ref) + if schema := components.Schemas[name]; schema != nil && schema.Value != nil { + if tempName, ok := schema.Value.Extensions["x-formData-name"]; ok { + name = tempName.(string) + } + formDataParams[name] = formDataSchema + formDataReqs[name] = false + for _, req := range schema.Value.Required { + if name == req { + formDataReqs[name] = true + } + } + } + } else if formDataSchema.Value != nil { + formDataParams[formDataName] = formDataSchema + formDataReqs[formDataName] = false + for _, req := range formDataSchema.Value.Required { + if formDataName == req { + formDataReqs[formDataName] = true + } + } + } } - result.Schema = ToV3SchemaRef(schema) + + return formDataBody(formDataParams, formDataReqs, consumes), nil } - return &openapi3.ParameterRef{ - Value: result, - }, nil, nil + + return nil, nil } -func ToV3Response(response *openapi2.Response) (*openapi3.ResponseRef, error) { - if ref := response.Ref; len(ref) > 0 { - return &openapi3.ResponseRef{ - Ref: ToV3Ref(ref), - }, nil +func ToV3Response(response *openapi2.Response, produces []string) (*openapi3.ResponseRef, error) { + if ref := response.Ref; ref != "" { + return &openapi3.ResponseRef{Ref: ToV3Ref(ref)}, nil } result := &openapi3.Response{ Description: &response.Description, + Extensions: stripNonExtensions(response.Extensions), } + + // Default to "application/json" if "produces" is not specified. + if len(produces) == 0 { + produces = []string{"application/json"} + } + if schemaRef := response.Schema; schemaRef != nil { - result.WithJSONSchemaRef(ToV3SchemaRef(schemaRef)) + schema := ToV3SchemaRef(schemaRef) + result.Content = make(openapi3.Content, len(produces)) + for _, mime := range produces { + result.Content[mime] = openapi3.NewMediaType().WithSchemaRef(schema) + } } - return &openapi3.ResponseRef{ - Value: result, - }, nil + if headers := response.Headers; len(headers) > 0 { + result.Headers = ToV3Headers(headers) + } + return &openapi3.ResponseRef{Value: result}, nil +} + +func ToV3Headers(defs map[string]*openapi2.Header) openapi3.Headers { + headers := make(openapi3.Headers, len(defs)) + for name, header := range defs { + header.In = "" + header.Name = "" + if ref := header.Ref; ref != "" { + headers[name] = &openapi3.HeaderRef{Ref: ToV3Ref(ref)} + } else { + parameter, _, _, _ := ToV3Parameter(nil, &header.Parameter, nil) + headers[name] = &openapi3.HeaderRef{Value: &openapi3.Header{ + Parameter: *parameter.Value, + }} + } + } + return headers } func ToV3Schemas(defs map[string]*openapi3.SchemaRef) map[string]*openapi3.SchemaRef { @@ -234,10 +457,8 @@ func ToV3Schemas(defs map[string]*openapi3.SchemaRef) map[string]*openapi3.Schem } func ToV3SchemaRef(schema *openapi3.SchemaRef) *openapi3.SchemaRef { - if ref := schema.Ref; len(ref) > 0 { - return &openapi3.SchemaRef{ - Ref: ToV3Ref(ref), - } + if ref := schema.Ref; ref != "" { + return &openapi3.SchemaRef{Ref: ToV3Ref(ref)} } if schema.Value == nil { return schema @@ -248,9 +469,17 @@ func ToV3SchemaRef(schema *openapi3.SchemaRef) *openapi3.SchemaRef { for k, v := range schema.Value.Properties { schema.Value.Properties[k] = ToV3SchemaRef(v) } - if schema.Value.AdditionalProperties != nil { - schema.Value.AdditionalProperties = ToV3SchemaRef(schema.Value.AdditionalProperties) + if v := schema.Value.AdditionalProperties.Schema; v != nil { + schema.Value.AdditionalProperties.Schema = ToV3SchemaRef(v) } + for i, v := range schema.Value.AllOf { + schema.Value.AllOf[i] = ToV3SchemaRef(v) + } + if val, ok := schema.Value.Extensions["x-nullable"]; ok { + schema.Value.Nullable, _ = val.(bool) + delete(schema.Value.Extensions, "x-nullable") + } + return schema } @@ -273,6 +502,8 @@ func FromV3Ref(ref string) string { for new, old := range ref2To3 { if strings.HasPrefix(ref, old) { ref = strings.Replace(ref, old, new, 1) + } else if strings.HasPrefix(ref, "#/components/requestBodies/") { + ref = strings.Replace(ref, "#/components/requestBodies/", "#/parameters/", 1) } } return ref @@ -295,6 +526,7 @@ func ToV3SecurityScheme(securityScheme *openapi2.SecurityScheme) (*openapi3.Secu } result := &openapi3.SecurityScheme{ Description: securityScheme.Description, + Extensions: stripNonExtensions(securityScheme.Extensions), } switch securityScheme.Type { case "basic": @@ -320,12 +552,14 @@ func ToV3SecurityScheme(securityScheme *openapi2.SecurityScheme) (*openapi3.Secu switch securityScheme.Flow { case "implicit": flows.Implicit = flow - case "accesscode": + case "accessCode": flows.AuthorizationCode = flow case "password": flows.Password = flow + case "application": + flows.ClientCredentials = flow default: - return nil, fmt.Errorf("Unsupported flow '%s'", securityScheme.Flow) + return nil, fmt.Errorf("unsupported flow %q", securityScheme.Flow) } } return &openapi3.SecuritySchemeRef{ @@ -334,20 +568,27 @@ func ToV3SecurityScheme(securityScheme *openapi2.SecurityScheme) (*openapi3.Secu }, nil } -func FromV3Swagger(swagger *openapi3.Swagger) (*openapi2.Swagger, error) { - resultResponses, err := FromV3Responses(swagger.Components.Responses) +// FromV3 converts an OpenAPIv3 spec to an OpenAPIv2 spec +func FromV3(doc3 *openapi3.T) (*openapi2.T, error) { + doc2Responses, err := FromV3Responses(doc3.Components.Responses, doc3.Components) if err != nil { return nil, err } - result := &openapi2.Swagger{ - Info: *swagger.Info, - Definitions: FromV3Schemas(swagger.Components.Schemas), - Responses: resultResponses, - Tags: swagger.Tags, + schemas, parameters := FromV3Schemas(doc3.Components.Schemas, doc3.Components) + doc2 := &openapi2.T{ + Swagger: "2.0", + Info: *doc3.Info, + Definitions: schemas, + Parameters: parameters, + Responses: doc2Responses, + Tags: doc3.Tags, + Extensions: stripNonExtensions(doc3.Extensions), + ExternalDocs: doc3.ExternalDocs, } + isHTTPS := false isHTTP := false - servers := swagger.Servers + servers := doc3.Servers for i, server := range servers { parsedURL, err := url.Parse(server.URL) if err == nil { @@ -359,114 +600,254 @@ func FromV3Swagger(swagger *openapi3.Swagger) (*openapi2.Swagger, error) { } // The first server is assumed to provide the base path if i == 0 { - result.Host = parsedURL.Host - result.BasePath = parsedURL.Path + doc2.Host = parsedURL.Host + doc2.BasePath = parsedURL.Path } } } if isHTTPS { - result.Schemes = append(result.Schemes, "https") + doc2.Schemes = append(doc2.Schemes, "https") } if isHTTP { - result.Schemes = append(result.Schemes, "http") + doc2.Schemes = append(doc2.Schemes, "http") } - for path, pathItem := range swagger.Paths { + for path, pathItem := range doc3.Paths { if pathItem == nil { continue } - result.AddOperation(path, "GET", nil) + doc2.AddOperation(path, "GET", nil) + addPathExtensions(doc2, path, stripNonExtensions(pathItem.Extensions)) for method, operation := range pathItem.Operations() { if operation == nil { continue } - resultOperation, err := FromV3Operation(swagger, operation) + doc2Operation, err := FromV3Operation(doc3, operation) if err != nil { return nil, err } - result.AddOperation(path, method, resultOperation) + doc2.AddOperation(path, method, doc2Operation) } params := openapi2.Parameters{} for _, param := range pathItem.Parameters { - p, err := FromV3Parameter(param) + p, err := FromV3Parameter(param, doc3.Components) if err != nil { return nil, err } params = append(params, p) } - result.Paths[path].Parameters = params + sort.Sort(params) + doc2.Paths[path].Parameters = params } - result.Parameters = map[string]*openapi2.Parameter{} - for name, param := range swagger.Components.Parameters { - if result.Parameters[name], err = FromV3Parameter(param); err != nil { + + for name, param := range doc3.Components.Parameters { + if doc2.Parameters[name], err = FromV3Parameter(param, doc3.Components); err != nil { return nil, err } } - if m := swagger.Components.SecuritySchemes; m != nil { - resultSecuritySchemes := make(map[string]*openapi2.SecurityScheme) + + for name, requestBodyRef := range doc3.Components.RequestBodies { + bodyOrRefParameters, formDataParameters, consumes, err := fromV3RequestBodies(name, requestBodyRef, doc3.Components) + if err != nil { + return nil, err + } + if len(formDataParameters) != 0 { + for _, param := range formDataParameters { + doc2.Parameters[param.Name] = param + } + } else if len(bodyOrRefParameters) != 0 { + for _, param := range bodyOrRefParameters { + doc2.Parameters[name] = param + } + } + + if len(consumes) != 0 { + doc2.Consumes = consumesToArray(consumes) + } + } + + if m := doc3.Components.SecuritySchemes; m != nil { + doc2SecuritySchemes := make(map[string]*openapi2.SecurityScheme) for id, securityScheme := range m { - v, err := FromV3SecurityScheme(swagger, securityScheme) + v, err := FromV3SecurityScheme(doc3, securityScheme) if err != nil { return nil, err } - resultSecuritySchemes[id] = v + doc2SecuritySchemes[id] = v } - result.SecurityDefinitions = resultSecuritySchemes + doc2.SecurityDefinitions = doc2SecuritySchemes } - result.Security = FromV3SecurityRequirements(swagger.Security) - return result, nil + doc2.Security = FromV3SecurityRequirements(doc3.Security) + + return doc2, nil +} + +func consumesToArray(consumes map[string]struct{}) []string { + consumesArr := make([]string, 0, len(consumes)) + for key := range consumes { + consumesArr = append(consumesArr, key) + } + sort.Strings(consumesArr) + return consumesArr +} + +func fromV3RequestBodies(name string, requestBodyRef *openapi3.RequestBodyRef, components *openapi3.Components) ( + bodyOrRefParameters openapi2.Parameters, + formParameters openapi2.Parameters, + consumes map[string]struct{}, + err error, +) { + if ref := requestBodyRef.Ref; ref != "" { + bodyOrRefParameters = append(bodyOrRefParameters, &openapi2.Parameter{Ref: FromV3Ref(ref)}) + return + } + + //Only select one formData or request body for an individual requestBody as OpenAPI 2 does not support multiples + if requestBodyRef.Value != nil { + for contentType, mediaType := range requestBodyRef.Value.Content { + if consumes == nil { + consumes = make(map[string]struct{}) + } + consumes[contentType] = struct{}{} + if contentType == "application/x-www-form-urlencoded" || contentType == "multipart/form-data" { + formParameters = FromV3RequestBodyFormData(mediaType) + continue + } + + paramName := name + if originalName, ok := requestBodyRef.Value.Extensions["x-originalParamName"]; ok { + paramName = originalName.(string) + } + + var r *openapi2.Parameter + if r, err = FromV3RequestBody(paramName, requestBodyRef, mediaType, components); err != nil { + return + } + + bodyOrRefParameters = append(bodyOrRefParameters, r) + } + } + return } -func FromV3Schemas(schemas map[string]*openapi3.SchemaRef) map[string]*openapi3.SchemaRef { - v2Defs := make(map[string]*openapi3.SchemaRef, len(schemas)) +func FromV3Schemas(schemas map[string]*openapi3.SchemaRef, components *openapi3.Components) (map[string]*openapi3.SchemaRef, map[string]*openapi2.Parameter) { + v2Defs := make(map[string]*openapi3.SchemaRef) + v2Params := make(map[string]*openapi2.Parameter) for name, schema := range schemas { - v2Defs[name] = FromV3SchemaRef(schema) + schemaConv, parameterConv := FromV3SchemaRef(schema, components) + if schemaConv != nil { + v2Defs[name] = schemaConv + } else if parameterConv != nil { + if parameterConv.Name == "" { + parameterConv.Name = name + } + v2Params[name] = parameterConv + } } - return v2Defs + return v2Defs, v2Params } -func FromV3SchemaRef(schema *openapi3.SchemaRef) *openapi3.SchemaRef { - if ref := schema.Ref; len(ref) > 0 { - return &openapi3.SchemaRef{ - Ref: FromV3Ref(ref), +func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components) (*openapi3.SchemaRef, *openapi2.Parameter) { + if ref := schema.Ref; ref != "" { + name := getParameterNameFromNewRef(ref) + if val, ok := components.Schemas[name]; ok { + if val.Value.Format == "binary" { + v2Ref := strings.Replace(ref, "#/components/schemas/", "#/parameters/", 1) + return nil, &openapi2.Parameter{Ref: v2Ref} + } } + + return &openapi3.SchemaRef{Ref: FromV3Ref(ref)}, nil } if schema.Value == nil { - return schema + return schema, nil } - if schema.Value.Items != nil { - schema.Value.Items = FromV3SchemaRef((schema.Value.Items)) + + if schema.Value != nil { + if schema.Value.Type == "string" && schema.Value.Format == "binary" { + paramType := "file" + required := false + + value, _ := schema.Value.Extensions["x-formData-name"] + originalName, _ := value.(string) + for _, prop := range schema.Value.Required { + if originalName == prop { + required = true + break + } + } + return nil, &openapi2.Parameter{ + In: "formData", + Name: originalName, + Description: schema.Value.Description, + Type: paramType, + Enum: schema.Value.Enum, + Minimum: schema.Value.Min, + Maximum: schema.Value.Max, + ExclusiveMin: schema.Value.ExclusiveMin, + ExclusiveMax: schema.Value.ExclusiveMax, + MinLength: schema.Value.MinLength, + MaxLength: schema.Value.MaxLength, + Default: schema.Value.Default, + Items: schema.Value.Items, + MinItems: schema.Value.MinItems, + MaxItems: schema.Value.MaxItems, + AllowEmptyValue: schema.Value.AllowEmptyValue, + UniqueItems: schema.Value.UniqueItems, + MultipleOf: schema.Value.MultipleOf, + Extensions: stripNonExtensions(schema.Value.Extensions), + Required: required, + } + } } - for k, v := range schema.Value.Properties { - schema.Value.Properties[k] = FromV3SchemaRef(v) + if v := schema.Value.Items; v != nil { + schema.Value.Items, _ = FromV3SchemaRef(v, components) } - if schema.Value.AdditionalProperties != nil { - schema.Value.AdditionalProperties = FromV3SchemaRef(schema.Value.AdditionalProperties) + keys := make([]string, 0, len(schema.Value.Properties)) + for k := range schema.Value.Properties { + keys = append(keys, k) } - return schema + sort.Strings(keys) + for _, key := range keys { + schema.Value.Properties[key], _ = FromV3SchemaRef(schema.Value.Properties[key], components) + } + if v := schema.Value.AdditionalProperties.Schema; v != nil { + schema.Value.AdditionalProperties.Schema, _ = FromV3SchemaRef(v, components) + } + for i, v := range schema.Value.AllOf { + schema.Value.AllOf[i], _ = FromV3SchemaRef(v, components) + } + if schema.Value.Nullable { + schema.Value.Nullable = false + schema.Value.Extensions["x-nullable"] = true + } + + return schema, nil } func FromV3SecurityRequirements(requirements openapi3.SecurityRequirements) openapi2.SecurityRequirements { if requirements == nil { return nil } - result := make([]map[string][]string, len(requirements)) - for i, item := range requirements { - result[i] = item + result := make([]map[string][]string, 0, len(requirements)) + for _, item := range requirements { + result = append(result, item) } return result } -func FromV3PathItem(swagger *openapi3.Swagger, pathItem *openapi3.PathItem) (*openapi2.PathItem, error) { - result := &openapi2.PathItem{} +func FromV3PathItem(doc3 *openapi3.T, pathItem *openapi3.PathItem) (*openapi2.PathItem, error) { + result := &openapi2.PathItem{ + Extensions: stripNonExtensions(pathItem.Extensions), + } for method, operation := range pathItem.Operations() { - r, err := FromV3Operation(swagger, operation) + r, err := FromV3Operation(doc3, operation) if err != nil { return nil, err } result.SetOperation(method, r) } for _, parameter := range pathItem.Parameters { - p, err := FromV3Parameter(parameter) + p, err := FromV3Parameter(parameter, doc3.Components) if err != nil { return nil, err } @@ -489,7 +870,57 @@ nameSearch: return "" } -func FromV3Operation(swagger *openapi3.Swagger, operation *openapi3.Operation) (*openapi2.Operation, error) { +func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameters { + parameters := openapi2.Parameters{} + for propName, schemaRef := range mediaType.Schema.Value.Properties { + if ref := schemaRef.Ref; ref != "" { + v2Ref := strings.Replace(ref, "#/components/schemas/", "#/parameters/", 1) + parameters = append(parameters, &openapi2.Parameter{Ref: v2Ref}) + continue + } + val := schemaRef.Value + typ := val.Type + if val.Format == "binary" { + typ = "file" + } + required := false + for _, name := range val.Required { + if name == propName { + required = true + break + } + } + parameter := &openapi2.Parameter{ + Name: propName, + Description: val.Description, + Type: typ, + In: "formData", + Extensions: stripNonExtensions(val.Extensions), + Enum: val.Enum, + ExclusiveMin: val.ExclusiveMin, + ExclusiveMax: val.ExclusiveMax, + MinLength: val.MinLength, + MaxLength: val.MaxLength, + Default: val.Default, + Items: val.Items, + MinItems: val.MinItems, + MaxItems: val.MaxItems, + Maximum: val.Max, + Minimum: val.Min, + Pattern: val.Pattern, + // CollectionFormat: val.CollectionFormat, + // Format: val.Format, + AllowEmptyValue: val.AllowEmptyValue, + Required: required, + UniqueItems: val.UniqueItems, + MultipleOf: val.MultipleOf, + } + parameters = append(parameters, parameter) + } + return parameters +} + +func FromV3Operation(doc3 *openapi3.T, operation *openapi3.Operation) (*openapi2.Operation, error) { if operation == nil { return nil, nil } @@ -497,28 +928,50 @@ func FromV3Operation(swagger *openapi3.Swagger, operation *openapi3.Operation) ( OperationID: operation.OperationID, Summary: operation.Summary, Description: operation.Description, + Deprecated: operation.Deprecated, Tags: operation.Tags, + Extensions: stripNonExtensions(operation.Extensions), } if v := operation.Security; v != nil { resultSecurity := FromV3SecurityRequirements(*v) result.Security = &resultSecurity } for _, parameter := range operation.Parameters { - r, err := FromV3Parameter(parameter) + r, err := FromV3Parameter(parameter, doc3.Components) if err != nil { return nil, err } result.Parameters = append(result.Parameters, r) } if v := operation.RequestBody; v != nil { - r, err := FromV3RequestBody(swagger, operation, v) + // Find parameter name that we can use for the body + name := findNameForRequestBody(operation) + if name == "" { + return nil, errors.New("could not find a name for request body") + } + + bodyOrRefParameters, formDataParameters, consumes, err := fromV3RequestBodies(name, v, doc3.Components) if err != nil { return nil, err } - result.Parameters = append(result.Parameters, r) + if len(formDataParameters) != 0 { + result.Parameters = append(result.Parameters, formDataParameters...) + } else if len(bodyOrRefParameters) != 0 { + for _, param := range bodyOrRefParameters { + result.Parameters = append(result.Parameters, param) + break // add a single request body + } + + } + + if len(consumes) != 0 { + result.Consumes = consumesToArray(consumes) + } } + sort.Sort(result.Parameters) + if responses := operation.Responses; responses != nil { - resultResponses, err := FromV3Responses(responses) + resultResponses, err := FromV3Responses(responses, doc3.Components) if err != nil { return nil, err } @@ -527,41 +980,26 @@ func FromV3Operation(swagger *openapi3.Swagger, operation *openapi3.Operation) ( return result, nil } -func FromV3RequestBody(swagger *openapi3.Swagger, operation *openapi3.Operation, requestBodyRef *openapi3.RequestBodyRef) (*openapi2.Parameter, error) { - if ref := requestBodyRef.Ref; len(ref) > 0 { - return &openapi2.Parameter{ - Ref: FromV3Ref(ref), - }, nil - } +func FromV3RequestBody(name string, requestBodyRef *openapi3.RequestBodyRef, mediaType *openapi3.MediaType, components *openapi3.Components) (*openapi2.Parameter, error) { requestBody := requestBodyRef.Value - // Find parameter name that we can use for the body - name := findNameForRequestBody(operation) - - // If found an available name - if name == "" { - return nil, errors.New("Could not find a name for request body") - } result := &openapi2.Parameter{ In: "body", Name: name, Description: requestBody.Description, Required: requestBody.Required, + Extensions: stripNonExtensions(requestBody.Extensions), } - // Add JSON schema - mediaType := requestBody.GetMediaType("application/json") if mediaType != nil { - result.Schema = mediaType.Schema + result.Schema, _ = FromV3SchemaRef(mediaType.Schema, components) } return result, nil } -func FromV3Parameter(ref *openapi3.ParameterRef) (*openapi2.Parameter, error) { - if v := ref.Ref; len(v) > 0 { - return &openapi2.Parameter{ - Ref: FromV3Ref(v), - }, nil +func FromV3Parameter(ref *openapi3.ParameterRef, components *openapi3.Components) (*openapi2.Parameter, error) { + if ref := ref.Ref; ref != "" { + return &openapi2.Parameter{Ref: FromV3Ref(ref)}, nil } parameter := ref.Value if parameter == nil { @@ -572,9 +1010,14 @@ func FromV3Parameter(ref *openapi3.ParameterRef) (*openapi2.Parameter, error) { In: parameter.In, Name: parameter.Name, Required: parameter.Required, + Extensions: stripNonExtensions(parameter.Extensions), } if schemaRef := parameter.Schema; schemaRef != nil { - schemaRef = FromV3SchemaRef(schemaRef) + schemaRef, _ = FromV3SchemaRef(schemaRef, components) + if ref := schemaRef.Ref; ref != "" { + result.Schema = &openapi3.SchemaRef{Ref: FromV3Ref(ref)} + return result, nil + } schema := schemaRef.Value result.Type = schema.Type result.Format = schema.Format @@ -590,14 +1033,18 @@ func FromV3Parameter(ref *openapi3.ParameterRef) (*openapi2.Parameter, error) { result.Items = schema.Items result.MinItems = schema.MinItems result.MaxItems = schema.MaxItems + result.AllowEmptyValue = schema.AllowEmptyValue + // result.CollectionFormat = schema.CollectionFormat + result.UniqueItems = schema.UniqueItems + result.MultipleOf = schema.MultipleOf } return result, nil } -func FromV3Responses(responses map[string]*openapi3.ResponseRef) (map[string]*openapi2.Response, error) { +func FromV3Responses(responses map[string]*openapi3.ResponseRef, components *openapi3.Components) (map[string]*openapi2.Response, error) { v2Responses := make(map[string]*openapi2.Response, len(responses)) for k, response := range responses { - r, err := FromV3Response(response) + r, err := FromV3Response(response, components) if err != nil { return nil, err } @@ -606,11 +1053,9 @@ func FromV3Responses(responses map[string]*openapi3.ResponseRef) (map[string]*op return v2Responses, nil } -func FromV3Response(ref *openapi3.ResponseRef) (*openapi2.Response, error) { - if v := ref.Ref; len(v) > 0 { - return &openapi2.Response{ - Ref: FromV3Ref(v), - }, nil +func FromV3Response(ref *openapi3.ResponseRef, components *openapi3.Components) (*openapi2.Response, error) { + if ref := ref.Ref; ref != "" { + return &openapi2.Response{Ref: FromV3Ref(ref)}, nil } response := ref.Value @@ -623,16 +1068,38 @@ func FromV3Response(ref *openapi3.ResponseRef) (*openapi2.Response, error) { } result := &openapi2.Response{ Description: description, + Extensions: stripNonExtensions(response.Extensions), } if content := response.Content; content != nil { if ct := content["application/json"]; ct != nil { - result.Schema = FromV3SchemaRef(ct.Schema) + result.Schema, _ = FromV3SchemaRef(ct.Schema, components) + } + } + if headers := response.Headers; len(headers) > 0 { + var err error + if result.Headers, err = FromV3Headers(headers, components); err != nil { + return nil, err } } return result, nil } -func FromV3SecurityScheme(swagger *openapi3.Swagger, ref *openapi3.SecuritySchemeRef) (*openapi2.SecurityScheme, error) { +func FromV3Headers(defs openapi3.Headers, components *openapi3.Components) (map[string]*openapi2.Header, error) { + headers := make(map[string]*openapi2.Header, len(defs)) + for name, header := range defs { + ref := openapi3.ParameterRef{Ref: header.Ref, Value: &header.Value.Parameter} + parameter, err := FromV3Parameter(&ref, components) + if err != nil { + return nil, err + } + parameter.In = "" + parameter.Name = "" + headers[name] = &openapi2.Header{Parameter: *parameter} + } + return headers, nil +} + +func FromV3SecurityScheme(doc3 *openapi3.T, ref *openapi3.SecuritySchemeRef) (*openapi2.SecurityScheme, error) { securityScheme := ref.Value if securityScheme == nil { return nil, nil @@ -640,6 +1107,7 @@ func FromV3SecurityScheme(swagger *openapi3.Swagger, ref *openapi3.SecuritySchem result := &openapi2.SecurityScheme{ Ref: FromV3Ref(ref.Ref), Description: securityScheme.Description, + Extensions: stripNonExtensions(securityScheme.Extensions), } switch securityScheme.Type { case "http": @@ -661,21 +1129,39 @@ func FromV3SecurityScheme(swagger *openapi3.Swagger, ref *openapi3.SecuritySchem if flows != nil { var flow *openapi3.OAuthFlow // TODO: Is this the right priority? What if multiple defined? - if flow = flows.Implicit; flow != nil { + switch { + case flows.Implicit != nil: result.Flow = "implicit" - } else if flow = flows.AuthorizationCode; flow != nil { - result.Flow = "accesscode" - } else if flow = flows.Password; flow != nil { + flow = flows.Implicit + result.AuthorizationURL = flow.AuthorizationURL + + case flows.AuthorizationCode != nil: + result.Flow = "accessCode" + flow = flows.AuthorizationCode + result.AuthorizationURL = flow.AuthorizationURL + result.TokenURL = flow.TokenURL + + case flows.Password != nil: result.Flow = "password" - } else { + flow = flows.Password + result.TokenURL = flow.TokenURL + + case flows.ClientCredentials != nil: + result.Flow = "application" + flow = flows.ClientCredentials + result.TokenURL = flow.TokenURL + + default: return nil, nil } + + result.Scopes = make(map[string]string, len(flow.Scopes)) for scope, desc := range flow.Scopes { result.Scopes[scope] = desc } } default: - return nil, fmt.Errorf("Unsupported security scheme type '%s'", securityScheme.Type) + return nil, fmt.Errorf("unsupported security scheme type %q", securityScheme.Type) } return result, nil } @@ -684,3 +1170,25 @@ var attemptedBodyParameterNames = []string{ "body", "requestBody", } + +// stripNonExtensions removes invalid extensions: those not prefixed by "x-" and returns them +func stripNonExtensions(extensions map[string]interface{}) map[string]interface{} { + for extName := range extensions { + if !strings.HasPrefix(extName, "x-") { + delete(extensions, extName) + } + } + return extensions +} + +func addPathExtensions(doc2 *openapi2.T, path string, extensions map[string]interface{}) { + if doc2.Paths == nil { + doc2.Paths = make(map[string]*openapi2.PathItem) + } + pathItem := doc2.Paths[path] + if pathItem == nil { + pathItem = &openapi2.PathItem{} + doc2.Paths[path] = pathItem + } + pathItem.Extensions = extensions +} diff --git a/openapi2conv/openapi2_conv_test.go b/openapi2conv/openapi2_conv_test.go index 2eeefecb3..317772152 100644 --- a/openapi2conv/openapi2_conv_test.go +++ b/openapi2conv/openapi2_conv_test.go @@ -1,421 +1,861 @@ package openapi2conv import ( + "context" "encoding/json" "testing" + "github.com/stretchr/testify/require" + "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi3" - "github.com/stretchr/testify/require" ) func TestConvOpenAPIV3ToV2(t *testing.T) { - var swagger3 openapi3.Swagger - err := json.Unmarshal([]byte(exampleV3), &swagger3) + var doc3 openapi3.T + err := json.Unmarshal([]byte(exampleV3), &doc3) require.NoError(t, err) + { + // Refs need resolving before we can Validate + sl := openapi3.NewLoader() + err = sl.ResolveRefsIn(&doc3, nil) + require.NoError(t, err) + err = doc3.Validate(context.Background()) + require.NoError(t, err) + } - actualV2, err := FromV3Swagger(&swagger3) + doc2, err := FromV3(&doc3) require.NoError(t, err) - data, err := json.Marshal(actualV2) + data, err := json.Marshal(doc2) require.NoError(t, err) require.JSONEq(t, exampleV2, string(data)) } +func TestConvOpenAPIV3ToV2WithReqBody(t *testing.T) { + var doc3 openapi3.T + err := json.Unmarshal([]byte(exampleRequestBodyV3), &doc3) + require.NoError(t, err) + { + // Refs need resolving before we can Validate + sl := openapi3.NewLoader() + err = sl.ResolveRefsIn(&doc3, nil) + require.NoError(t, err) + err = doc3.Validate(context.Background()) + require.NoError(t, err) + } + + doc2, err := FromV3(&doc3) + require.NoError(t, err) + data, err := json.Marshal(doc2) + require.NoError(t, err) + require.JSONEq(t, exampleRequestBodyV2, string(data)) +} + func TestConvOpenAPIV2ToV3(t *testing.T) { - var swagger2 openapi2.Swagger - err := json.Unmarshal([]byte(exampleV2), &swagger2) + var doc2 openapi2.T + err := json.Unmarshal([]byte(exampleV2), &doc2) require.NoError(t, err) - actualV3, err := ToV3Swagger(&swagger2) + doc3, err := ToV3(&doc2) + require.NoError(t, err) + err = doc3.Validate(context.Background()) require.NoError(t, err) - data, err := json.Marshal(actualV3) + data, err := json.Marshal(doc3) require.NoError(t, err) require.JSONEq(t, exampleV3, string(data)) } const exampleV2 = ` { - "info": {"title":"MyAPI","version":"0.1"}, - "schemes": ["https"], - "host": "test.example.com", - "basePath": "/v2", - "tags": [ - { - "name": "Example", - "description": "An example tag." - } - ], - "paths": { - "/another/{banana}/{id}": { - "parameters": [ - { - "$ref": "#/parameters/banana" - }, - { - "in": "path", - "name": "id", - "type": "integer", - "required": true - } - ] - }, - "/example": { - "delete": { - "description": "example delete", - "responses": { - "default": { - "description": "default response" - }, - "403": { - "$ref": "#/responses/ForbiddenError" - }, - "404": { - "description": "404 response" - } - } - }, - "get": { - "operationId": "example-get", - "summary": "example get", - "description": "example get", - "tags": [ - "Example" - ], - "parameters": [ - { - "in": "query", - "name": "x" - }, - { - "in": "query", - "name": "y", - "description": "The y parameter", - "type": "integer", - "minimum": 1, - "maximum": 10000, - "default": 250 - }, - { - "in": "query", - "name": "bbox", - "description": "Only return results that intersect the provided bounding box.", - "maxItems": 4, - "minItems": 4, - "type": "array", - "items": { - "type": "number" - } - }, - { - "in": "body", - "name": "body", - "schema": {} - } - ], - "responses": { - "200": { - "description": "ok", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/Item" - } - } - }, - "default": { - "description": "default response" - }, - "404": { - "description": "404 response" - } - }, - "security": [ - { - "get_security_0": [ - "scope0", - "scope1" - ], - "get_security_1": [] - } - ] - }, - "head": { - "description": "example head", - "responses": {} - }, - "patch": { - "description": "example patch", - "responses": {} - }, - "post": { - "description": "example post", - "responses": {} - }, - "put": { - "description": "example put", - "responses": {} - }, - "options": { - "description": "example options", - "responses": {} - } - } - }, - "responses": { - "ForbiddenError": { - "description": "Insufficient permission to perform the requested action.", - "schema": { - "$ref": "#/definitions/Error" - } - } - }, - "definitions": { - "Item": { - "type": "object", - "properties": { - "foo": { - "type": "string" - } - }, - "additionalProperties": { - "$ref": "#/definitions/ItemExtension" - } - }, - "ItemExtension": { - "description": "It could be anything." - }, - "Error": { - "description": "Error response.", - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - } - }, - "parameters": { - "banana": { - "in": "path", - "type": "string" - } - }, - "security": [ - { - "default_security_0": [ - "scope0", - "scope1" - ], - "default_security_1": [] - } - ] + "basePath": "/v2", + "consumes": [ + "application/json", + "application/xml" + ], + "definitions": { + "Error": { + "description": "Error response.", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "Item": { + "additionalProperties": true, + "properties": { + "foo": { + "type": "string", + "x-nullable": true + }, + "quux": { + "$ref": "#/definitions/ItemExtension" + } + }, + "type": "object" + }, + "ItemExtension": { + "description": "It could be anything.", + "type": "boolean" + }, + "foo": { + "description": "foo description", + "enum": [ + "bar", + "baz" + ], + "type": "string" + } + }, + "externalDocs": { + "description": "Example Documentation", + "url": "https://example/doc/" + }, + "host": "test.example.com", + "info": { + "title": "MyAPI", + "version": "0.1", + "x-info": "info extension" + }, + "parameters": { + "banana": { + "in": "path", + "name": "banana", + "required": true, + "type": "string" + }, + "post_form_ref": { + "description": "param description", + "in": "formData", + "name": "fileUpload2", + "required": true, + "type": "file", + "x-formData-name": "fileUpload2", + "x-mimetype": "text/plain" + }, + "put_body": { + "in": "body", + "name": "banana", + "required": true, + "schema": { + "type": "string" + }, + "x-originalParamName": "banana" + } + }, + "paths": { + "/another/{banana}/{id}": { + "parameters": [ + { + "$ref": "#/parameters/banana" + }, + { + "in": "path", + "name": "id", + "required": true, + "type": "integer" + } + ] + }, + "/example": { + "delete": { + "description": "example delete", + "operationId": "example-delete", + "parameters": [ + { + "description": "Only return results that intersect the provided bounding box.", + "in": "query", + "items": { + "type": "number" + }, + "maxItems": 4, + "minItems": 4, + "name": "bbox", + "type": "array" + }, + { + "in": "query", + "name": "x", + "type": "string", + "x-parameter": "parameter extension 1" + }, + { + "default": 250, + "description": "The y parameter", + "in": "query", + "maximum": 10000, + "minimum": 1, + "name": "y", + "type": "integer" + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "items": { + "$ref": "#/definitions/Item" + }, + "type": "array" + }, + "headers": { + "ETag": { + "description": "The ETag (or entity tag) HTTP response header is an identifier for a specific version of a resource.", + "type": "string", + "maxLength": 64 + } + } + }, + "404": { + "description": "404 response" + }, + "default": { + "description": "default response", + "x-response": "response extension 1" + } + }, + "security": [ + { + "get_security_0": [ + "scope0", + "scope1" + ], + "get_security_1": [] + } + ], + "summary": "example get", + "tags": [ + "Example" + ] + }, + "get": { + "description": "example get", + "responses": { + "403": { + "$ref": "#/responses/ForbiddenError" + }, + "404": { + "description": "404 response" + }, + "default": { + "description": "default response" + } + }, + "x-operation": "operation extension 1" + }, + "head": { + "description": "example head", + "responses": { + "default": { + "description": "default response" + } + } + }, + "options": { + "description": "example options", + "responses": { + "default": { + "description": "default response" + } + } + }, + "patch": { + "consumes": [ + "application/json", + "application/xml" + ], + "description": "example patch", + "parameters": [ + { + "in": "body", + "name": "patch_body", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/Item" + } + ] + }, + "x-originalParamName": "patch_body", + "x-requestBody": "requestbody extension 1" + } + ], + "responses": { + "default": { + "description": "default response" + } + } + }, + "post": { + "consumes": [ + "multipart/form-data" + ], + "description": "example post", + "parameters": [ + { + "$ref": "#/parameters/post_form_ref" + }, + { + "description": "param description", + "in": "formData", + "name": "fileUpload", + "type": "file", + "x-formData-name": "fileUpload", + "x-mimetype": "text/plain" + }, + { + "description": "File Id", + "in": "query", + "name": "id", + "type": "integer" + }, + { + "description": "Description of file contents", + "in": "formData", + "name": "note", + "type": "integer", + "x-formData-name": "note" + } + ], + "responses": { + "default": { + "description": "default response" + } + } + }, + "put": { + "description": "example put", + "parameters": [ + { + "$ref": "#/parameters/put_body" + } + ], + "responses": { + "default": { + "description": "default response" + } + } + }, + "x-path": "path extension 1", + "x-path2": "path extension 2" + }, + "/foo": { + "get": { + "operationId": "getFoo", + "consumes": [ + "application/json", + "application/xml" + ], + "parameters": [ + { + "x-originalParamName": "foo", + "in": "body", + "name": "foo", + "schema": { + "$ref": "#/definitions/foo" + } + } + ], + "responses": { + "default": { + "description": "OK", + "schema": { + "$ref": "#/definitions/foo" + } + } + }, + "summary": "get foo" + } + } + }, + "responses": { + "ForbiddenError": { + "description": "Insufficient permission to perform the requested action.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "schemes": [ + "https" + ], + "security": [ + { + "default_security_0": [ + "scope0", + "scope1" + ], + "default_security_1": [] + } + ], + "swagger": "2.0", + "tags": [ + { + "description": "An example tag.", + "name": "Example" + } + ], + "x-root": "root extension 1", + "x-root2": "root extension 2" } ` const exampleV3 = ` { - "openapi": "3.0.2", - "info": {"title":"MyAPI","version":"0.1"}, - "components": { - "responses": { - "ForbiddenError": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Insufficient permission to perform the requested action." - } - }, - "parameters": { - "banana": { - "in": "path", - "schema": { - "type": "string" - } - } - }, - "schemas": { - "Item": { - "type": "object", - "properties": { - "foo": { - "type": "string" - } - }, - "additionalProperties": { - "$ref": "#/components/schemas/ItemExtension" - } - }, - "ItemExtension": { - "description": "It could be anything." - }, - "Error": { - "description": "Error response.", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - } - } - }, - "tags": [ - { - "name": "Example", - "description": "An example tag." - } - ], - "servers": [ - { - "url": "https://test.example.com/v2" - } - ], - "paths": { - "/another/{banana}/{id}": { - "parameters": [ - { - "$ref": "#/components/parameters/banana" - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ] - }, - "/example": { - "delete": { - "description": "example delete", - "responses": { - "default": { - "description": "default response" - }, - "403": { - "$ref": "#/components/responses/ForbiddenError" - }, - "404": { - "description": "404 response" - } - } - }, - "get": { - "operationId": "example-get", - "summary": "example get", - "description": "example get", - "tags": [ - "Example" - ], - "parameters": [ - { - "in": "query", - "name": "x" - }, - { - "description": "The y parameter", - "in": "query", - "name": "y", - "schema": { - "default": 250, - "maximum": 10000, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Only return results that intersect the provided bounding box.", - "in": "query", - "name": "bbox", - "schema": { - "type": "array", - "items": { - "type": "number" - }, - "minItems": 4, - "maxItems": 4 - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": {} - } - } - }, - "responses": { - "200": { - "description": "ok", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/Item" - }, - "type": "array" - } - } - } - }, - "default": { - "description": "default response" - }, - "404": { - "description": "404 response" - } - }, - "security": [ - { - "get_security_0": [ - "scope0", - "scope1" - ], - "get_security_1": [] - } - ] - }, - "head": { - "description": "example head", - "responses": {} - }, - "options": { - "description": "example options", - "responses": {} - }, - "patch": { - "description": "example patch", - "responses": {} - }, - "post": { - "description": "example post", - "responses": {} - }, - "put": { - "description": "example put", - "responses": {} - } - } - }, - "security": [ - { - "default_security_0": [ - "scope0", - "scope1" - ], - "default_security_1": [] - } - ] + "components": { + "parameters": { + "banana": { + "in": "path", + "name": "banana", + "required": true, + "schema": { + "type": "string" + } + } + }, + "requestBodies": { + "put_body": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "application/xml": { + "schema": { + "type": "string" + } + } + }, + "required": true, + "x-originalParamName": "banana" + } + }, + "responses": { + "ForbiddenError": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Insufficient permission to perform the requested action." + } + }, + "schemas": { + "Error": { + "description": "Error response.", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "Item": { + "additionalProperties": true, + "properties": { + "foo": { + "type": "string", + "nullable": true + }, + "quux": { + "$ref": "#/components/schemas/ItemExtension" + } + }, + "type": "object" + }, + "ItemExtension": { + "description": "It could be anything.", + "type": "boolean" + }, + "post_form_ref": { + "description": "param description", + "format": "binary", + "required": [ + "fileUpload2" + ], + "type": "string", + "x-formData-name": "fileUpload2", + "x-mimetype": "text/plain" + }, + "foo": { + "description": "foo description", + "enum": [ + "bar", + "baz" + ], + "type": "string" + } + } + }, + "externalDocs": { + "description": "Example Documentation", + "url": "https://example/doc/" + }, + "info": { + "title": "MyAPI", + "version": "0.1", + "x-info": "info extension" + }, + "openapi": "3.0.3", + "paths": { + "/another/{banana}/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/banana" + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "/example": { + "delete": { + "description": "example delete", + "operationId": "example-delete", + "parameters": [ + { + "description": "Only return results that intersect the provided bounding box.", + "in": "query", + "name": "bbox", + "schema": { + "items": { + "type": "number" + }, + "maxItems": 4, + "minItems": 4, + "type": "array" + } + }, + { + "in": "query", + "name": "x", + "schema": { + "type": "string" + }, + "x-parameter": "parameter extension 1" + }, + { + "description": "The y parameter", + "in": "query", + "name": "y", + "schema": { + "default": 250, + "maximum": 10000, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Item" + }, + "type": "array" + } + } + }, + "description": "ok", + "headers": { + "ETag": { + "description": "The ETag (or entity tag) HTTP response header is an identifier for a specific version of a resource.", + "schema": { + "type": "string", + "maxLength": 64 + } + } + } + }, + "404": { + "description": "404 response" + }, + "default": { + "description": "default response", + "x-response": "response extension 1" + } + }, + "security": [ + { + "get_security_0": [ + "scope0", + "scope1" + ], + "get_security_1": [] + } + ], + "summary": "example get", + "tags": [ + "Example" + ] + }, + "get": { + "description": "example get", + "responses": { + "403": { + "$ref": "#/components/responses/ForbiddenError" + }, + "404": { + "description": "404 response" + }, + "default": { + "description": "default response" + } + }, + "x-operation": "operation extension 1" + }, + "head": { + "description": "example head", + "responses": { + "default": { + "description": "default response" + } + } + }, + "options": { + "description": "example options", + "responses": { + "default": { + "description": "default response" + } + } + }, + "patch": { + "description": "example patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Item" + } + ] + } + }, + "application/xml": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Item" + } + ] + } + } + }, + "x-originalParamName": "patch_body", + "x-requestBody": "requestbody extension 1" + }, + "responses": { + "default": { + "description": "default response" + } + } + }, + "post": { + "description": "example post", + "parameters": [ + { + "description": "File Id", + "in": "query", + "name": "id", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "fileUpload": { + "description": "param description", + "format": "binary", + "type": "string", + "x-formData-name": "fileUpload", + "x-mimetype": "text/plain" + }, + "fileUpload2": { + "$ref": "#/components/schemas/post_form_ref" + }, + "note": { + "description": "Description of file contents", + "type": "integer", + "x-formData-name": "note" + } + }, + "required": [ + "fileUpload2" + ], + "type": "object" + } + } + } + }, + "responses": { + "default": { + "description": "default response" + } + } + }, + "put": { + "description": "example put", + "requestBody": { + "$ref": "#/components/requestBodies/put_body" + }, + "responses": { + "default": { + "description": "default response" + } + } + }, + "x-path": "path extension 1", + "x-path2": "path extension 2" + }, + "/foo": { + "get": { + "operationId": "getFoo", + "requestBody": { + "x-originalParamName": "foo", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/foo" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/foo" + } + } + } + }, + "responses": { + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/foo" + } + } + }, + "description": "OK" + } + }, + "summary": "get foo" + } + } + }, + "security": [ + { + "default_security_0": [ + "scope0", + "scope1" + ], + "default_security_1": [] + } + ], + "servers": [ + { + "url": "https://test.example.com/v2" + } + ], + "tags": [ + { + "description": "An example tag.", + "name": "Example" + } + ], + "x-root": "root extension 1", + "x-root2": "root extension 2" +} +` + +const exampleRequestBodyV3 = `{ + "info": { + "description": "Test Spec", + "title": "Test Spec", + "version": "0.0.0" + }, + "components": { + "requestBodies": { + "FooBody": { + "content": { + "application/json": { + "schema": { + "properties": { "message": { "type": "string" } }, + "type": "object" + } + } + }, + "description": "test spec request body.", + "required": true + } + } + }, + "paths": { + "/foo-path": { + "post": { + "requestBody": { "$ref": "#/components/requestBodies/FooBody" }, + "responses": { "202": { "description": "Test spec post." } }, + "summary": "Test spec path" + } + } + }, + "servers": [{ "url": "http://localhost/" }], + "openapi": "3.0.3" +} +` + +const exampleRequestBodyV2 = `{ + "basePath": "/", + "consumes": ["application/json"], + "host": "localhost", + "info": { + "description": "Test Spec", + "title": "Test Spec", + "version": "0.0.0" + }, + "parameters": { + "FooBody": { + "description": "test spec request body.", + "in": "body", + "name": "FooBody", + "required": true, + "schema": { + "properties": { "message": { "type": "string" } }, + "type": "object" + } + } + }, + "paths": { + "/foo-path": { + "post": { + "parameters": [{ "$ref": "#/parameters/FooBody" }], + "responses": { "202": { "description": "Test spec post." } }, + "summary": "Test spec path" + } + } + }, + "schemes": ["http"], + "swagger": "2.0" } ` diff --git a/openapi2conv/testdata/swagger.json b/openapi2conv/testdata/swagger.json new file mode 120000 index 000000000..c211aa245 --- /dev/null +++ b/openapi2conv/testdata/swagger.json @@ -0,0 +1 @@ +../../openapi2/testdata/swagger.json \ No newline at end of file diff --git a/openapi3/callback.go b/openapi3/callback.go index 60196ba16..62cea72d8 100644 --- a/openapi3/callback.go +++ b/openapi3/callback.go @@ -1,13 +1,46 @@ package openapi3 -import "context" +import ( + "context" + "fmt" + "sort" -// Callback is specified by OpenAPI/Swagger standard version 3.0. + "github.com/go-openapi/jsonpointer" +) + +type Callbacks map[string]*CallbackRef + +var _ jsonpointer.JSONPointable = (*Callbacks)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (c Callbacks) JSONLookup(token string) (interface{}, error) { + ref, ok := c[token] + if ref == nil || !ok { + return nil, fmt.Errorf("object has no field %q", token) + } + + if ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + +// Callback is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#callback-object type Callback map[string]*PathItem -func (value Callback) Validate(c context.Context) error { - for _, v := range value { - if err := v.Validate(c); err != nil { +// Validate returns an error if Callback does not comply with the OpenAPI spec. +func (callback Callback) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + keys := make([]string, 0, len(callback)) + for key := range callback { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + v := callback[key] + if err := v.Validate(ctx); err != nil { return err } } diff --git a/openapi3/components.go b/openapi3/components.go index 78b66aa31..0981e8bfe 100644 --- a/openapi3/components.go +++ b/openapi3/components.go @@ -2,103 +2,241 @@ package openapi3 import ( "context" + "encoding/json" "fmt" "regexp" - - "github.com/getkin/kin-openapi/jsoninfo" + "sort" ) -// Components is specified by OpenAPI/Swagger standard version 3.0. +// Components is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#components-object type Components struct { - ExtensionProps - Schemas map[string]*SchemaRef `json:"schemas,omitempty" yaml:"schemas,omitempty"` - Parameters map[string]*ParameterRef `json:"parameters,omitempty" yaml:"parameters,omitempty"` - Headers map[string]*HeaderRef `json:"headers,omitempty" yaml:"headers,omitempty"` - RequestBodies map[string]*RequestBodyRef `json:"requestBodies,omitempty" yaml:"requestBodies,omitempty"` - Responses map[string]*ResponseRef `json:"responses,omitempty" yaml:"responses,omitempty"` - SecuritySchemes map[string]*SecuritySchemeRef `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` - Examples map[string]*ExampleRef `json:"examples,omitempty" yaml:"examples,omitempty"` - Links map[string]*LinkRef `json:"links,omitempty" yaml:"links,omitempty"` - Callbacks map[string]*CallbackRef `json:"callbacks,omitempty" yaml:"callbacks,omitempty"` + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Schemas Schemas `json:"schemas,omitempty" yaml:"schemas,omitempty"` + Parameters ParametersMap `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Headers Headers `json:"headers,omitempty" yaml:"headers,omitempty"` + RequestBodies RequestBodies `json:"requestBodies,omitempty" yaml:"requestBodies,omitempty"` + Responses Responses `json:"responses,omitempty" yaml:"responses,omitempty"` + SecuritySchemes SecuritySchemes `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` + Examples Examples `json:"examples,omitempty" yaml:"examples,omitempty"` + Links Links `json:"links,omitempty" yaml:"links,omitempty"` + Callbacks Callbacks `json:"callbacks,omitempty" yaml:"callbacks,omitempty"` } func NewComponents() Components { return Components{} } -func (components *Components) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(components) +// MarshalJSON returns the JSON encoding of Components. +func (components Components) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 9+len(components.Extensions)) + for k, v := range components.Extensions { + m[k] = v + } + if x := components.Schemas; len(x) != 0 { + m["schemas"] = x + } + if x := components.Parameters; len(x) != 0 { + m["parameters"] = x + } + if x := components.Headers; len(x) != 0 { + m["headers"] = x + } + if x := components.RequestBodies; len(x) != 0 { + m["requestBodies"] = x + } + if x := components.Responses; len(x) != 0 { + m["responses"] = x + } + if x := components.SecuritySchemes; len(x) != 0 { + m["securitySchemes"] = x + } + if x := components.Examples; len(x) != 0 { + m["examples"] = x + } + if x := components.Links; len(x) != 0 { + m["links"] = x + } + if x := components.Callbacks; len(x) != 0 { + m["callbacks"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets Components to a copy of data. func (components *Components) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, components) + type ComponentsBis Components + var x ComponentsBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "schemas") + delete(x.Extensions, "parameters") + delete(x.Extensions, "headers") + delete(x.Extensions, "requestBodies") + delete(x.Extensions, "responses") + delete(x.Extensions, "securitySchemes") + delete(x.Extensions, "examples") + delete(x.Extensions, "links") + delete(x.Extensions, "callbacks") + *components = Components(x) + return nil } -func (components *Components) Validate(c context.Context) (err error) { - for k, v := range components.Schemas { +// Validate returns an error if Components does not comply with the OpenAPI spec. +func (components *Components) Validate(ctx context.Context, opts ...ValidationOption) (err error) { + ctx = WithValidationOptions(ctx, opts...) + + schemas := make([]string, 0, len(components.Schemas)) + for name := range components.Schemas { + schemas = append(schemas, name) + } + sort.Strings(schemas) + for _, k := range schemas { + v := components.Schemas[k] if err = ValidateIdentifier(k); err != nil { - return + return fmt.Errorf("schema %q: %w", k, err) } - if err = v.Validate(c); err != nil { - return + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("schema %q: %w", k, err) } } - for k, v := range components.Parameters { + parameters := make([]string, 0, len(components.Parameters)) + for name := range components.Parameters { + parameters = append(parameters, name) + } + sort.Strings(parameters) + for _, k := range parameters { + v := components.Parameters[k] if err = ValidateIdentifier(k); err != nil { - return + return fmt.Errorf("parameter %q: %w", k, err) } - if err = v.Validate(c); err != nil { - return + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("parameter %q: %w", k, err) } } - for k, v := range components.RequestBodies { + requestBodies := make([]string, 0, len(components.RequestBodies)) + for name := range components.RequestBodies { + requestBodies = append(requestBodies, name) + } + sort.Strings(requestBodies) + for _, k := range requestBodies { + v := components.RequestBodies[k] if err = ValidateIdentifier(k); err != nil { - return + return fmt.Errorf("request body %q: %w", k, err) } - if err = v.Validate(c); err != nil { - return + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("request body %q: %w", k, err) } } - for k, v := range components.Responses { + responses := make([]string, 0, len(components.Responses)) + for name := range components.Responses { + responses = append(responses, name) + } + sort.Strings(responses) + for _, k := range responses { + v := components.Responses[k] if err = ValidateIdentifier(k); err != nil { - return + return fmt.Errorf("response %q: %w", k, err) } - if err = v.Validate(c); err != nil { - return + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("response %q: %w", k, err) } } - for k, v := range components.Headers { + headers := make([]string, 0, len(components.Headers)) + for name := range components.Headers { + headers = append(headers, name) + } + sort.Strings(headers) + for _, k := range headers { + v := components.Headers[k] if err = ValidateIdentifier(k); err != nil { - return + return fmt.Errorf("header %q: %w", k, err) } - if err = v.Validate(c); err != nil { - return + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("header %q: %w", k, err) } } - for k, v := range components.SecuritySchemes { + securitySchemes := make([]string, 0, len(components.SecuritySchemes)) + for name := range components.SecuritySchemes { + securitySchemes = append(securitySchemes, name) + } + sort.Strings(securitySchemes) + for _, k := range securitySchemes { + v := components.SecuritySchemes[k] + if err = ValidateIdentifier(k); err != nil { + return fmt.Errorf("security scheme %q: %w", k, err) + } + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("security scheme %q: %w", k, err) + } + } + + examples := make([]string, 0, len(components.Examples)) + for name := range components.Examples { + examples = append(examples, name) + } + sort.Strings(examples) + for _, k := range examples { + v := components.Examples[k] + if err = ValidateIdentifier(k); err != nil { + return fmt.Errorf("example %q: %w", k, err) + } + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("example %q: %w", k, err) + } + } + + links := make([]string, 0, len(components.Links)) + for name := range components.Links { + links = append(links, name) + } + sort.Strings(links) + for _, k := range links { + v := components.Links[k] + if err = ValidateIdentifier(k); err != nil { + return fmt.Errorf("link %q: %w", k, err) + } + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("link %q: %w", k, err) + } + } + + callbacks := make([]string, 0, len(components.Callbacks)) + for name := range components.Callbacks { + callbacks = append(callbacks, name) + } + sort.Strings(callbacks) + for _, k := range callbacks { + v := components.Callbacks[k] if err = ValidateIdentifier(k); err != nil { - return + return fmt.Errorf("callback %q: %w", k, err) } - if err = v.Validate(c); err != nil { - return + if err = v.Validate(ctx); err != nil { + return fmt.Errorf("callback %q: %w", k, err) } } - return + return validateExtensions(ctx, components.Extensions) } -const identifierPattern = `^[a-zA-Z0-9.\-_]+$` +const identifierPattern = `^[a-zA-Z0-9._-]+$` -var identifierRegExp = regexp.MustCompile(identifierPattern) +// IdentifierRegExp verifies whether Component object key matches 'identifierPattern' pattern, according to OapiAPI v3.x.0. +// Hovever, to be able supporting legacy OpenAPI v2.x, there is a need to customize above pattern in orde not to fail +// converted v2-v3 validation +var IdentifierRegExp = regexp.MustCompile(identifierPattern) func ValidateIdentifier(value string) error { - if identifierRegExp.MatchString(value) { + if IdentifierRegExp.MatchString(value) { return nil } - return fmt.Errorf("Identifier '%s' is not supported by OpenAPI version 3 standard (regexp: '%s')", value, identifierPattern) + return fmt.Errorf("identifier %q is not supported by OpenAPIv3 standard (regexp: %q)", value, identifierPattern) } diff --git a/openapi3/content.go b/openapi3/content.go index 8d187fd91..81b070eec 100644 --- a/openapi3/content.go +++ b/openapi3/content.go @@ -2,6 +2,7 @@ package openapi3 import ( "context" + "sort" "strings" ) @@ -9,7 +10,33 @@ import ( type Content map[string]*MediaType func NewContent() Content { - return make(map[string]*MediaType, 4) + return make(map[string]*MediaType) +} + +func NewContentWithSchema(schema *Schema, consumes []string) Content { + if len(consumes) == 0 { + return Content{ + "*/*": NewMediaType().WithSchema(schema), + } + } + content := make(map[string]*MediaType, len(consumes)) + for _, mediaType := range consumes { + content[mediaType] = NewMediaType().WithSchema(schema) + } + return content +} + +func NewContentWithSchemaRef(schema *SchemaRef, consumes []string) Content { + if len(consumes) == 0 { + return Content{ + "*/*": NewMediaType().WithSchemaRef(schema), + } + } + content := make(map[string]*MediaType, len(consumes)) + for _, mediaType := range consumes { + content[mediaType] = NewMediaType().WithSchemaRef(schema) + } + return content } func NewContentWithJSONSchema(schema *Schema) Content { @@ -23,6 +50,18 @@ func NewContentWithJSONSchemaRef(schema *SchemaRef) Content { } } +func NewContentWithFormDataSchema(schema *Schema) Content { + return Content{ + "multipart/form-data": NewMediaType().WithSchema(schema), + } +} + +func NewContentWithFormDataSchemaRef(schema *SchemaRef) Content { + return Content{ + "multipart/form-data": NewMediaType().WithSchemaRef(schema), + } +} + func (content Content) Get(mime string) *MediaType { // If the mime is empty then short-circuit to the wildcard. // We do this here so that we catch only the specific case of @@ -66,10 +105,18 @@ func (content Content) Get(mime string) *MediaType { return content["*/*"] } -func (content Content) Validate(c context.Context) error { - for _, v := range content { - // Validate MediaType - if err := v.Validate(c); err != nil { +// Validate returns an error if Content does not comply with the OpenAPI spec. +func (content Content) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + keys := make([]string, 0, len(content)) + for key := range content { + keys = append(keys, key) + } + sort.Strings(keys) + for _, k := range keys { + v := content[k] + if err := v.Validate(ctx); err != nil { return err } } diff --git a/openapi3/discriminator.go b/openapi3/discriminator.go index de518d578..8b6b813f2 100644 --- a/openapi3/discriminator.go +++ b/openapi3/discriminator.go @@ -2,25 +2,48 @@ package openapi3 import ( "context" - - "github.com/getkin/kin-openapi/jsoninfo" + "encoding/json" ) -// Discriminator is specified by OpenAPI/Swagger standard version 3.0. +// Discriminator is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object type Discriminator struct { - ExtensionProps - PropertyName string `json:"propertyName" yaml:"propertyName"` + Extensions map[string]interface{} `json:"-" yaml:"-"` + + PropertyName string `json:"propertyName" yaml:"propertyName"` // required Mapping map[string]string `json:"mapping,omitempty" yaml:"mapping,omitempty"` } -func (value *Discriminator) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(value) +// MarshalJSON returns the JSON encoding of Discriminator. +func (discriminator Discriminator) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 2+len(discriminator.Extensions)) + for k, v := range discriminator.Extensions { + m[k] = v + } + m["propertyName"] = discriminator.PropertyName + if x := discriminator.Mapping; len(x) != 0 { + m["mapping"] = x + } + return json.Marshal(m) } -func (value *Discriminator) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, value) +// UnmarshalJSON sets Discriminator to a copy of data. +func (discriminator *Discriminator) UnmarshalJSON(data []byte) error { + type DiscriminatorBis Discriminator + var x DiscriminatorBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "propertyName") + delete(x.Extensions, "mapping") + *discriminator = Discriminator(x) + return nil } -func (value *Discriminator) Validate(c context.Context) error { - return nil +// Validate returns an error if Discriminator does not comply with the OpenAPI spec. +func (discriminator *Discriminator) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + return validateExtensions(ctx, discriminator.Extensions) } diff --git a/openapi3/discriminator_test.go b/openapi3/discriminator_test.go index c12227141..7c16992cf 100644 --- a/openapi3/discriminator_test.go +++ b/openapi3/discriminator_test.go @@ -1,15 +1,24 @@ -package openapi3_test +package openapi3 import ( "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) -var jsonSpecWithDiscriminator = []byte(` +func TestParsingDiscriminator(t *testing.T) { + const spec = ` { "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "title", + "description": "desc", + "contact": { + "email": "email" + } + }, + "paths": {}, "components": { "schemas": { "MyResponseType": { @@ -34,10 +43,14 @@ var jsonSpecWithDiscriminator = []byte(` } } } -`) +` -func TestParsingDiscriminator(t *testing.T) { - loader, err := openapi3.NewSwaggerLoader().LoadSwaggerFromData(jsonSpecWithDiscriminator) + loader := NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) require.NoError(t, err) - require.Equal(t, 2, len(loader.Components.Schemas["MyResponseType"].Value.OneOf)) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + require.Equal(t, 2, len(doc.Components.Schemas["MyResponseType"].Value.Discriminator.Mapping)) } diff --git a/openapi3/doc.go b/openapi3/doc.go index 9f9554962..41c9965c6 100644 --- a/openapi3/doc.go +++ b/openapi3/doc.go @@ -1,5 +1,4 @@ -// Package openapi3 parses and writes OpenAPI 3 specifications. +// Package openapi3 parses and writes OpenAPI 3 specification documents. // -// The OpenAPI 3.0 specification can be found at: -// https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.md +// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md package openapi3 diff --git a/openapi3/encoding.go b/openapi3/encoding.go index a60bddf82..dc2e54438 100644 --- a/openapi3/encoding.go +++ b/openapi3/encoding.go @@ -2,20 +2,21 @@ package openapi3 import ( "context" + "encoding/json" "fmt" - - "github.com/getkin/kin-openapi/jsoninfo" + "sort" ) // Encoding is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#encoding-object type Encoding struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` - ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` - Headers map[string]*HeaderRef `json:"headers,omitempty" yaml:"headers,omitempty"` - Style string `json:"style,omitempty" yaml:"style,omitempty"` - Explode *bool `json:"explode,omitempty" yaml:"explode,omitempty"` - AllowReserved bool `json:"allowReserved,omitempty" yaml:"allowReserved,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + Headers Headers `json:"headers,omitempty" yaml:"headers,omitempty"` + Style string `json:"style,omitempty" yaml:"style,omitempty"` + Explode *bool `json:"explode,omitempty" yaml:"explode,omitempty"` + AllowReserved bool `json:"allowReserved,omitempty" yaml:"allowReserved,omitempty"` } func NewEncoding() *Encoding { @@ -38,12 +39,45 @@ func (encoding *Encoding) WithHeaderRef(name string, ref *HeaderRef) *Encoding { return encoding } -func (encoding *Encoding) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(encoding) +// MarshalJSON returns the JSON encoding of Encoding. +func (encoding Encoding) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 5+len(encoding.Extensions)) + for k, v := range encoding.Extensions { + m[k] = v + } + if x := encoding.ContentType; x != "" { + m["contentType"] = x + } + if x := encoding.Headers; len(x) != 0 { + m["headers"] = x + } + if x := encoding.Style; x != "" { + m["style"] = x + } + if x := encoding.Explode; x != nil { + m["explode"] = x + } + if x := encoding.AllowReserved; x { + m["allowReserved"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets Encoding to a copy of data. func (encoding *Encoding) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, encoding) + type EncodingBis Encoding + var x EncodingBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "contentType") + delete(x.Extensions, "headers") + delete(x.Extensions, "style") + delete(x.Extensions, "explode") + delete(x.Extensions, "allowReserved") + *encoding = Encoding(x) + return nil } // SerializationMethod returns a serialization method of request body. @@ -61,15 +95,25 @@ func (encoding *Encoding) SerializationMethod() *SerializationMethod { return sm } -func (encoding *Encoding) Validate(c context.Context) error { +// Validate returns an error if Encoding does not comply with the OpenAPI spec. +func (encoding *Encoding) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if encoding == nil { return nil } - for k, v := range encoding.Headers { + + headers := make([]string, 0, len(encoding.Headers)) + for k := range encoding.Headers { + headers = append(headers, k) + } + sort.Strings(headers) + for _, k := range headers { + v := encoding.Headers[k] if err := ValidateIdentifier(k); err != nil { return nil } - if err := v.Validate(c); err != nil { + if err := v.Validate(ctx); err != nil { return nil } } @@ -84,10 +128,9 @@ func (encoding *Encoding) Validate(c context.Context) error { sm.Style == SerializationPipeDelimited && sm.Explode, sm.Style == SerializationPipeDelimited && !sm.Explode, sm.Style == SerializationDeepObject && sm.Explode: - // it is a valid default: - return fmt.Errorf("Serialization method with style=%q and explode=%v is not supported by media type", sm.Style, sm.Explode) + return fmt.Errorf("serialization method with style=%q and explode=%v is not supported by media type", sm.Style, sm.Explode) } - return nil + return validateExtensions(ctx, encoding.Extensions) } diff --git a/openapi3/encoding_test.go b/openapi3/encoding_test.go index 67e7b6b65..5c354540d 100644 --- a/openapi3/encoding_test.go +++ b/openapi3/encoding_test.go @@ -1,4 +1,4 @@ -package openapi3_test +package openapi3 import ( "context" @@ -6,7 +6,6 @@ import ( "reflect" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) @@ -17,13 +16,13 @@ func TestEncodingJSON(t *testing.T) { require.NotEmpty(t, data) t.Log("Unmarshal *openapi3.Encoding from JSON") - docA := &openapi3.Encoding{} + docA := &Encoding{} err = json.Unmarshal(encodingJSON, &docA) require.NoError(t, err) require.NotEmpty(t, data) t.Log("Validate *openapi3.Encoding") - err = docA.Validate(context.TODO()) + err = docA.Validate(context.Background()) require.NoError(t, err) t.Log("Ensure representations match") @@ -45,13 +44,13 @@ var encodingJSON = []byte(` } `) -func encoding() *openapi3.Encoding { +func encoding() *Encoding { explode := true - return &openapi3.Encoding{ + return &Encoding{ ContentType: "application/json", - Headers: map[string]*openapi3.HeaderRef{ + Headers: map[string]*HeaderRef{ "someHeader": { - Value: &openapi3.Header{}, + Value: &Header{}, }, }, Style: "form", @@ -64,32 +63,32 @@ func TestEncodingSerializationMethod(t *testing.T) { boolPtr := func(b bool) *bool { return &b } testCases := []struct { name string - enc *openapi3.Encoding - want *openapi3.SerializationMethod + enc *Encoding + want *SerializationMethod }{ { name: "default", - want: &openapi3.SerializationMethod{Style: openapi3.SerializationForm, Explode: true}, + want: &SerializationMethod{Style: SerializationForm, Explode: true}, }, { name: "encoding with style", - enc: &openapi3.Encoding{Style: openapi3.SerializationSpaceDelimited}, - want: &openapi3.SerializationMethod{Style: openapi3.SerializationSpaceDelimited, Explode: true}, + enc: &Encoding{Style: SerializationSpaceDelimited}, + want: &SerializationMethod{Style: SerializationSpaceDelimited, Explode: true}, }, { name: "encoding with explode", - enc: &openapi3.Encoding{Explode: boolPtr(true)}, - want: &openapi3.SerializationMethod{Style: openapi3.SerializationForm, Explode: true}, + enc: &Encoding{Explode: boolPtr(true)}, + want: &SerializationMethod{Style: SerializationForm, Explode: true}, }, { name: "encoding with no explode", - enc: &openapi3.Encoding{Explode: boolPtr(false)}, - want: &openapi3.SerializationMethod{Style: openapi3.SerializationForm, Explode: false}, + enc: &Encoding{Explode: boolPtr(false)}, + want: &SerializationMethod{Style: SerializationForm, Explode: false}, }, { name: "encoding with style and explode ", - enc: &openapi3.Encoding{Style: openapi3.SerializationSpaceDelimited, Explode: boolPtr(false)}, - want: &openapi3.SerializationMethod{Style: openapi3.SerializationSpaceDelimited, Explode: false}, + enc: &Encoding{Style: SerializationSpaceDelimited, Explode: boolPtr(false)}, + want: &SerializationMethod{Style: SerializationSpaceDelimited, Explode: false}, }, } for _, tc := range testCases { diff --git a/openapi3/errors.go b/openapi3/errors.go new file mode 100644 index 000000000..74baab9a5 --- /dev/null +++ b/openapi3/errors.go @@ -0,0 +1,59 @@ +package openapi3 + +import ( + "bytes" + "errors" +) + +// MultiError is a collection of errors, intended for when +// multiple issues need to be reported upstream +type MultiError []error + +func (me MultiError) Error() string { + return spliceErr(" | ", me) +} + +func spliceErr(sep string, errs []error) string { + buff := &bytes.Buffer{} + for i, e := range errs { + buff.WriteString(e.Error()) + if i != len(errs)-1 { + buff.WriteString(sep) + } + } + return buff.String() +} + +// Is allows you to determine if a generic error is in fact a MultiError using `errors.Is()` +// It will also return true if any of the contained errors match target +func (me MultiError) Is(target error) bool { + if _, ok := target.(MultiError); ok { + return true + } + for _, e := range me { + if errors.Is(e, target) { + return true + } + } + return false +} + +// As allows you to use `errors.As()` to set target to the first error within the multi error that matches the target type +func (me MultiError) As(target interface{}) bool { + for _, e := range me { + if errors.As(e, target) { + return true + } + } + return false +} + +type multiErrorForOneOf MultiError + +func (meo multiErrorForOneOf) Error() string { + return spliceErr(" Or ", meo) +} + +func (meo multiErrorForOneOf) Unwrap() error { + return MultiError(meo) +} diff --git a/openapi3/example.go b/openapi3/example.go new file mode 100644 index 000000000..04338beee --- /dev/null +++ b/openapi3/example.go @@ -0,0 +1,93 @@ +package openapi3 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/go-openapi/jsonpointer" +) + +type Examples map[string]*ExampleRef + +var _ jsonpointer.JSONPointable = (*Examples)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (e Examples) JSONLookup(token string) (interface{}, error) { + ref, ok := e[token] + if ref == nil || !ok { + return nil, fmt.Errorf("object has no field %q", token) + } + + if ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + +// Example is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#example-object +type Example struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Value interface{} `json:"value,omitempty" yaml:"value,omitempty"` + ExternalValue string `json:"externalValue,omitempty" yaml:"externalValue,omitempty"` +} + +func NewExample(value interface{}) *Example { + return &Example{Value: value} +} + +// MarshalJSON returns the JSON encoding of Example. +func (example Example) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 4+len(example.Extensions)) + for k, v := range example.Extensions { + m[k] = v + } + if x := example.Summary; x != "" { + m["summary"] = x + } + if x := example.Description; x != "" { + m["description"] = x + } + if x := example.Value; x != nil { + m["value"] = x + } + if x := example.ExternalValue; x != "" { + m["externalValue"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets Example to a copy of data. +func (example *Example) UnmarshalJSON(data []byte) error { + type ExampleBis Example + var x ExampleBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "summary") + delete(x.Extensions, "description") + delete(x.Extensions, "value") + delete(x.Extensions, "externalValue") + *example = Example(x) + return nil +} + +// Validate returns an error if Example does not comply with the OpenAPI spec. +func (example *Example) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if example.Value != nil && example.ExternalValue != "" { + return errors.New("value and externalValue are mutually exclusive") + } + if example.Value == nil && example.ExternalValue == "" { + return errors.New("no value or externalValue field") + } + + return validateExtensions(ctx, example.Extensions) +} diff --git a/openapi3/example_test.go b/openapi3/example_test.go index a5dfb3008..4e9296ac0 100644 --- a/openapi3/example_test.go +++ b/openapi3/example_test.go @@ -1,10 +1,9 @@ -package openapi3_test +package openapi3 import ( "encoding/json" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) @@ -15,7 +14,7 @@ func TestExampleJSON(t *testing.T) { require.NotEmpty(t, data) t.Log("Unmarshal *openapi3.Example from JSON") - docA := &openapi3.Example{} + docA := &Example{} err = json.Unmarshal(exampleJSON, &docA) require.NoError(t, err) require.NotEmpty(t, data) @@ -40,7 +39,7 @@ var exampleJSON = []byte(` } `) -func example() *openapi3.Example { +func example() *Example { value := map[string]string{ "name": "Fluffy", "petType": "Cat", @@ -48,7 +47,7 @@ func example() *openapi3.Example { "gender": "male", "breed": "Persian", } - return &openapi3.Example{ + return &Example{ Summary: "An example of a cat", Value: value, } diff --git a/openapi3/example_validation.go b/openapi3/example_validation.go new file mode 100644 index 000000000..fb7a1da16 --- /dev/null +++ b/openapi3/example_validation.go @@ -0,0 +1,16 @@ +package openapi3 + +import "context" + +func validateExampleValue(ctx context.Context, input interface{}, schema *Schema) error { + opts := make([]SchemaValidationOption, 0, 2) + + if vo := getValidationOptions(ctx); vo.examplesValidationAsReq { + opts = append(opts, VisitAsRequest()) + } else if vo.examplesValidationAsRes { + opts = append(opts, VisitAsResponse()) + } + opts = append(opts, MultiErrors()) + + return schema.VisitJSON(input, opts...) +} diff --git a/openapi3/example_validation_test.go b/openapi3/example_validation_test.go new file mode 100644 index 000000000..de8954828 --- /dev/null +++ b/openapi3/example_validation_test.go @@ -0,0 +1,527 @@ +package openapi3 + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExamplesSchemaValidation(t *testing.T) { + type testCase struct { + name string + requestSchemaExample string + responseSchemaExample string + mediaTypeRequestExample string + mediaTypeResponseExample string + readWriteOnlyMediaTypeRequestExample string + readWriteOnlyMediaTypeResponseExample string + parametersExample string + componentExamples string + errContains string + } + + testCases := []testCase{ + { + name: "invalid_parameter_examples", + parametersExample: ` + examples: + param1example: + value: abcd + `, + errContains: `invalid paths: invalid path /user: invalid operation POST: param1example`, + }, + { + name: "valid_parameter_examples", + parametersExample: ` + examples: + param1example: + value: 1 + `, + }, + { + name: "invalid_parameter_example", + parametersExample: ` + example: abcd + `, + errContains: `invalid path /user: invalid operation POST: invalid example`, + }, + { + name: "valid_parameter_example", + parametersExample: ` + example: 1 + `, + }, + { + name: "invalid_component_examples", + mediaTypeRequestExample: ` + examples: + BadUser: + $ref: '#/components/examples/BadUser' + `, + componentExamples: ` + examples: + BadUser: + value: + username: "]bad[" + email: bad + password: short + `, + errContains: `invalid paths: invalid path /user: invalid operation POST: example BadUser`, + }, + { + name: "valid_component_examples", + mediaTypeRequestExample: ` + examples: + BadUser: + $ref: '#/components/examples/BadUser' + `, + componentExamples: ` + examples: + BadUser: + value: + username: good + email: good@mail.com + password: password + `, + }, + { + name: "invalid_mediatype_examples", + mediaTypeRequestExample: ` + example: + username: "]bad[" + email: bad + password: short + `, + errContains: `invalid path /user: invalid operation POST: invalid example`, + }, + { + name: "valid_mediatype_examples", + mediaTypeRequestExample: ` + example: + username: good + email: good@mail.com + password: password + `, + }, + { + name: "invalid_schema_request_example", + requestSchemaExample: ` + example: + username: good + email: good@email.com + # missing password + `, + errContains: `schema "CreateUserRequest": invalid example`, + }, + { + name: "valid_schema_request_example", + requestSchemaExample: ` + example: + username: good + email: good@email.com + password: password + `, + }, + { + name: "invalid_schema_response_example", + responseSchemaExample: ` + example: + user_id: 1 + # missing access_token + `, + errContains: `schema "CreateUserResponse": invalid example`, + }, + { + name: "valid_schema_response_example", + responseSchemaExample: ` + example: + user_id: 1 + access_token: "abcd" + `, + }, + { + name: "valid_readonly_writeonly_examples", + readWriteOnlyMediaTypeRequestExample: ` + examples: + ReadWriteOnlyRequest: + $ref: '#/components/examples/ReadWriteOnlyRequestData' +`, + readWriteOnlyMediaTypeResponseExample: ` + examples: + ReadWriteOnlyResponse: + $ref: '#/components/examples/ReadWriteOnlyResponseData' +`, + componentExamples: ` + examples: + ReadWriteOnlyRequestData: + value: + username: user + password: password + ReadWriteOnlyResponseData: + value: + user_id: 4321 + `, + }, + { + name: "invalid_readonly_request_examples", + readWriteOnlyMediaTypeRequestExample: ` + examples: + ReadWriteOnlyRequest: + $ref: '#/components/examples/ReadWriteOnlyRequestData' +`, + componentExamples: ` + examples: + ReadWriteOnlyRequestData: + value: + username: user + password: password + user_id: 4321 +`, + errContains: `ReadWriteOnlyRequest: readOnly property "user_id" in request`, + }, + { + name: "invalid_writeonly_response_examples", + readWriteOnlyMediaTypeResponseExample: ` + examples: + ReadWriteOnlyResponse: + $ref: '#/components/examples/ReadWriteOnlyResponseData' +`, + componentExamples: ` + examples: + ReadWriteOnlyResponseData: + value: + password: password + user_id: 4321 +`, + + errContains: `ReadWriteOnlyResponse: writeOnly property "password" in response`, + }, + } + + testOptions := []struct { + name string + disableExamplesValidation bool + }{ + { + name: "examples_validation_disabled", + disableExamplesValidation: true, + }, + { + name: "examples_validation_enabled", + disableExamplesValidation: false, + }, + } + + t.Parallel() + + for _, testOption := range testOptions { + testOption := testOption + t.Run(testOption.name, func(t *testing.T) { + t.Parallel() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + spec := bytes.Buffer{} + spec.WriteString(` +openapi: 3.0.3 +info: + title: An API + version: 1.2.3.4 +paths: + /user: + post: + description: User creation. + operationId: createUser + parameters: + - name: param1 + in: 'query' + schema: + format: int64 + type: integer`) + spec.WriteString(tc.parametersExample) + spec.WriteString(` + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserRequest" +`) + spec.WriteString(tc.mediaTypeRequestExample) + spec.WriteString(` + description: Created user object + responses: + '204': + description: "success" + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserResponse"`) + spec.WriteString(tc.mediaTypeResponseExample) + spec.WriteString(` + /readWriteOnly: + post: + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReadWriteOnlyData" +`) + spec.WriteString(tc.readWriteOnlyMediaTypeRequestExample) + spec.WriteString(` + responses: + '201': + description: a response + content: + application/json: + schema: + $ref: "#/components/schemas/ReadWriteOnlyData"`) + spec.WriteString(tc.readWriteOnlyMediaTypeResponseExample) + spec.WriteString(` +components: + schemas: + CreateUserRequest:`) + spec.WriteString(tc.requestSchemaExample) + spec.WriteString(` + required: + - username + - email + - password + properties: + username: + type: string + pattern: "^[ a-zA-Z0-9_-]+$" + minLength: 3 + email: + type: string + pattern: "^[A-Za-z0-9+_.-]+@(.+)$" + password: + type: string + minLength: 7 + type: object + CreateUserResponse:`) + spec.WriteString(tc.responseSchemaExample) + spec.WriteString(` + required: + - access_token + - user_id + properties: + access_token: + type: string + user_id: + format: int64 + type: integer + type: object + ReadWriteOnlyData: + required: + # only required in request + - username + - password + # only required in response + - user_id + properties: + username: + type: string + default: default + writeOnly: true # only sent in a request + password: + type: string + default: default + writeOnly: true # only sent in a request + user_id: + format: int64 + default: 1 + type: integer + readOnly: true # only returned in a response + type: object +`) + spec.WriteString(tc.componentExamples) + + loader := NewLoader() + doc, err := loader.LoadFromData(spec.Bytes()) + require.NoError(t, err) + + if testOption.disableExamplesValidation { + err = doc.Validate(loader.Context, DisableExamplesValidation()) + } else { + err = doc.Validate(loader.Context, EnableExamplesValidation()) + } + + if tc.errContains != "" && !testOption.disableExamplesValidation { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errContains) + } else { + require.NoError(t, err) + } + }) + } + }) + } +} + +func TestExampleObjectValidation(t *testing.T) { + type testCase struct { + name string + mediaTypeRequestExample string + componentExamples string + errContains string + } + + testCases := []testCase{ + { + name: "example_examples_mutually_exclusive", + mediaTypeRequestExample: ` + examples: + BadUser: + $ref: '#/components/examples/BadUser' + example: + username: good + email: real@email.com + password: validpassword +`, + errContains: `invalid path /user: invalid operation POST: example and examples are mutually exclusive`, + componentExamples: ` + examples: + BadUser: + value: + username: "]bad[" + email: bad + password: short +`, + }, + { + name: "example_without_value", + componentExamples: ` + examples: + BadUser: + description: empty user example +`, + errContains: `invalid components: example "BadUser": no value or externalValue field`, + }, + { + name: "value_externalValue_mutual_exclusion", + componentExamples: ` + examples: + BadUser: + value: + username: good + email: real@email.com + password: validpassword + externalValue: 'http://example.com/examples/example' +`, + errContains: `invalid components: example "BadUser": value and externalValue are mutually exclusive`, + }, + } + + testOptions := []struct { + name string + disableExamplesValidation bool + }{ + { + name: "examples_validation_disabled", + disableExamplesValidation: true, + }, + { + name: "examples_validation_enabled", + disableExamplesValidation: false, + }, + } + + t.Parallel() + + for _, testOption := range testOptions { + testOption := testOption + t.Run(testOption.name, func(t *testing.T) { + t.Parallel() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + spec := bytes.Buffer{} + spec.WriteString(` +openapi: 3.0.3 +info: + title: An API + version: 1.2.3.4 +paths: + /user: + post: + description: User creation. + operationId: createUser + parameters: + - name: param1 + in: 'query' + schema: + format: int64 + type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserRequest" +`) + spec.WriteString(tc.mediaTypeRequestExample) + spec.WriteString(` + description: Created user object + required: true + responses: + '204': + description: "success" + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserResponse" +components: + schemas: + CreateUserRequest: + required: + - username + - email + - password + properties: + username: + type: string + pattern: "^[ a-zA-Z0-9_-]+$" + minLength: 3 + email: + type: string + pattern: "^[A-Za-z0-9+_.-]+@(.+)$" + password: + type: string + minLength: 7 + type: object + CreateUserResponse: + description: represents the response to a User creation + required: + - access_token + - user_id + properties: + access_token: + type: string + user_id: + format: int64 + type: integer + type: object +`) + spec.WriteString(tc.componentExamples) + + loader := NewLoader() + doc, err := loader.LoadFromData(spec.Bytes()) + require.NoError(t, err) + + if testOption.disableExamplesValidation { + err = doc.Validate(loader.Context, DisableExamplesValidation()) + } else { + err = doc.Validate(loader.Context) + } + + if tc.errContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errContains) + } else { + require.NoError(t, err) + } + }) + } + }) + } +} diff --git a/openapi3/examples.go b/openapi3/examples.go deleted file mode 100644 index d89263ebc..000000000 --- a/openapi3/examples.go +++ /dev/null @@ -1,29 +0,0 @@ -package openapi3 - -import ( - "github.com/getkin/kin-openapi/jsoninfo" -) - -// Example is specified by OpenAPI/Swagger 3.0 standard. -type Example struct { - ExtensionProps - - Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Value interface{} `json:"value,omitempty" yaml:"value,omitempty"` - ExternalValue string `json:"externalValue,omitempty" yaml:"externalValue,omitempty"` -} - -func NewExample(value interface{}) *Example { - return &Example{ - Value: value, - } -} - -func (example *Example) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(example) -} - -func (example *Example) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, example) -} diff --git a/openapi3/extension.go b/openapi3/extension.go index f6b7ef9bb..c29959091 100644 --- a/openapi3/extension.go +++ b/openapi3/extension.go @@ -1,38 +1,24 @@ package openapi3 import ( - "github.com/getkin/kin-openapi/jsoninfo" + "context" + "fmt" + "sort" + "strings" ) -// ExtensionProps provides support for OpenAPI extensions. -// It reads/writes all properties that begin with "x-". -type ExtensionProps struct { - Extensions map[string]interface{} `json:"-" yaml:"-"` -} - -// Assert that the type implements the interface -var _ jsoninfo.StrictStruct = &ExtensionProps{} - -// EncodeWith will be invoked by package "jsoninfo" -func (props *ExtensionProps) EncodeWith(encoder *jsoninfo.ObjectEncoder, value interface{}) error { - for k, v := range props.Extensions { - if err := encoder.EncodeExtension(k, v); err != nil { - return err +func validateExtensions(ctx context.Context, extensions map[string]interface{}) error { // FIXME: newtype + Validate(...) + var unknowns []string + for k := range extensions { + if !strings.HasPrefix(k, "x-") { + unknowns = append(unknowns, k) } } - return encoder.EncodeStructFieldsAndExtensions(value) -} -// DecodeWith will be invoked by package "jsoninfo" -func (props *ExtensionProps) DecodeWith(decoder *jsoninfo.ObjectDecoder, value interface{}) error { - if err := decoder.DecodeStructFieldsAndExtensions(value); err != nil { - return err - } - source := decoder.DecodeExtensionMap() - result := make(map[string]interface{}, len(source)) - for k, v := range source { - result[k] = v + if len(unknowns) != 0 { + sort.Strings(unknowns) + return fmt.Errorf("extra sibling fields: %+v", unknowns) } - props.Extensions = result + return nil } diff --git a/openapi3/extension_test.go b/openapi3/extension_test.go deleted file mode 100644 index 775d8b6bc..000000000 --- a/openapi3/extension_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package openapi3_test - -import ( - "github.com/getkin/kin-openapi/jsoninfo" - "github.com/getkin/kin-openapi/openapi3" - "github.com/stretchr/testify/assert" - "testing" -) - -func TestExtensionProps_EncodeWith(t *testing.T) { - t.Run("successfully encoded", func(t *testing.T) { - encoder := jsoninfo.NewObjectEncoder() - var extensionProps = openapi3.ExtensionProps{ - Extensions: map[string]interface{}{ - "field1": "value1", - }, - } - - var value = struct { - Field1 string `json:"field1"` - Field2 string `json:"field2"` - }{} - - err := extensionProps.EncodeWith(encoder, &value) - assert.Nil(t, err) - }) -} - -func TestExtensionProps_DecodeWith(t *testing.T) { - data := []byte(` - { - "field1": "value1", - "field2": "value2" - } -`) - t.Run("successfully decode all the fields", func(t *testing.T) { - decoder, err := jsoninfo.NewObjectDecoder(data) - assert.Nil(t, err) - var extensionProps = &openapi3.ExtensionProps{ - Extensions: map[string]interface{}{ - "field1": "value1", - "field2": "value1", - }, - } - - var value = struct { - Field1 string `json:"field1"` - Field2 string `json:"field2"` - }{} - - err = extensionProps.DecodeWith(decoder, &value) - assert.Nil(t, err) - assert.Equal(t, 0, len(extensionProps.Extensions)) - assert.Equal(t, "value1", value.Field1) - assert.Equal(t, "value2", value.Field2) - }) - - t.Run("successfully decode some of the fields", func(t *testing.T) { - decoder, err := jsoninfo.NewObjectDecoder(data) - assert.Nil(t, err) - var extensionProps = &openapi3.ExtensionProps{ - Extensions: map[string]interface{}{ - "field1": "value1", - "field2": "value2", - }, - } - - var value = &struct { - Field1 string `json:"field1"` - }{} - - err = extensionProps.DecodeWith(decoder, value) - assert.Nil(t, err) - assert.Equal(t, 1, len(extensionProps.Extensions)) - assert.Equal(t, "value1", value.Field1) - }) - - t.Run("successfully decode none of the fields", func(t *testing.T) { - decoder, err := jsoninfo.NewObjectDecoder(data) - assert.Nil(t, err) - - var extensionProps = &openapi3.ExtensionProps{ - Extensions: map[string]interface{}{ - "field1": "value1", - "field2": "value2", - }, - } - - var value = struct { - Field3 string `json:"field3"` - Field4 string `json:"field4"` - }{} - - err = extensionProps.DecodeWith(decoder, &value) - assert.Nil(t, err) - assert.Equal(t, 2, len(extensionProps.Extensions)) - assert.Empty(t, value.Field3) - assert.Empty(t, value.Field4) - }) -} diff --git a/openapi3/external_docs.go b/openapi3/external_docs.go index 5a1476bde..276a36cce 100644 --- a/openapi3/external_docs.go +++ b/openapi3/external_docs.go @@ -1,21 +1,61 @@ package openapi3 import ( - "github.com/getkin/kin-openapi/jsoninfo" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" ) -// ExternalDocs is specified by OpenAPI/Swagger standard version 3.0. +// ExternalDocs is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#external-documentation-object type ExternalDocs struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` - Description string `json:"description,omitempty"` - URL string `json:"url,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` } -func (e *ExternalDocs) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(e) +// MarshalJSON returns the JSON encoding of ExternalDocs. +func (e ExternalDocs) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 2+len(e.Extensions)) + for k, v := range e.Extensions { + m[k] = v + } + if x := e.Description; x != "" { + m["description"] = x + } + if x := e.URL; x != "" { + m["url"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets ExternalDocs to a copy of data. func (e *ExternalDocs) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, e) + type ExternalDocsBis ExternalDocs + var x ExternalDocsBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "description") + delete(x.Extensions, "url") + *e = ExternalDocs(x) + return nil +} + +// Validate returns an error if ExternalDocs does not comply with the OpenAPI spec. +func (e *ExternalDocs) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if e.URL == "" { + return errors.New("url is required") + } + if _, err := url.Parse(e.URL); err != nil { + return fmt.Errorf("url is incorrect: %w", err) + } + + return validateExtensions(ctx, e.Extensions) } diff --git a/openapi3/external_docs_test.go b/openapi3/external_docs_test.go new file mode 100644 index 000000000..f2fb64f2e --- /dev/null +++ b/openapi3/external_docs_test.go @@ -0,0 +1,42 @@ +package openapi3 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExternalDocs_Validate(t *testing.T) { + tests := []struct { + name string + extDocs *ExternalDocs + expectedErr string + }{ + { + name: "url is missing", + extDocs: &ExternalDocs{}, + expectedErr: "url is required", + }, + { + name: "url is incorrect", + extDocs: &ExternalDocs{URL: "ht tps://example.com"}, + expectedErr: `url is incorrect: parse "ht tps://example.com": first path segment in URL cannot contain colon`, + }, + { + name: "ok", + extDocs: &ExternalDocs{URL: "https://example.com"}, + }, + } + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + err := tt.extDocs.Validate(context.Background()) + if tt.expectedErr != "" { + require.EqualError(t, err, tt.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/openapi3/header.go b/openapi3/header.go index 310ef9f92..8bce69f2e 100644 --- a/openapi3/header.go +++ b/openapi3/header.go @@ -2,31 +2,101 @@ package openapi3 import ( "context" + "errors" + "fmt" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" ) +type Headers map[string]*HeaderRef + +var _ jsonpointer.JSONPointable = (*Headers)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (h Headers) JSONLookup(token string) (interface{}, error) { + ref, ok := h[token] + if ref == nil || !ok { + return nil, fmt.Errorf("object has no field %q", token) + } + + if ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + +// Header is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#header-object type Header struct { - ExtensionProps - - // Optional description. Should use CommonMark syntax. - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` - Required bool `json:"required,omitempty" yaml:"required,omitempty"` - Schema *SchemaRef `json:"schema,omitempty" yaml:"schema,omitempty"` - Example interface{} `json:"example,omitempty" yaml:"example,omitempty"` - Examples map[string]*ExampleRef `json:"examples,omitempty" yaml:"examples,omitempty"` - Content Content `json:"content,omitempty" yaml:"content,omitempty"` + Parameter } -func (value *Header) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, value) +var _ jsonpointer.JSONPointable = (*Header)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (header Header) JSONLookup(token string) (interface{}, error) { + return header.Parameter.JSONLookup(token) +} + +// MarshalJSON returns the JSON encoding of Header. +func (header Header) MarshalJSON() ([]byte, error) { + return header.Parameter.MarshalJSON() } -func (value *Header) Validate(c context.Context) error { - if v := value.Schema; v != nil { - if err := v.Validate(c); err != nil { - return err +// UnmarshalJSON sets Header to a copy of data. +func (header *Header) UnmarshalJSON(data []byte) error { + return header.Parameter.UnmarshalJSON(data) +} + +// SerializationMethod returns a header's serialization method. +func (header *Header) SerializationMethod() (*SerializationMethod, error) { + style := header.Style + if style == "" { + style = SerializationSimple + } + explode := false + if header.Explode != nil { + explode = *header.Explode + } + return &SerializationMethod{Style: style, Explode: explode}, nil +} + +// Validate returns an error if Header does not comply with the OpenAPI spec. +func (header *Header) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if header.Name != "" { + return errors.New("header 'name' MUST NOT be specified, it is given in the corresponding headers map") + } + if header.In != "" { + return errors.New("header 'in' MUST NOT be specified, it is implicitly in header") + } + + // Validate a parameter's serialization method. + sm, err := header.SerializationMethod() + if err != nil { + return err + } + if smSupported := false || + sm.Style == SerializationSimple && !sm.Explode || + sm.Style == SerializationSimple && sm.Explode; !smSupported { + e := fmt.Errorf("serialization method with style=%q and explode=%v is not supported by a header parameter", sm.Style, sm.Explode) + return fmt.Errorf("header schema is invalid: %w", e) + } + + if (header.Schema == nil) == (header.Content == nil) { + e := fmt.Errorf("parameter must contain exactly one of content and schema: %v", header) + return fmt.Errorf("header schema is invalid: %w", e) + } + if schema := header.Schema; schema != nil { + if err := schema.Validate(ctx); err != nil { + return fmt.Errorf("header schema is invalid: %w", err) + } + } + + if content := header.Content; content != nil { + if err := content.Validate(ctx); err != nil { + return fmt.Errorf("header content is invalid: %w", err) } } return nil diff --git a/openapi3/info.go b/openapi3/info.go index 59e03cc13..381047fca 100644 --- a/openapi3/info.go +++ b/openapi3/info.go @@ -2,15 +2,15 @@ package openapi3 import ( "context" + "encoding/json" "errors" - "fmt" - - "github.com/getkin/kin-openapi/jsoninfo" ) -// Info is specified by OpenAPI/Swagger standard version 3.0. +// Info is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#info-object type Info struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` + Title string `json:"title" yaml:"title"` // Required Description string `json:"description,omitempty" yaml:"description,omitempty"` TermsOfService string `json:"termsOfService,omitempty" yaml:"termsOfService,omitempty"` @@ -19,76 +19,167 @@ type Info struct { Version string `json:"version" yaml:"version"` // Required } -func (value *Info) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(value) +// MarshalJSON returns the JSON encoding of Info. +func (info Info) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 6+len(info.Extensions)) + for k, v := range info.Extensions { + m[k] = v + } + m["title"] = info.Title + if x := info.Description; x != "" { + m["description"] = x + } + if x := info.TermsOfService; x != "" { + m["termsOfService"] = x + } + if x := info.Contact; x != nil { + m["contact"] = x + } + if x := info.License; x != nil { + m["license"] = x + } + m["version"] = info.Version + return json.Marshal(m) } -func (value *Info) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, value) +// UnmarshalJSON sets Info to a copy of data. +func (info *Info) UnmarshalJSON(data []byte) error { + type InfoBis Info + var x InfoBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "title") + delete(x.Extensions, "description") + delete(x.Extensions, "termsOfService") + delete(x.Extensions, "contact") + delete(x.Extensions, "license") + delete(x.Extensions, "version") + *info = Info(x) + return nil } -func (value *Info) Validate(c context.Context) error { - if contact := value.Contact; contact != nil { - if err := contact.Validate(c); err != nil { - return fmt.Errorf("Error when validating Contact: %s", err.Error()) +// Validate returns an error if Info does not comply with the OpenAPI spec. +func (info *Info) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if contact := info.Contact; contact != nil { + if err := contact.Validate(ctx); err != nil { + return err } } - if license := value.License; license != nil { - if err := license.Validate(c); err != nil { - return fmt.Errorf("Error when validating License: %s", err.Error()) + if license := info.License; license != nil { + if err := license.Validate(ctx); err != nil { + return err } } - if value.Version == "" { - return errors.New("Variable 'version' must be a non-empty JSON string") + if info.Version == "" { + return errors.New("value of version must be a non-empty string") } - if value.Title == "" { - return errors.New("Variable 'title' must be a non-empty JSON string") + if info.Title == "" { + return errors.New("value of title must be a non-empty string") } - return nil + return validateExtensions(ctx, info.Extensions) } -// Contact is specified by OpenAPI/Swagger standard version 3.0. +// Contact is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#contact-object type Contact struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` URL string `json:"url,omitempty" yaml:"url,omitempty"` Email string `json:"email,omitempty" yaml:"email,omitempty"` } -func (value *Contact) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(value) +// MarshalJSON returns the JSON encoding of Contact. +func (contact Contact) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 3+len(contact.Extensions)) + for k, v := range contact.Extensions { + m[k] = v + } + if x := contact.Name; x != "" { + m["name"] = x + } + if x := contact.URL; x != "" { + m["url"] = x + } + if x := contact.Email; x != "" { + m["email"] = x + } + return json.Marshal(m) } -func (value *Contact) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, value) +// UnmarshalJSON sets Contact to a copy of data. +func (contact *Contact) UnmarshalJSON(data []byte) error { + type ContactBis Contact + var x ContactBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "name") + delete(x.Extensions, "url") + delete(x.Extensions, "email") + *contact = Contact(x) + return nil } -func (value *Contact) Validate(c context.Context) error { - return nil +// Validate returns an error if Contact does not comply with the OpenAPI spec. +func (contact *Contact) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + return validateExtensions(ctx, contact.Extensions) } -// License is specified by OpenAPI/Swagger standard version 3.0. +// License is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#license-object type License struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` + Name string `json:"name" yaml:"name"` // Required URL string `json:"url,omitempty" yaml:"url,omitempty"` } -func (value *License) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(value) +// MarshalJSON returns the JSON encoding of License. +func (license License) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 2+len(license.Extensions)) + for k, v := range license.Extensions { + m[k] = v + } + m["name"] = license.Name + if x := license.URL; x != "" { + m["url"] = x + } + return json.Marshal(m) } -func (value *License) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, value) +// UnmarshalJSON sets License to a copy of data. +func (license *License) UnmarshalJSON(data []byte) error { + type LicenseBis License + var x LicenseBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "name") + delete(x.Extensions, "url") + *license = License(x) + return nil } -func (value *License) Validate(c context.Context) error { - if value.Name == "" { - return errors.New("Variable 'name' must be a non-empty JSON string") +// Validate returns an error if License does not comply with the OpenAPI spec. +func (license *License) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if license.Name == "" { + return errors.New("value of license name must be a non-empty string") } - return nil + + return validateExtensions(ctx, license.Extensions) } diff --git a/openapi3/internalize_refs.go b/openapi3/internalize_refs.go new file mode 100644 index 000000000..b8506535e --- /dev/null +++ b/openapi3/internalize_refs.go @@ -0,0 +1,434 @@ +package openapi3 + +import ( + "context" + "path/filepath" + "strings" +) + +type RefNameResolver func(string) string + +// DefaultRefResolver is a default implementation of refNameResolver for the +// InternalizeRefs function. +// +// If a reference points to an element inside a document, it returns the last +// element in the reference using filepath.Base. Otherwise if the reference points +// to a file, it returns the file name trimmed of all extensions. +func DefaultRefNameResolver(ref string) string { + if ref == "" { + return "" + } + split := strings.SplitN(ref, "#", 2) + if len(split) == 2 { + return filepath.Base(split[1]) + } + ref = split[0] + for ext := filepath.Ext(ref); len(ext) > 0; ext = filepath.Ext(ref) { + ref = strings.TrimSuffix(ref, ext) + } + return filepath.Base(ref) +} + +func schemaNames(s Schemas) []string { + out := make([]string, 0, len(s)) + for i := range s { + out = append(out, i) + } + return out +} + +func parametersMapNames(s ParametersMap) []string { + out := make([]string, 0, len(s)) + for i := range s { + out = append(out, i) + } + return out +} + +func isExternalRef(ref string, parentIsExternal bool) bool { + return ref != "" && (!strings.HasPrefix(ref, "#/components/") || parentIsExternal) +} + +func (doc *T) addSchemaToSpec(s *SchemaRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { + if s == nil || !isExternalRef(s.Ref, parentIsExternal) { + return false + } + + name := refNameResolver(s.Ref) + if doc.Components != nil { + if _, ok := doc.Components.Schemas[name]; ok { + s.Ref = "#/components/schemas/" + name + return true + } + } + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Schemas == nil { + doc.Components.Schemas = make(Schemas) + } + doc.Components.Schemas[name] = s.Value.NewRef() + s.Ref = "#/components/schemas/" + name + return true +} + +func (doc *T) addParameterToSpec(p *ParameterRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { + if p == nil || !isExternalRef(p.Ref, parentIsExternal) { + return false + } + name := refNameResolver(p.Ref) + if doc.Components != nil { + if _, ok := doc.Components.Parameters[name]; ok { + p.Ref = "#/components/parameters/" + name + return true + } + } + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Parameters == nil { + doc.Components.Parameters = make(ParametersMap) + } + doc.Components.Parameters[name] = &ParameterRef{Value: p.Value} + p.Ref = "#/components/parameters/" + name + return true +} + +func (doc *T) addHeaderToSpec(h *HeaderRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { + if h == nil || !isExternalRef(h.Ref, parentIsExternal) { + return false + } + name := refNameResolver(h.Ref) + if doc.Components != nil { + if _, ok := doc.Components.Headers[name]; ok { + h.Ref = "#/components/headers/" + name + return true + } + } + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Headers == nil { + doc.Components.Headers = make(Headers) + } + doc.Components.Headers[name] = &HeaderRef{Value: h.Value} + h.Ref = "#/components/headers/" + name + return true +} + +func (doc *T) addRequestBodyToSpec(r *RequestBodyRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { + if r == nil || !isExternalRef(r.Ref, parentIsExternal) { + return false + } + name := refNameResolver(r.Ref) + if doc.Components != nil { + if _, ok := doc.Components.RequestBodies[name]; ok { + r.Ref = "#/components/requestBodies/" + name + return true + } + } + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.RequestBodies == nil { + doc.Components.RequestBodies = make(RequestBodies) + } + doc.Components.RequestBodies[name] = &RequestBodyRef{Value: r.Value} + r.Ref = "#/components/requestBodies/" + name + return true +} + +func (doc *T) addResponseToSpec(r *ResponseRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { + if r == nil || !isExternalRef(r.Ref, parentIsExternal) { + return false + } + name := refNameResolver(r.Ref) + if doc.Components != nil { + if _, ok := doc.Components.Responses[name]; ok { + r.Ref = "#/components/responses/" + name + return true + } + } + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Responses == nil { + doc.Components.Responses = make(Responses) + } + doc.Components.Responses[name] = &ResponseRef{Value: r.Value} + r.Ref = "#/components/responses/" + name + return true +} + +func (doc *T) addSecuritySchemeToSpec(ss *SecuritySchemeRef, refNameResolver RefNameResolver, parentIsExternal bool) { + if ss == nil || !isExternalRef(ss.Ref, parentIsExternal) { + return + } + name := refNameResolver(ss.Ref) + if doc.Components != nil { + if _, ok := doc.Components.SecuritySchemes[name]; ok { + ss.Ref = "#/components/securitySchemes/" + name + return + } + } + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.SecuritySchemes == nil { + doc.Components.SecuritySchemes = make(SecuritySchemes) + } + doc.Components.SecuritySchemes[name] = &SecuritySchemeRef{Value: ss.Value} + ss.Ref = "#/components/securitySchemes/" + name + +} + +func (doc *T) addExampleToSpec(e *ExampleRef, refNameResolver RefNameResolver, parentIsExternal bool) { + if e == nil || !isExternalRef(e.Ref, parentIsExternal) { + return + } + name := refNameResolver(e.Ref) + if doc.Components != nil { + if _, ok := doc.Components.Examples[name]; ok { + e.Ref = "#/components/examples/" + name + return + } + } + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Examples == nil { + doc.Components.Examples = make(Examples) + } + doc.Components.Examples[name] = &ExampleRef{Value: e.Value} + e.Ref = "#/components/examples/" + name + +} + +func (doc *T) addLinkToSpec(l *LinkRef, refNameResolver RefNameResolver, parentIsExternal bool) { + if l == nil || !isExternalRef(l.Ref, parentIsExternal) { + return + } + name := refNameResolver(l.Ref) + if doc.Components != nil { + if _, ok := doc.Components.Links[name]; ok { + l.Ref = "#/components/links/" + name + return + } + } + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Links == nil { + doc.Components.Links = make(Links) + } + doc.Components.Links[name] = &LinkRef{Value: l.Value} + l.Ref = "#/components/links/" + name + +} + +func (doc *T) addCallbackToSpec(c *CallbackRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { + if c == nil || !isExternalRef(c.Ref, parentIsExternal) { + return false + } + name := refNameResolver(c.Ref) + + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Callbacks == nil { + doc.Components.Callbacks = make(Callbacks) + } + c.Ref = "#/components/callbacks/" + name + doc.Components.Callbacks[name] = &CallbackRef{Value: c.Value} + return true +} + +func (doc *T) derefSchema(s *Schema, refNameResolver RefNameResolver, parentIsExternal bool) { + if s == nil || doc.isVisitedSchema(s) { + return + } + + for _, list := range []SchemaRefs{s.AllOf, s.AnyOf, s.OneOf} { + for _, s2 := range list { + isExternal := doc.addSchemaToSpec(s2, refNameResolver, parentIsExternal) + if s2 != nil { + doc.derefSchema(s2.Value, refNameResolver, isExternal || parentIsExternal) + } + } + } + for _, s2 := range s.Properties { + isExternal := doc.addSchemaToSpec(s2, refNameResolver, parentIsExternal) + if s2 != nil { + doc.derefSchema(s2.Value, refNameResolver, isExternal || parentIsExternal) + } + } + for _, ref := range []*SchemaRef{s.Not, s.AdditionalProperties.Schema, s.Items} { + isExternal := doc.addSchemaToSpec(ref, refNameResolver, parentIsExternal) + if ref != nil { + doc.derefSchema(ref.Value, refNameResolver, isExternal || parentIsExternal) + } + } +} + +func (doc *T) derefHeaders(hs Headers, refNameResolver RefNameResolver, parentIsExternal bool) { + for _, h := range hs { + isExternal := doc.addHeaderToSpec(h, refNameResolver, parentIsExternal) + if doc.isVisitedHeader(h.Value) { + continue + } + doc.derefParameter(h.Value.Parameter, refNameResolver, parentIsExternal || isExternal) + } +} + +func (doc *T) derefExamples(es Examples, refNameResolver RefNameResolver, parentIsExternal bool) { + for _, e := range es { + doc.addExampleToSpec(e, refNameResolver, parentIsExternal) + } +} + +func (doc *T) derefContent(c Content, refNameResolver RefNameResolver, parentIsExternal bool) { + for _, mediatype := range c { + isExternal := doc.addSchemaToSpec(mediatype.Schema, refNameResolver, parentIsExternal) + if mediatype.Schema != nil { + doc.derefSchema(mediatype.Schema.Value, refNameResolver, isExternal || parentIsExternal) + } + doc.derefExamples(mediatype.Examples, refNameResolver, parentIsExternal) + for _, e := range mediatype.Encoding { + doc.derefHeaders(e.Headers, refNameResolver, parentIsExternal) + } + } +} + +func (doc *T) derefLinks(ls Links, refNameResolver RefNameResolver, parentIsExternal bool) { + for _, l := range ls { + doc.addLinkToSpec(l, refNameResolver, parentIsExternal) + } +} + +func (doc *T) derefResponses(es Responses, refNameResolver RefNameResolver, parentIsExternal bool) { + for _, e := range es { + isExternal := doc.addResponseToSpec(e, refNameResolver, parentIsExternal) + if e.Value != nil { + doc.derefHeaders(e.Value.Headers, refNameResolver, isExternal || parentIsExternal) + doc.derefContent(e.Value.Content, refNameResolver, isExternal || parentIsExternal) + doc.derefLinks(e.Value.Links, refNameResolver, isExternal || parentIsExternal) + } + } +} + +func (doc *T) derefParameter(p Parameter, refNameResolver RefNameResolver, parentIsExternal bool) { + isExternal := doc.addSchemaToSpec(p.Schema, refNameResolver, parentIsExternal) + doc.derefContent(p.Content, refNameResolver, parentIsExternal) + if p.Schema != nil { + doc.derefSchema(p.Schema.Value, refNameResolver, isExternal || parentIsExternal) + } +} + +func (doc *T) derefRequestBody(r RequestBody, refNameResolver RefNameResolver, parentIsExternal bool) { + doc.derefContent(r.Content, refNameResolver, parentIsExternal) +} + +func (doc *T) derefPaths(paths map[string]*PathItem, refNameResolver RefNameResolver, parentIsExternal bool) { + for _, ops := range paths { + if isExternalRef(ops.Ref, parentIsExternal) { + parentIsExternal = true + } + // inline full operations + ops.Ref = "" + + for _, param := range ops.Parameters { + doc.addParameterToSpec(param, refNameResolver, parentIsExternal) + } + + for _, op := range ops.Operations() { + isExternal := doc.addRequestBodyToSpec(op.RequestBody, refNameResolver, parentIsExternal) + if op.RequestBody != nil && op.RequestBody.Value != nil { + doc.derefRequestBody(*op.RequestBody.Value, refNameResolver, parentIsExternal || isExternal) + } + for _, cb := range op.Callbacks { + isExternal := doc.addCallbackToSpec(cb, refNameResolver, parentIsExternal) + if cb.Value != nil { + doc.derefPaths(*cb.Value, refNameResolver, parentIsExternal || isExternal) + } + } + doc.derefResponses(op.Responses, refNameResolver, parentIsExternal) + for _, param := range op.Parameters { + isExternal := doc.addParameterToSpec(param, refNameResolver, parentIsExternal) + if param.Value != nil { + doc.derefParameter(*param.Value, refNameResolver, parentIsExternal || isExternal) + } + } + } + } +} + +// InternalizeRefs removes all references to external files from the spec and moves them +// to the components section. +// +// refNameResolver takes in references to returns a name to store the reference under locally. +// It MUST return a unique name for each reference type. +// A default implementation is provided that will suffice for most use cases. See the function +// documention for more details. +// +// Example: +// +// doc.InternalizeRefs(context.Background(), nil) +func (doc *T) InternalizeRefs(ctx context.Context, refNameResolver func(ref string) string) { + doc.resetVisited() + + if refNameResolver == nil { + refNameResolver = DefaultRefNameResolver + } + + if components := doc.Components; components != nil { + names := schemaNames(components.Schemas) + for _, name := range names { + schema := components.Schemas[name] + isExternal := doc.addSchemaToSpec(schema, refNameResolver, false) + if schema != nil { + schema.Ref = "" // always dereference the top level + doc.derefSchema(schema.Value, refNameResolver, isExternal) + } + } + names = parametersMapNames(components.Parameters) + for _, name := range names { + p := components.Parameters[name] + isExternal := doc.addParameterToSpec(p, refNameResolver, false) + if p != nil && p.Value != nil { + p.Ref = "" // always dereference the top level + doc.derefParameter(*p.Value, refNameResolver, isExternal) + } + } + doc.derefHeaders(components.Headers, refNameResolver, false) + for _, req := range components.RequestBodies { + isExternal := doc.addRequestBodyToSpec(req, refNameResolver, false) + if req != nil && req.Value != nil { + req.Ref = "" // always dereference the top level + doc.derefRequestBody(*req.Value, refNameResolver, isExternal) + } + } + doc.derefResponses(components.Responses, refNameResolver, false) + for _, ss := range components.SecuritySchemes { + doc.addSecuritySchemeToSpec(ss, refNameResolver, false) + } + doc.derefExamples(components.Examples, refNameResolver, false) + doc.derefLinks(components.Links, refNameResolver, false) + for _, cb := range components.Callbacks { + isExternal := doc.addCallbackToSpec(cb, refNameResolver, false) + if cb != nil && cb.Value != nil { + cb.Ref = "" // always dereference the top level + doc.derefPaths(*cb.Value, refNameResolver, isExternal) + } + } + } + + doc.derefPaths(doc.Paths, refNameResolver, false) +} diff --git a/openapi3/internalize_refs_test.go b/openapi3/internalize_refs_test.go new file mode 100644 index 000000000..fe6b29d90 --- /dev/null +++ b/openapi3/internalize_refs_test.go @@ -0,0 +1,65 @@ +package openapi3 + +import ( + "context" + "os" + "regexp" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInternalizeRefs(t *testing.T) { + ctx := context.Background() + + regexpRef := regexp.MustCompile(`"\$ref":`) + regexpRefInternal := regexp.MustCompile(`"\$ref":"#`) + + tests := []struct { + filename string + }{ + {"testdata/testref.openapi.yml"}, + {"testdata/recursiveRef/openapi.yml"}, + {"testdata/spec.yaml"}, + {"testdata/callbacks.yml"}, + } + + for _, test := range tests { + t.Run(test.filename, func(t *testing.T) { + // Load in the reference spec from the testdata + sl := NewLoader() + sl.IsExternalRefsAllowed = true + doc, err := sl.LoadFromFile(test.filename) + require.NoError(t, err, "loading test file") + err = doc.Validate(ctx) + require.NoError(t, err, "validating spec") + + // Internalize the references + doc.InternalizeRefs(ctx, nil) + + // Validate the internalized spec + err = doc.Validate(ctx) + require.NoError(t, err, "validating internalized spec") + + actual, err := doc.MarshalJSON() + require.NoError(t, err, "marshalling internalized spec") + + // run a static check over the file, making sure each occurence of a + // reference is followed by a # + numRefs := len(regexpRef.FindAll(actual, -1)) + numInternalRefs := len(regexpRefInternal.FindAll(actual, -1)) + require.Equal(t, numRefs, numInternalRefs, "checking all references are internal") + + // load from actual, but with the path set to the current directory + doc2, err := sl.LoadFromData(actual) + require.NoError(t, err, "reloading spec") + err = doc2.Validate(ctx) + require.NoError(t, err, "validating reloaded spec") + + // compare with expected + expected, err := os.ReadFile(test.filename + ".internalized.yml") + require.NoError(t, err) + require.JSONEq(t, string(expected), string(actual)) + }) + } +} diff --git a/openapi3/issue136_test.go b/openapi3/issue136_test.go new file mode 100644 index 000000000..3aa7edd8f --- /dev/null +++ b/openapi3/issue136_test.go @@ -0,0 +1,53 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue136(t *testing.T) { + specf := func(dflt string) string { + return ` +openapi: 3.0.2 +info: + title: "Hello World REST APIs" + version: "1.0" +paths: {} +components: + schemas: + SomeSchema: + type: string + default: ` + dflt + ` +` + } + + for _, testcase := range []struct { + dflt, err string + }{ + { + dflt: `"foo"`, + err: "", + }, + { + dflt: `1`, + err: "invalid components: invalid schema default: value must be a string", + }, + } { + t.Run(testcase.dflt, func(t *testing.T) { + spec := specf(testcase.dflt) + + sl := NewLoader() + + doc, err := sl.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(sl.Context) + if testcase.err == "" { + require.NoError(t, err) + } else { + require.Error(t, err, testcase.err) + } + }) + } +} diff --git a/openapi3/issue241_test.go b/openapi3/issue241_test.go new file mode 100644 index 000000000..14caa9b26 --- /dev/null +++ b/openapi3/issue241_test.go @@ -0,0 +1,29 @@ +package openapi3_test + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestIssue241(t *testing.T) { + data, err := ioutil.ReadFile("testdata/issue241.yml") + require.NoError(t, err) + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + spec, err := loader.LoadFromData(data) + require.NoError(t, err) + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + err = enc.Encode(spec) + require.NoError(t, err) + require.Equal(t, string(data), buf.String()) +} diff --git a/openapi3/issue301_test.go b/openapi3/issue301_test.go new file mode 100644 index 000000000..a0225fdb8 --- /dev/null +++ b/openapi3/issue301_test.go @@ -0,0 +1,28 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue301(t *testing.T) { + sl := NewLoader() + sl.IsExternalRefsAllowed = true + + doc, err := sl.LoadFromFile("testdata/callbacks.yml") + require.NoError(t, err) + + err = doc.Validate(sl.Context) + require.NoError(t, err) + + transCallbacks := doc.Paths["/trans"].Post.Callbacks["transactionCallback"].Value + require.Equal(t, "object", (*transCallbacks)["http://notificationServer.com?transactionId={$request.body#/id}&email={$request.body#/email}"].Post.RequestBody. + Value.Content["application/json"].Schema. + Value.Type) + + otherCallbacks := doc.Paths["/other"].Post.Callbacks["myEvent"].Value + require.Equal(t, "boolean", (*otherCallbacks)["{$request.query.queryUrl}"].Post.RequestBody. + Value.Content["application/json"].Schema. + Value.Type) +} diff --git a/openapi3/issue341_test.go b/openapi3/issue341_test.go new file mode 100644 index 000000000..ba9bed76b --- /dev/null +++ b/openapi3/issue341_test.go @@ -0,0 +1,63 @@ +package openapi3 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue341(t *testing.T) { + sl := NewLoader() + sl.IsExternalRefsAllowed = true + doc, err := sl.LoadFromFile("testdata/main.yaml") + require.NoError(t, err) + + err = doc.Validate(sl.Context) + require.NoError(t, err) + + err = sl.ResolveRefsIn(doc, nil) + require.NoError(t, err) + + bs, err := doc.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, `{"info":{"title":"test file","version":"n/a"},"openapi":"3.0.0","paths":{"/testpath":{"$ref":"testpath.yaml#/paths/~1testpath"}}}`, string(bs)) + + require.Equal(t, "string", doc.Paths["/testpath"].Get.Responses["200"].Value.Content["application/json"].Schema.Value.Type) + + doc.InternalizeRefs(context.Background(), nil) + bs, err = doc.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, `{ + "components": { + "responses": { + "testpath_200_response": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "a custom response" + } + } + }, + "info": { + "title": "test file", + "version": "n/a" + }, + "openapi": "3.0.0", + "paths": { + "/testpath": { + "get": { + "responses": { + "200": { + "$ref": "#/components/responses/testpath_200_response" + } + } + } + } + } + }`, string(bs)) +} diff --git a/openapi3/issue344_test.go b/openapi3/issue344_test.go new file mode 100644 index 000000000..44ba2b7f5 --- /dev/null +++ b/openapi3/issue344_test.go @@ -0,0 +1,20 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue344(t *testing.T) { + sl := NewLoader() + sl.IsExternalRefsAllowed = true + + doc, err := sl.LoadFromFile("testdata/spec.yaml") + require.NoError(t, err) + + err = doc.Validate(sl.Context) + require.NoError(t, err) + + require.Equal(t, "string", doc.Components.Schemas["Test"].Value.Properties["test"].Value.Properties["name"].Value.Type) +} diff --git a/openapi3/issue376_test.go b/openapi3/issue376_test.go new file mode 100644 index 000000000..825f1d1ac --- /dev/null +++ b/openapi3/issue376_test.go @@ -0,0 +1,165 @@ +package openapi3 + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue376(t *testing.T) { + spec := []byte(` +openapi: 3.0.0 +components: + schemas: + schema1: + type: object + additionalProperties: + type: string + schema2: + type: object + properties: + prop: + $ref: '#/components/schemas/schema1/additionalProperties' +paths: {} +info: + title: An API + version: 1.2.3.4 +`) + + loader := NewLoader() + + doc, err := loader.LoadFromData(spec) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + require.Equal(t, "An API", doc.Info.Title) + require.Equal(t, 2, len(doc.Components.Schemas)) + require.Equal(t, 0, len(doc.Paths)) + + require.Equal(t, "string", doc.Components.Schemas["schema2"].Value.Properties["prop"].Value.Type) +} + +func TestExclusiveValuesOfValuesAdditionalProperties(t *testing.T) { + schema := &Schema{ + AdditionalProperties: AdditionalProperties{ + Has: BoolPtr(false), + Schema: NewSchemaRef("", &Schema{}), + }, + } + err := schema.Validate(context.Background()) + require.ErrorContains(t, err, ` to both `) + + schema = &Schema{ + AdditionalProperties: AdditionalProperties{ + Has: BoolPtr(false), + }, + } + err = schema.Validate(context.Background()) + require.NoError(t, err) + + schema = &Schema{ + AdditionalProperties: AdditionalProperties{ + Schema: NewSchemaRef("", &Schema{}), + }, + } + err = schema.Validate(context.Background()) + require.NoError(t, err) +} + +func TestMultijsonTagSerialization(t *testing.T) { + specYAML := []byte(` +openapi: 3.0.0 +components: + schemas: + unset: + type: number + empty-object: + additionalProperties: {} + object: + additionalProperties: {type: string} + boolean: + additionalProperties: false +paths: {} +info: + title: An API + version: 1.2.3.4 +`) + + specJSON := []byte(`{ + "openapi": "3.0.0", + "components": { + "schemas": { + "unset": { + "type": "number" + }, + "empty-object": { + "additionalProperties": { + } + }, + "object": { + "additionalProperties": { + "type": "string" + } + }, + "boolean": { + "additionalProperties": false + } + } + }, + "paths": { + }, + "info": { + "title": "An API", + "version": "1.2.3.4" + } +}`) + + for i, spec := range [][]byte{specJSON, specYAML} { + t.Run(fmt.Sprintf("spec%02d", i), func(t *testing.T) { + loader := NewLoader() + + doc, err := loader.LoadFromData(spec) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + for propName, propSchema := range doc.Components.Schemas { + t.Run(propName, func(t *testing.T) { + ap := propSchema.Value.AdditionalProperties.Schema + apa := propSchema.Value.AdditionalProperties.Has + + apStr := "" + if ap != nil { + apStr = fmt.Sprintf("{Ref:%s Value.Type:%v}", (*ap).Ref, (*ap).Value.Type) + } + apaStr := "" + if apa != nil { + apaStr = fmt.Sprintf("%v", *apa) + } + + encoded, err := propSchema.MarshalJSON() + require.NoError(t, err) + require.Equal(t, map[string]string{ + "unset": `{"type":"number"}`, + "empty-object": `{"additionalProperties":{}}`, + "object": `{"additionalProperties":{"type":"string"}}`, + "boolean": `{"additionalProperties":false}`, + }[propName], string(encoded)) + + if propName == "unset" { + require.True(t, ap == nil && apa == nil) + return + } + + require.Truef(t, (ap != nil && apa == nil) || (ap == nil && apa != nil), + "%s: isnil(%s) xor isnil(%s)", propName, apaStr, apStr) + }) + } + }) + } +} diff --git a/openapi3/issue382_test.go b/openapi3/issue382_test.go new file mode 100644 index 000000000..c29b7e981 --- /dev/null +++ b/openapi3/issue382_test.go @@ -0,0 +1,15 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOverridingGlobalParametersValidation(t *testing.T) { + loader := NewLoader() + doc, err := loader.LoadFromFile("testdata/Test_param_override.yml") + require.NoError(t, err) + err = doc.Validate(loader.Context) + require.NoError(t, err) +} diff --git a/openapi3/issue513_test.go b/openapi3/issue513_test.go new file mode 100644 index 000000000..332b9226e --- /dev/null +++ b/openapi3/issue513_test.go @@ -0,0 +1,173 @@ +package openapi3 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue513OKWithExtension(t *testing.T) { + spec := ` +openapi: "3.0.3" +info: + title: 'My app' + version: 1.0.0 + description: 'An API' + +paths: + /v1/operation: + delete: + summary: Delete something + responses: + 200: + description: Success + default: + description: '* **400** - Bad Request' + x-my-extension: {val: ue} + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Error: + type: object + description: An error response body. + properties: + message: + description: A detailed message describing the error. + type: string +`[1:] + sl := NewLoader() + doc, err := sl.LoadFromData([]byte(spec)) + require.NoError(t, err) + err = doc.Validate(sl.Context) + require.NoError(t, err) + data, err := json.Marshal(doc) + require.NoError(t, err) + require.Contains(t, string(data), `x-my-extension`) +} + +func TestIssue513KOHasExtraFieldSchema(t *testing.T) { + spec := ` +openapi: "3.0.3" +info: + title: 'My app' + version: 1.0.0 + description: 'An API' + +paths: + /v1/operation: + delete: + summary: Delete something + responses: + 200: + description: Success + default: + description: '* **400** - Bad Request' + x-my-extension: {val: ue} + # Notice here schema is invalid. It should instead be: + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/Error' + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Error: + type: object + description: An error response body. + properties: + message: + description: A detailed message describing the error. + type: string +`[1:] + sl := NewLoader() + doc, err := sl.LoadFromData([]byte(spec)) + require.NoError(t, err) + require.Contains(t, doc.Paths["/v1/operation"].Delete.Responses["default"].Value.Extensions, `x-my-extension`) + err = doc.Validate(sl.Context) + require.ErrorContains(t, err, `extra sibling fields: [schema]`) +} + +func TestIssue513KOMixesRefAlongWithOtherFields(t *testing.T) { + spec := ` +openapi: "3.0.3" +info: + title: 'My app' + version: 1.0.0 + description: 'An API' + +paths: + /v1/operation: + delete: + summary: Delete something + responses: + 200: + description: A sibling field that the spec says is ignored + $ref: '#/components/responses/SomeResponseBody' +components: + responses: + SomeResponseBody: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + schemas: + Error: + type: object + description: An error response body. + properties: + message: + description: A detailed message describing the error. + type: string +`[1:] + sl := NewLoader() + doc, err := sl.LoadFromData([]byte(spec)) + require.NoError(t, err) + err = doc.Validate(sl.Context) + require.ErrorContains(t, err, `extra sibling fields: [description]`) +} + +func TestIssue513KOMixesRefAlongWithOtherFieldsAllowed(t *testing.T) { + spec := ` +openapi: "3.0.3" +info: + title: 'My app' + version: 1.0.0 + description: 'An API' + +paths: + /v1/operation: + delete: + summary: Delete something + responses: + 200: + description: A sibling field that the spec says is ignored + $ref: '#/components/responses/SomeResponseBody' +components: + responses: + SomeResponseBody: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + schemas: + Error: + type: object + description: An error response body. + properties: + message: + description: A detailed message describing the error. + type: string +`[1:] + sl := NewLoader() + doc, err := sl.LoadFromData([]byte(spec)) + require.NoError(t, err) + err = doc.Validate(sl.Context, AllowExtraSiblingFields("description")) + require.NoError(t, err) +} diff --git a/openapi3/issue542_test.go b/openapi3/issue542_test.go new file mode 100644 index 000000000..05f5db64d --- /dev/null +++ b/openapi3/issue542_test.go @@ -0,0 +1,37 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue542(t *testing.T) { + spec := []byte(` +openapi: '3.0.0' +info: + version: '1.0.0' + title: Swagger Petstore + license: + name: MIT +servers: +- url: http://petstore.swagger.io/v1 +paths: {} +components: + schemas: + Cat: + anyOf: + - $ref: '#/components/schemas/Kitten' + - type: object + Kitten: + type: string +`[1:]) + + sl := NewLoader() + + doc, err := sl.LoadFromData(spec) + require.NoError(t, err) + + doc.Validate(sl.Context) + require.NoError(t, err) +} diff --git a/openapi3/issue570_test.go b/openapi3/issue570_test.go new file mode 100644 index 000000000..f3c527e3b --- /dev/null +++ b/openapi3/issue570_test.go @@ -0,0 +1,15 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssue570(t *testing.T) { + loader := NewLoader() + _, err := loader.LoadFromFile("testdata/issue570.json") + require.Error(t, err) + assert.Contains(t, err.Error(), CircularReferenceError) +} diff --git a/openapi3/issue601_test.go b/openapi3/issue601_test.go new file mode 100644 index 000000000..ef841c25f --- /dev/null +++ b/openapi3/issue601_test.go @@ -0,0 +1,34 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue601(t *testing.T) { + // Document is invalid: first validation error returned is because + // schema: + // example: {key: value} + // is not how schema examples are defined (but how components' examples are defined. Components are maps.) + // Correct code should be: + // schema: {example: value} + sl := NewLoader() + doc, err := sl.LoadFromFile("testdata/lxkns.yaml") + require.NoError(t, err) + + err = doc.Validate(sl.Context) + require.Contains(t, err.Error(), `invalid components: schema "DiscoveryResult": invalid example: Error at "/type": property "type" is missing`) + require.Contains(t, err.Error(), `| Error at "/nsid": property "nsid" is missing`) + + err = doc.Validate(sl.Context, DisableExamplesValidation()) + require.NoError(t, err) + + // Now let's remove all the invalid parts + for _, schema := range doc.Components.Schemas { + schema.Value.Example = nil + } + + err = doc.Validate(sl.Context) + require.NoError(t, err) +} diff --git a/openapi3/issue615_test.go b/openapi3/issue615_test.go new file mode 100644 index 000000000..496a972bb --- /dev/null +++ b/openapi3/issue615_test.go @@ -0,0 +1,34 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestIssue615(t *testing.T) { + { + var old int + old, openapi3.CircularReferenceCounter = openapi3.CircularReferenceCounter, 1 + defer func() { openapi3.CircularReferenceCounter = old }() + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + _, err := loader.LoadFromFile("testdata/recursiveRef/issue615.yml") + require.ErrorContains(t, err, openapi3.CircularReferenceError) + } + + var old int + old, openapi3.CircularReferenceCounter = openapi3.CircularReferenceCounter, 4 + defer func() { openapi3.CircularReferenceCounter = old }() + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/recursiveRef/issue615.yml") + require.NoError(t, err) + + doc.Validate(loader.Context) + require.NoError(t, err) +} diff --git a/openapi3/issue618_test.go b/openapi3/issue618_test.go new file mode 100644 index 000000000..2085ca0ee --- /dev/null +++ b/openapi3/issue618_test.go @@ -0,0 +1,39 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue618(t *testing.T) { + spec := ` +openapi: 3.0.0 +info: + title: foo + version: 0.0.0 +paths: + /foo: + get: + responses: + '200': + description: Some description value text + content: + application/json: + schema: + $ref: ./testdata/schema618.yml#/components/schemas/JournalEntry +`[1:] + + loader := NewLoader() + loader.IsExternalRefsAllowed = true + ctx := loader.Context + + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + doc.InternalizeRefs(ctx, nil) + + require.Contains(t, doc.Components.Schemas, "JournalEntry") + require.Contains(t, doc.Components.Schemas, "Record") + require.Contains(t, doc.Components.Schemas, "Account") +} diff --git a/openapi3/issue638_test.go b/openapi3/issue638_test.go new file mode 100644 index 000000000..1db8a6f51 --- /dev/null +++ b/openapi3/issue638_test.go @@ -0,0 +1,21 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue638(t *testing.T) { + for i := 0; i < 50; i++ { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + // This path affects the occurrence of the issue #638. + // ../openapi3/testdata/issue638/test1.yaml : reproduce + // ./testdata/issue638/test1.yaml : not reproduce + // testdata/issue638/test1.yaml : reproduce + doc, err := loader.LoadFromFile("testdata/issue638/test1.yaml") + require.NoError(t, err) + require.Equal(t, "int", doc.Components.Schemas["test1d"].Value.Type) + } +} diff --git a/openapi3/issue652_test.go b/openapi3/issue652_test.go new file mode 100644 index 000000000..f36e92005 --- /dev/null +++ b/openapi3/issue652_test.go @@ -0,0 +1,29 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestIssue652(t *testing.T) { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + + // Test checks that no slice bounds out of range error occurs while loading + // from file that contains reference to file in the parent directory. + require.NotPanics(t, func() { + const schemaName = "ReferenceToParentDirectory" + + spec, err := loader.LoadFromFile("testdata/issue652/nested/schema.yml") + require.NoError(t, err) + require.Contains(t, spec.Components.Schemas, schemaName) + + schema := spec.Components.Schemas[schemaName] + assert.Equal(t, schema.Ref, "../definitions.yml#/components/schemas/TestSchema") + assert.Equal(t, schema.Value.Type, "string") + }) +} diff --git a/openapi3/issue657_test.go b/openapi3/issue657_test.go new file mode 100644 index 000000000..195ccd19c --- /dev/null +++ b/openapi3/issue657_test.go @@ -0,0 +1,79 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestOneOf_Warning_Errors(t *testing.T) { + t.Parallel() + + loader := openapi3.NewLoader() + spec := ` +components: + schemas: + Something: + type: object + properties: + field: + title: Some field + oneOf: + - title: First rule + type: string + minLength: 10 + maxLength: 10 + - title: Second rule + type: string + minLength: 15 + maxLength: 15 +`[1:] + + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + tests := [...]struct { + name string + value string + checkErr require.ErrorAssertionFunc + }{ + { + name: "valid value", + value: "ABCDE01234", + checkErr: require.NoError, + }, + { + name: "valid value", + value: "ABCDE0123456789", + checkErr: require.NoError, + }, + { + name: "no valid value", + value: "ABCDE", + checkErr: func(t require.TestingT, err error, i ...interface{}) { + require.Equal(t, "doesn't match schema due to: minimum string length is 10\nSchema:\n {\n \"maxLength\": 10,\n \"minLength\": 10,\n \"title\": \"First rule\",\n \"type\": \"string\"\n }\n\nValue:\n \"ABCDE\"\n Or minimum string length is 15\nSchema:\n {\n \"maxLength\": 15,\n \"minLength\": 15,\n \"title\": \"Second rule\",\n \"type\": \"string\"\n }\n\nValue:\n \"ABCDE\"\n", err.Error()) + + wErr := &openapi3.MultiError{} + require.ErrorAs(t, err, wErr) + + require.Len(t, *wErr, 2) + + require.Equal(t, "minimum string length is 10", (*wErr)[0].(*openapi3.SchemaError).Reason) + require.Equal(t, "minimum string length is 15", (*wErr)[1].(*openapi3.SchemaError).Reason) + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err = doc.Components.Schemas["Something"].Value.Properties["field"].Value.VisitJSON(test.value) + + test.checkErr(t, err) + }) + } +} diff --git a/openapi3/issue689_test.go b/openapi3/issue689_test.go new file mode 100644 index 000000000..cafbadfac --- /dev/null +++ b/openapi3/issue689_test.go @@ -0,0 +1,107 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestIssue689(t *testing.T) { + t.Parallel() + + tests := [...]struct { + name string + schema *openapi3.Schema + value map[string]interface{} + opts []openapi3.SchemaValidationOption + checkErr require.ErrorAssertionFunc + }{ + // read-only + { + name: "read-only property succeeds when read-only validation is disabled", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", ReadOnly: true}}), + value: map[string]interface{}{"foo": true}, + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest(), + openapi3.DisableReadOnlyValidation()}, + checkErr: require.NoError, + }, + { + name: "non read-only property succeeds when read-only validation is disabled", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", ReadOnly: false}}), + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest()}, + value: map[string]interface{}{"foo": true}, + checkErr: require.NoError, + }, + { + name: "read-only property fails when read-only validation is enabled", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", ReadOnly: true}}), + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest()}, + value: map[string]interface{}{"foo": true}, + checkErr: require.Error, + }, + { + name: "non read-only property succeeds when read-only validation is enabled", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", ReadOnly: false}}), + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest()}, + value: map[string]interface{}{"foo": true}, + checkErr: require.NoError, + }, + // write-only + { + name: "write-only property succeeds when write-only validation is disabled", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", WriteOnly: true}}), + value: map[string]interface{}{"foo": true}, + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsResponse(), + openapi3.DisableWriteOnlyValidation()}, + checkErr: require.NoError, + }, + { + name: "non write-only property succeeds when write-only validation is disabled", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", WriteOnly: false}}), + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsResponse()}, + value: map[string]interface{}{"foo": true}, + checkErr: require.NoError, + }, + { + name: "write-only property fails when write-only validation is enabled", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", WriteOnly: true}}), + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsResponse()}, + value: map[string]interface{}{"foo": true}, + checkErr: require.Error, + }, + { + name: "non write-only property succeeds when write-only validation is enabled", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", WriteOnly: false}}), + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsResponse()}, + value: map[string]interface{}{"foo": true}, + checkErr: require.NoError, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := test.schema.VisitJSON(test.value, test.opts...) + test.checkErr(t, err) + }) + } +} diff --git a/openapi3/issue697_test.go b/openapi3/issue697_test.go new file mode 100644 index 000000000..c7317584a --- /dev/null +++ b/openapi3/issue697_test.go @@ -0,0 +1,15 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue697(t *testing.T) { + loader := NewLoader() + doc, err := loader.LoadFromFile("testdata/issue697.yml") + require.NoError(t, err) + err = doc.Validate(loader.Context) + require.NoError(t, err) +} diff --git a/openapi3/issue735_test.go b/openapi3/issue735_test.go new file mode 100644 index 000000000..f7e420c5d --- /dev/null +++ b/openapi3/issue735_test.go @@ -0,0 +1,278 @@ +package openapi3 + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +type testCase struct { + name string + schema *Schema + value interface{} + extraNotContains []interface{} + options []SchemaValidationOption +} + +func TestIssue735(t *testing.T) { + DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122) + DefineStringFormat("email", FormatOfStringForEmail) + DefineIPv4Format() + DefineIPv6Format() + + testCases := []testCase{ + { + name: "type string", + schema: NewStringSchema(), + value: 42, + }, + { + name: "type boolean", + schema: NewBoolSchema(), + value: 42, + }, + { + name: "type integer", + schema: NewIntegerSchema(), + value: "foo", + }, + { + name: "type number", + schema: NewFloat64Schema(), + value: "foo", + }, + { + name: "type array", + schema: NewArraySchema(), + value: 42, + }, + { + name: "type object", + schema: NewObjectSchema(), + value: 42, + }, + { + name: "min", + schema: NewSchema().WithMin(100), + value: 42, + }, + { + name: "max", + schema: NewSchema().WithMax(0), + value: 42, + }, + { + name: "exclusive min", + schema: NewSchema().WithMin(100).WithExclusiveMin(true), + value: 42, + }, + { + name: "exclusive max", + schema: NewSchema().WithMax(0).WithExclusiveMax(true), + value: 42, + }, + { + name: "multiple of", + schema: &Schema{MultipleOf: Float64Ptr(5.0)}, + value: 42, + }, + { + name: "enum", + schema: NewSchema().WithEnum(3, 5), + value: 42, + }, + { + name: "min length", + schema: NewSchema().WithMinLength(100), + value: "foo", + }, + { + name: "max length", + schema: NewSchema().WithMaxLength(0), + value: "foo", + }, + { + name: "pattern", + schema: NewSchema().WithPattern("[0-9]"), + value: "foo", + }, + { + name: "items", + schema: NewSchema().WithItems(NewStringSchema()), + value: []interface{}{42}, + extraNotContains: []interface{}{42}, + }, + { + name: "min items", + schema: NewSchema().WithMinItems(100), + value: []interface{}{42}, + extraNotContains: []interface{}{42}, + }, + { + name: "max items", + schema: NewSchema().WithMaxItems(0), + value: []interface{}{42}, + extraNotContains: []interface{}{42}, + }, + { + name: "unique items", + schema: NewSchema().WithUniqueItems(true), + value: []interface{}{42, 42}, + extraNotContains: []interface{}{42}, + }, + { + name: "min properties", + schema: NewSchema().WithMinProperties(100), + value: map[string]interface{}{"foo": 42}, + extraNotContains: []interface{}{42}, + }, + { + name: "max properties", + schema: NewSchema().WithMaxProperties(0), + value: map[string]interface{}{"foo": 42}, + extraNotContains: []interface{}{42}, + }, + { + name: "additional properties other schema type", + schema: NewSchema().WithAdditionalProperties(NewStringSchema()), + value: map[string]interface{}{"foo": 42}, + extraNotContains: []interface{}{42}, + }, + { + name: "additional properties false", + schema: &Schema{AdditionalProperties: AdditionalProperties{ + Has: BoolPtr(false), + }}, + value: map[string]interface{}{"foo": 42}, + extraNotContains: []interface{}{42}, + }, + { + name: "invalid properties schema", + schema: NewSchema().WithProperties(map[string]*Schema{ + "foo": NewStringSchema(), + }), + value: map[string]interface{}{"foo": 42}, + extraNotContains: []interface{}{42}, + }, + // TODO: uncomment when https://github.com/getkin/kin-openapi/issues/502 is fixed + //{ + // name: "read only properties", + // schema: NewSchema().WithProperties(map[string]*Schema{ + // "foo": {ReadOnly: true}, + // }).WithoutAdditionalProperties(), + // value: map[string]interface{}{"foo": 42}, + // extraNotContains: []interface{}{42}, + // options: []SchemaValidationOption{VisitAsRequest()}, + //}, + //{ + // name: "write only properties", + // schema: NewSchema().WithProperties(map[string]*Schema{ + // "foo": {WriteOnly: true}, + // }).WithoutAdditionalProperties(), + // value: map[string]interface{}{"foo": 42}, + // extraNotContains: []interface{}{42}, + // options: []SchemaValidationOption{VisitAsResponse()}, + //}, + { + name: "required properties", + schema: &Schema{ + Properties: Schemas{ + "bar": NewStringSchema().NewRef(), + }, + Required: []string{"bar"}, + }, + value: map[string]interface{}{"foo": 42}, + extraNotContains: []interface{}{42}, + }, + { + name: "one of (matches more then one)", + schema: NewOneOfSchema( + &Schema{MultipleOf: Float64Ptr(6)}, + &Schema{MultipleOf: Float64Ptr(7)}, + ), + value: 42, + }, + { + name: "one of (no matches)", + schema: NewOneOfSchema( + &Schema{MultipleOf: Float64Ptr(5)}, + &Schema{MultipleOf: Float64Ptr(10)}, + ), + value: 42, + }, + { + name: "any of", + schema: NewAnyOfSchema( + &Schema{MultipleOf: Float64Ptr(5)}, + &Schema{MultipleOf: Float64Ptr(10)}, + ), + value: 42, + }, + { + name: "all of (match some)", + schema: NewAllOfSchema( + &Schema{MultipleOf: Float64Ptr(6)}, + &Schema{MultipleOf: Float64Ptr(5)}, + ), + value: 42, + }, + { + name: "all of (no match)", + schema: NewAllOfSchema( + &Schema{MultipleOf: Float64Ptr(10)}, + &Schema{MultipleOf: Float64Ptr(5)}, + ), + value: 42, + }, + { + name: "uuid format", + schema: NewUUIDSchema(), + value: "foo", + }, + { + name: "date time format", + schema: NewDateTimeSchema(), + value: "foo", + }, + { + name: "date format", + schema: NewSchema().WithFormat("date"), + value: "foo", + }, + { + name: "ipv4 format", + schema: NewSchema().WithFormat("ipv4"), + value: "foo", + }, + { + name: "ipv6 format", + schema: NewSchema().WithFormat("ipv6"), + value: "foo", + }, + { + name: "email format", + schema: NewSchema().WithFormat("email"), + value: "foo", + }, + { + name: "byte format", + schema: NewBytesSchema(), + value: "foo!", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.schema.VisitJSON(tc.value, tc.options...) + var schemaError = &SchemaError{} + require.Error(t, err) + require.ErrorAs(t, err, &schemaError) + require.NotZero(t, schemaError.Reason) + require.NotContains(t, schemaError.Reason, fmt.Sprint(tc.value)) + for _, extra := range tc.extraNotContains { + require.NotContains(t, schemaError.Reason, fmt.Sprint(extra)) + } + }) + } +} diff --git a/openapi3/issue741_test.go b/openapi3/issue741_test.go new file mode 100644 index 000000000..aad522023 --- /dev/null +++ b/openapi3/issue741_test.go @@ -0,0 +1,43 @@ +package openapi3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue741(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + body := `{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1","description":"An API"},"paths":{},"components":{"schemas":{"Foo":{"type":"string"}}}}` + _, err := w.Write([]byte(body)) + if err != nil { + panic(err) + } + })) + defer ts.Close() + + rootSpec := []byte(fmt.Sprintf( + `{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1","description":"An API"},"paths":{},"components":{"schemas":{"Bar1":{"$ref":"%s#/components/schemas/Foo"}}}}`, + ts.URL, + )) + + wg := &sync.WaitGroup{} + n := 10 + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromData(rootSpec) + require.NoError(t, err) + require.NotNil(t, doc) + }() + } + wg.Wait() +} diff --git a/openapi3/issue746_test.go b/openapi3/issue746_test.go new file mode 100644 index 000000000..390a34848 --- /dev/null +++ b/openapi3/issue746_test.go @@ -0,0 +1,26 @@ +package openapi3 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue746(t *testing.T) { + schema := &Schema{} + err := schema.UnmarshalJSON([]byte(`{"additionalProperties": false}`)) + require.NoError(t, err) + + var value interface{} + err = json.Unmarshal([]byte(`{"foo": "bar"}`), &value) + require.NoError(t, err) + + err = schema.VisitJSON(value) + require.Error(t, err) + + schemaErr := &SchemaError{} + require.ErrorAs(t, err, &schemaErr) + require.Equal(t, "properties", schemaErr.SchemaField) + require.Equal(t, `property "foo" is unsupported`, schemaErr.Reason) +} diff --git a/openapi3/issue753_test.go b/openapi3/issue753_test.go new file mode 100644 index 000000000..4390641a4 --- /dev/null +++ b/openapi3/issue753_test.go @@ -0,0 +1,20 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue753(t *testing.T) { + loader := NewLoader() + + doc, err := loader.LoadFromFile("testdata/issue753.yml") + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + require.NotNil(t, (*doc.Paths["/test1"].Post.Callbacks["callback1"].Value)["{$request.body#/callback}"].Post.RequestBody.Value.Content["application/json"].Schema.Value) + require.NotNil(t, (*doc.Paths["/test2"].Post.Callbacks["callback2"].Value)["{$request.body#/callback}"].Post.RequestBody.Value.Content["application/json"].Schema.Value) +} diff --git a/openapi3/issue759_test.go b/openapi3/issue759_test.go new file mode 100644 index 000000000..255d8b7b6 --- /dev/null +++ b/openapi3/issue759_test.go @@ -0,0 +1,34 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue759(t *testing.T) { + spec := []byte(` +openapi: 3.0.0 +info: + title: title + description: description + version: 0.0.0 +paths: + /slash: + get: + responses: + "200": + # Ref should point to a response, not a schema + $ref: "#/components/schemas/UserStruct" +components: + schemas: + UserStruct: + type: object +`[1:]) + + loader := NewLoader() + + doc, err := loader.LoadFromData(spec) + require.Nil(t, doc) + require.EqualError(t, err, `bad data in "#/components/schemas/UserStruct" (expecting ref to response object)`) +} diff --git a/openapi3/issue767_test.go b/openapi3/issue767_test.go new file mode 100644 index 000000000..d498877c9 --- /dev/null +++ b/openapi3/issue767_test.go @@ -0,0 +1,90 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestIssue767(t *testing.T) { + t.Parallel() + + tests := [...]struct { + name string + schema *openapi3.Schema + value map[string]interface{} + opts []openapi3.SchemaValidationOption + checkErr require.ErrorAssertionFunc + }{ + { + name: "default values disabled should fail with minProps 1", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", Default: true}}).WithMinProperties(1), + value: map[string]interface{}{}, + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest(), + }, + checkErr: require.Error, + }, + { + name: "default values enabled should pass with minProps 1", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", Default: true}}).WithMinProperties(1), + value: map[string]interface{}{}, + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest(), + openapi3.DefaultsSet(func() {}), + }, + checkErr: require.NoError, + }, + { + name: "default values enabled should pass with minProps 2", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", Default: true}, + "bar": {Type: "boolean"}, + }).WithMinProperties(2), + value: map[string]interface{}{"bar": false}, + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest(), + openapi3.DefaultsSet(func() {}), + }, + checkErr: require.NoError, + }, + { + name: "default values enabled should fail with maxProps 1", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", Default: true}, + "bar": {Type: "boolean"}, + }).WithMaxProperties(1), + value: map[string]interface{}{"bar": false}, + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest(), + openapi3.DefaultsSet(func() {}), + }, + checkErr: require.Error, + }, + { + name: "default values disabled should pass with maxProps 1", + schema: openapi3.NewSchema().WithProperties(map[string]*openapi3.Schema{ + "foo": {Type: "boolean", Default: true}, + "bar": {Type: "boolean"}, + }).WithMaxProperties(1), + value: map[string]interface{}{"bar": false}, + opts: []openapi3.SchemaValidationOption{ + openapi3.VisitAsRequest(), + }, + checkErr: require.NoError, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := test.schema.VisitJSON(test.value, test.opts...) + test.checkErr(t, err) + }) + } +} diff --git a/openapi3/link.go b/openapi3/link.go index 2c1ec013f..08dfa8d67 100644 --- a/openapi3/link.go +++ b/openapi3/link.go @@ -2,37 +2,100 @@ package openapi3 import ( "context" + "encoding/json" "errors" "fmt" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" ) -// Link is specified by OpenAPI/Swagger standard version 3.0. +type Links map[string]*LinkRef + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (links Links) JSONLookup(token string) (interface{}, error) { + ref, ok := links[token] + if ok == false { + return nil, fmt.Errorf("object has no field %q", token) + } + + if ref != nil && ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + +var _ jsonpointer.JSONPointable = (*Links)(nil) + +// Link is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#link-object type Link struct { - ExtensionProps - OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` + Extensions map[string]interface{} `json:"-" yaml:"-"` + OperationRef string `json:"operationRef,omitempty" yaml:"operationRef,omitempty"` + OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"` Server *Server `json:"server,omitempty" yaml:"server,omitempty"` RequestBody interface{} `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` } -func (value *Link) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(value) +// MarshalJSON returns the JSON encoding of Link. +func (link Link) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 6+len(link.Extensions)) + for k, v := range link.Extensions { + m[k] = v + } + + if x := link.OperationRef; x != "" { + m["operationRef"] = x + } + if x := link.OperationID; x != "" { + m["operationId"] = x + } + if x := link.Description; x != "" { + m["description"] = x + } + if x := link.Parameters; len(x) != 0 { + m["parameters"] = x + } + if x := link.Server; x != nil { + m["server"] = x + } + if x := link.RequestBody; x != nil { + m["requestBody"] = x + } + + return json.Marshal(m) } -func (value *Link) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, value) +// UnmarshalJSON sets Link to a copy of data. +func (link *Link) UnmarshalJSON(data []byte) error { + type LinkBis Link + var x LinkBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "operationRef") + delete(x.Extensions, "operationId") + delete(x.Extensions, "description") + delete(x.Extensions, "parameters") + delete(x.Extensions, "server") + delete(x.Extensions, "requestBody") + *link = Link(x) + return nil } -func (value *Link) Validate(c context.Context) error { - if value.OperationID == "" && value.OperationRef == "" { +// Validate returns an error if Link does not comply with the OpenAPI spec. +func (link *Link) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if link.OperationID == "" && link.OperationRef == "" { return errors.New("missing operationId or operationRef on link") } - if value.OperationID != "" && value.OperationRef != "" { - return fmt.Errorf("operationId '%s' and operationRef '%s' are mutually exclusive", value.OperationID, value.OperationRef) + if link.OperationID != "" && link.OperationRef != "" { + return fmt.Errorf("operationId %q and operationRef %q are mutually exclusive", link.OperationID, link.OperationRef) } - return nil + + return validateExtensions(ctx, link.Extensions) } diff --git a/openapi3/load_cicular_ref_with_external_file_test.go b/openapi3/load_cicular_ref_with_external_file_test.go new file mode 100644 index 000000000..9bcaaf77f --- /dev/null +++ b/openapi3/load_cicular_ref_with_external_file_test.go @@ -0,0 +1,70 @@ +//go:build go1.16 +// +build go1.16 + +package openapi3_test + +import ( + "embed" + "encoding/json" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +//go:embed testdata/circularRef/* +var circularResSpecs embed.FS + +func TestLoadCircularRefFromFile(t *testing.T) { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, uri *url.URL) ([]byte, error) { + return circularResSpecs.ReadFile(uri.Path) + } + + got, err := loader.LoadFromFile("testdata/circularRef/base.yml") + require.NoError(t, err) + + foo := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "foo2": { + Ref: "other.yml#/components/schemas/Foo2", // reference to an external file + Value: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "id": { + Value: &openapi3.Schema{Type: "string"}}, + }, + }, + }, + }, + }, + } + bar := &openapi3.SchemaRef{Value: &openapi3.Schema{Properties: make(map[string]*openapi3.SchemaRef)}} + // circular reference + bar.Value.Properties["foo"] = &openapi3.SchemaRef{Ref: "#/components/schemas/Foo", Value: foo.Value} + foo.Value.Properties["bar"] = &openapi3.SchemaRef{Ref: "#/components/schemas/Bar", Value: bar.Value} + + want := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{ + Title: "Recursive cyclic refs example", + Version: "1.0", + }, + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "Foo": foo, + "Bar": bar, + }, + }, + } + + jsoner := func(doc *openapi3.T) string { + data, err := json.Marshal(doc) + require.NoError(t, err) + return string(data) + } + require.JSONEq(t, jsoner(want), jsoner(got)) +} diff --git a/openapi3/load_with_go_embed_test.go b/openapi3/load_with_go_embed_test.go new file mode 100644 index 000000000..e0fb915ba --- /dev/null +++ b/openapi3/load_with_go_embed_test.go @@ -0,0 +1,35 @@ +//go:build go1.16 +// +build go1.16 + +package openapi3_test + +import ( + "embed" + "fmt" + "net/url" + + "github.com/getkin/kin-openapi/openapi3" +) + +//go:embed testdata/recursiveRef/* +var fs embed.FS + +func Example() { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, uri *url.URL) ([]byte, error) { + return fs.ReadFile(uri.Path) + } + + doc, err := loader.LoadFromFile("testdata/recursiveRef/openapi.yml") + if err != nil { + panic(err) + } + + if err = doc.Validate(loader.Context); err != nil { + panic(err) + } + + fmt.Println(doc.Paths["/foo"].Get.Responses["200"].Value.Content["application/json"].Schema.Value.Properties["foo2"].Value.Properties["foo"].Value.Properties["bar"].Value.Type) + // Output: string +} diff --git a/openapi3/loader.go b/openapi3/loader.go new file mode 100644 index 000000000..4a14f67f0 --- /dev/null +++ b/openapi3/loader.go @@ -0,0 +1,1039 @@ +package openapi3 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "path" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/invopop/yaml" +) + +var CircularReferenceError = "kin-openapi bug found: circular schema reference not handled" +var CircularReferenceCounter = 3 + +func foundUnresolvedRef(ref string) error { + return fmt.Errorf("found unresolved ref: %q", ref) +} + +func failedToResolveRefFragmentPart(value, what string) error { + return fmt.Errorf("failed to resolve %q in fragment in URI: %q", what, value) +} + +// Loader helps deserialize an OpenAPIv3 document +type Loader struct { + // IsExternalRefsAllowed enables visiting other files + IsExternalRefsAllowed bool + + // ReadFromURIFunc allows overriding the any file/URL reading func + ReadFromURIFunc ReadFromURIFunc + + Context context.Context + + rootDir string + rootLocation string + + visitedPathItemRefs map[string]struct{} + + visitedDocuments map[string]*T + + visitedCallback map[*Callback]struct{} + visitedExample map[*Example]struct{} + visitedHeader map[*Header]struct{} + visitedLink map[*Link]struct{} + visitedParameter map[*Parameter]struct{} + visitedRequestBody map[*RequestBody]struct{} + visitedResponse map[*Response]struct{} + visitedSchema map[*Schema]struct{} + visitedSecurityScheme map[*SecurityScheme]struct{} +} + +// NewLoader returns an empty Loader +func NewLoader() *Loader { + return &Loader{ + Context: context.Background(), + } +} + +func (loader *Loader) resetVisitedPathItemRefs() { + loader.visitedPathItemRefs = make(map[string]struct{}) +} + +// LoadFromURI loads a spec from a remote URL +func (loader *Loader) LoadFromURI(location *url.URL) (*T, error) { + loader.resetVisitedPathItemRefs() + return loader.loadFromURIInternal(location) +} + +// LoadFromFile loads a spec from a local file path +func (loader *Loader) LoadFromFile(location string) (*T, error) { + loader.rootDir = path.Dir(location) + return loader.LoadFromURI(&url.URL{Path: filepath.ToSlash(location)}) +} + +func (loader *Loader) loadFromURIInternal(location *url.URL) (*T, error) { + data, err := loader.readURL(location) + if err != nil { + return nil, err + } + return loader.loadFromDataWithPathInternal(data, location) +} + +func (loader *Loader) allowsExternalRefs(ref string) (err error) { + if !loader.IsExternalRefsAllowed { + err = fmt.Errorf("encountered disallowed external reference: %q", ref) + } + return +} + +// loadSingleElementFromURI reads the data from ref and unmarshals to the passed element. +func (loader *Loader) loadSingleElementFromURI(ref string, rootPath *url.URL, element interface{}) (*url.URL, error) { + if err := loader.allowsExternalRefs(ref); err != nil { + return nil, err + } + + parsedURL, err := url.Parse(ref) + if err != nil { + return nil, err + } + if fragment := parsedURL.Fragment; fragment != "" { + return nil, fmt.Errorf("unexpected ref fragment %q", fragment) + } + + resolvedPath, err := resolvePath(rootPath, parsedURL) + if err != nil { + return nil, fmt.Errorf("could not resolve path: %w", err) + } + + data, err := loader.readURL(resolvedPath) + if err != nil { + return nil, err + } + if err := unmarshal(data, element); err != nil { + return nil, err + } + + return resolvedPath, nil +} + +func (loader *Loader) readURL(location *url.URL) ([]byte, error) { + if f := loader.ReadFromURIFunc; f != nil { + return f(loader, location) + } + return DefaultReadFromURI(loader, location) +} + +// LoadFromData loads a spec from a byte array +func (loader *Loader) LoadFromData(data []byte) (*T, error) { + loader.resetVisitedPathItemRefs() + doc := &T{} + if err := unmarshal(data, doc); err != nil { + return nil, err + } + if err := loader.ResolveRefsIn(doc, nil); err != nil { + return nil, err + } + return doc, nil +} + +// LoadFromDataWithPath takes the OpenAPI document data in bytes and a path where the resolver can find referred +// elements and returns a *T with all resolved data or an error if unable to load data or resolve refs. +func (loader *Loader) LoadFromDataWithPath(data []byte, location *url.URL) (*T, error) { + loader.resetVisitedPathItemRefs() + return loader.loadFromDataWithPathInternal(data, location) +} + +func (loader *Loader) loadFromDataWithPathInternal(data []byte, location *url.URL) (*T, error) { + if loader.visitedDocuments == nil { + loader.visitedDocuments = make(map[string]*T) + loader.rootLocation = location.Path + } + uri := location.String() + if doc, ok := loader.visitedDocuments[uri]; ok { + return doc, nil + } + + doc := &T{} + loader.visitedDocuments[uri] = doc + + if err := unmarshal(data, doc); err != nil { + return nil, err + } + if err := loader.ResolveRefsIn(doc, location); err != nil { + return nil, err + } + + return doc, nil +} + +func unmarshal(data []byte, v interface{}) error { + // See https://github.com/getkin/kin-openapi/issues/680 + if err := json.Unmarshal(data, v); err != nil { + // UnmarshalStrict(data, v) TODO: investigate how ymlv3 handles duplicate map keys + return yaml.Unmarshal(data, v) + } + return nil +} + +// ResolveRefsIn expands references if for instance spec was just unmarshalled +func (loader *Loader) ResolveRefsIn(doc *T, location *url.URL) (err error) { + if loader.Context == nil { + loader.Context = context.Background() + } + + if loader.visitedPathItemRefs == nil { + loader.resetVisitedPathItemRefs() + } + + if components := doc.Components; components != nil { + for _, component := range components.Headers { + if err = loader.resolveHeaderRef(doc, component, location); err != nil { + return + } + } + for _, component := range components.Parameters { + if err = loader.resolveParameterRef(doc, component, location); err != nil { + return + } + } + for _, component := range components.RequestBodies { + if err = loader.resolveRequestBodyRef(doc, component, location); err != nil { + return + } + } + for _, component := range components.Responses { + if err = loader.resolveResponseRef(doc, component, location); err != nil { + return + } + } + for _, component := range components.Schemas { + if err = loader.resolveSchemaRef(doc, component, location, []string{}); err != nil { + return + } + } + for _, component := range components.SecuritySchemes { + if err = loader.resolveSecuritySchemeRef(doc, component, location); err != nil { + return + } + } + + examples := make([]string, 0, len(components.Examples)) + for name := range components.Examples { + examples = append(examples, name) + } + sort.Strings(examples) + for _, name := range examples { + component := components.Examples[name] + if err = loader.resolveExampleRef(doc, component, location); err != nil { + return + } + } + + for _, component := range components.Callbacks { + if err = loader.resolveCallbackRef(doc, component, location); err != nil { + return + } + } + } + + // Visit all operations + for _, pathItem := range doc.Paths { + if pathItem == nil { + continue + } + if err = loader.resolvePathItemRef(doc, pathItem, location); err != nil { + return + } + } + + return +} + +func join(basePath *url.URL, relativePath *url.URL) (*url.URL, error) { + if basePath == nil { + return relativePath, nil + } + newPath, err := url.Parse(basePath.String()) + if err != nil { + return nil, fmt.Errorf("cannot copy path: %q", basePath.String()) + } + newPath.Path = path.Join(path.Dir(newPath.Path), relativePath.Path) + return newPath, nil +} + +func resolvePath(basePath *url.URL, componentPath *url.URL) (*url.URL, error) { + if componentPath.Scheme == "" && componentPath.Host == "" { + // support absolute paths + if componentPath.Path[0] == '/' { + return componentPath, nil + } + return join(basePath, componentPath) + } + return componentPath, nil +} + +func isSingleRefElement(ref string) bool { + return !strings.Contains(ref, "#") +} + +func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolved interface{}) ( + componentDoc *T, + componentPath *url.URL, + err error, +) { + if componentDoc, ref, componentPath, err = loader.resolveRef(doc, ref, path); err != nil { + return nil, nil, err + } + + parsedURL, err := url.Parse(ref) + if err != nil { + return nil, nil, fmt.Errorf("cannot parse reference: %q: %v", ref, parsedURL) + } + fragment := parsedURL.Fragment + if !strings.HasPrefix(fragment, "/") { + return nil, nil, fmt.Errorf("expected fragment prefix '#/' in URI %q", ref) + } + + drill := func(cursor interface{}) (interface{}, error) { + for _, pathPart := range strings.Split(fragment[1:], "/") { + pathPart = unescapeRefString(pathPart) + + if cursor, err = drillIntoField(cursor, pathPart); err != nil { + e := failedToResolveRefFragmentPart(ref, pathPart) + return nil, fmt.Errorf("%s: %w", e, err) + } + if cursor == nil { + return nil, failedToResolveRefFragmentPart(ref, pathPart) + } + } + return cursor, nil + } + var cursor interface{} + if cursor, err = drill(componentDoc); err != nil { + if path == nil { + return nil, nil, err + } + var err2 error + data, err2 := loader.readURL(path) + if err2 != nil { + return nil, nil, err + } + if err2 = unmarshal(data, &cursor); err2 != nil { + return nil, nil, err + } + if cursor, err2 = drill(cursor); err2 != nil || cursor == nil { + return nil, nil, err + } + err = nil + } + + switch { + case reflect.TypeOf(cursor) == reflect.TypeOf(resolved): + reflect.ValueOf(resolved).Elem().Set(reflect.ValueOf(cursor).Elem()) + return componentDoc, componentPath, nil + + case reflect.TypeOf(cursor) == reflect.TypeOf(map[string]interface{}{}): + codec := func(got, expect interface{}) error { + enc, err := json.Marshal(got) + if err != nil { + return err + } + if err = json.Unmarshal(enc, expect); err != nil { + return err + } + return nil + } + if err := codec(cursor, resolved); err != nil { + return nil, nil, fmt.Errorf("bad data in %q (expecting %s)", ref, readableType(resolved)) + } + return componentDoc, componentPath, nil + + default: + return nil, nil, fmt.Errorf("bad data in %q (expecting %s)", ref, readableType(resolved)) + } +} + +func readableType(x interface{}) string { + switch x.(type) { + case *Callback: + return "callback object" + case *CallbackRef: + return "ref to callback object" + case *ExampleRef: + return "ref to example object" + case *HeaderRef: + return "ref to header object" + case *LinkRef: + return "ref to link object" + case *ParameterRef: + return "ref to parameter object" + case *PathItem: + return "pathItem object" + case *RequestBodyRef: + return "ref to requestBody object" + case *ResponseRef: + return "ref to response object" + case *SchemaRef: + return "ref to schema object" + case *SecuritySchemeRef: + return "ref to securityScheme object" + default: + panic(fmt.Sprintf("unreachable %T", x)) + } +} + +func drillIntoField(cursor interface{}, fieldName string) (interface{}, error) { + // Special case due to multijson + if s, ok := cursor.(*SchemaRef); ok && fieldName == "additionalProperties" { + if ap := s.Value.AdditionalProperties.Has; ap != nil { + return *ap, nil + } + return s.Value.AdditionalProperties.Schema, nil + } + + switch val := reflect.Indirect(reflect.ValueOf(cursor)); val.Kind() { + case reflect.Map: + elementValue := val.MapIndex(reflect.ValueOf(fieldName)) + if !elementValue.IsValid() { + return nil, fmt.Errorf("map key %q not found", fieldName) + } + return elementValue.Interface(), nil + + case reflect.Slice: + i, err := strconv.ParseUint(fieldName, 10, 32) + if err != nil { + return nil, err + } + index := int(i) + if 0 > index || index >= val.Len() { + return nil, errors.New("slice index out of bounds") + } + return val.Index(index).Interface(), nil + + case reflect.Struct: + hasFields := false + for i := 0; i < val.NumField(); i++ { + hasFields = true + if fieldName == strings.Split(val.Type().Field(i).Tag.Get("yaml"), ",")[0] { + return val.Field(i).Interface(), nil + } + } + // if cursor is a "ref wrapper" struct (e.g. RequestBodyRef), + if _, ok := val.Type().FieldByName("Value"); ok { + // try digging into its Value field + return drillIntoField(val.FieldByName("Value").Interface(), fieldName) + } + if hasFields { + if ff := val.Type().Field(0); ff.PkgPath == "" && ff.Name == "Extensions" { + extensions := val.Field(0).Interface().(map[string]interface{}) + if enc, ok := extensions[fieldName]; ok { + return enc, nil + } + } + } + return nil, fmt.Errorf("struct field %q not found", fieldName) + + default: + return nil, errors.New("not a map, slice nor struct") + } +} + +func (loader *Loader) resolveRef(doc *T, ref string, path *url.URL) (*T, string, *url.URL, error) { + if ref != "" && ref[0] == '#' { + return doc, ref, path, nil + } + + if err := loader.allowsExternalRefs(ref); err != nil { + return nil, "", nil, err + } + + parsedURL, err := url.Parse(ref) + if err != nil { + return nil, "", nil, fmt.Errorf("cannot parse reference: %q: %v", ref, parsedURL) + } + fragment := parsedURL.Fragment + parsedURL.Fragment = "" + + var resolvedPath *url.URL + if resolvedPath, err = resolvePath(path, parsedURL); err != nil { + return nil, "", nil, fmt.Errorf("error resolving path: %w", err) + } + + if doc, err = loader.loadFromURIInternal(resolvedPath); err != nil { + return nil, "", nil, fmt.Errorf("error resolving reference %q: %w", ref, err) + } + + return doc, "#" + fragment, resolvedPath, nil +} + +func (loader *Loader) resolveHeaderRef(doc *T, component *HeaderRef, documentPath *url.URL) (err error) { + if component != nil && component.Value != nil { + if loader.visitedHeader == nil { + loader.visitedHeader = make(map[*Header]struct{}) + } + if _, ok := loader.visitedHeader[component.Value]; ok { + return nil + } + loader.visitedHeader[component.Value] = struct{}{} + } + + if component == nil { + return errors.New("invalid header: value MUST be an object") + } + if ref := component.Ref; ref != "" { + if isSingleRefElement(ref) { + var header Header + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &header); err != nil { + return err + } + component.Value = &header + } else { + var resolved HeaderRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err := loader.resolveHeaderRef(doc, &resolved, componentPath); err != nil { + return err + } + component.Value = resolved.Value + } + } + value := component.Value + if value == nil { + return nil + } + + if schema := value.Schema; schema != nil { + if err := loader.resolveSchemaRef(doc, schema, documentPath, []string{}); err != nil { + return err + } + } + return nil +} + +func (loader *Loader) resolveParameterRef(doc *T, component *ParameterRef, documentPath *url.URL) (err error) { + if component != nil && component.Value != nil { + if loader.visitedParameter == nil { + loader.visitedParameter = make(map[*Parameter]struct{}) + } + if _, ok := loader.visitedParameter[component.Value]; ok { + return nil + } + loader.visitedParameter[component.Value] = struct{}{} + } + + if component == nil { + return errors.New("invalid parameter: value MUST be an object") + } + ref := component.Ref + if ref != "" { + if isSingleRefElement(ref) { + var param Parameter + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, ¶m); err != nil { + return err + } + component.Value = ¶m + } else { + var resolved ParameterRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err := loader.resolveParameterRef(doc, &resolved, componentPath); err != nil { + return err + } + component.Value = resolved.Value + } + } + value := component.Value + if value == nil { + return nil + } + + if value.Content != nil && value.Schema != nil { + return errors.New("cannot contain both schema and content in a parameter") + } + for _, contentType := range value.Content { + if schema := contentType.Schema; schema != nil { + if err := loader.resolveSchemaRef(doc, schema, documentPath, []string{}); err != nil { + return err + } + } + } + if schema := value.Schema; schema != nil { + if err := loader.resolveSchemaRef(doc, schema, documentPath, []string{}); err != nil { + return err + } + } + return nil +} + +func (loader *Loader) resolveRequestBodyRef(doc *T, component *RequestBodyRef, documentPath *url.URL) (err error) { + if component != nil && component.Value != nil { + if loader.visitedRequestBody == nil { + loader.visitedRequestBody = make(map[*RequestBody]struct{}) + } + if _, ok := loader.visitedRequestBody[component.Value]; ok { + return nil + } + loader.visitedRequestBody[component.Value] = struct{}{} + } + + if component == nil { + return errors.New("invalid requestBody: value MUST be an object") + } + if ref := component.Ref; ref != "" { + if isSingleRefElement(ref) { + var requestBody RequestBody + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &requestBody); err != nil { + return err + } + component.Value = &requestBody + } else { + var resolved RequestBodyRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err = loader.resolveRequestBodyRef(doc, &resolved, componentPath); err != nil { + return err + } + component.Value = resolved.Value + } + } + value := component.Value + if value == nil { + return nil + } + + for _, contentType := range value.Content { + examples := make([]string, 0, len(contentType.Examples)) + for name := range contentType.Examples { + examples = append(examples, name) + } + sort.Strings(examples) + for _, name := range examples { + example := contentType.Examples[name] + if err := loader.resolveExampleRef(doc, example, documentPath); err != nil { + return err + } + contentType.Examples[name] = example + } + if schema := contentType.Schema; schema != nil { + if err := loader.resolveSchemaRef(doc, schema, documentPath, []string{}); err != nil { + return err + } + } + } + return nil +} + +func (loader *Loader) resolveResponseRef(doc *T, component *ResponseRef, documentPath *url.URL) (err error) { + if component != nil && component.Value != nil { + if loader.visitedResponse == nil { + loader.visitedResponse = make(map[*Response]struct{}) + } + if _, ok := loader.visitedResponse[component.Value]; ok { + return nil + } + loader.visitedResponse[component.Value] = struct{}{} + } + + if component == nil { + return errors.New("invalid response: value MUST be an object") + } + ref := component.Ref + if ref != "" { + if isSingleRefElement(ref) { + var resp Response + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resp); err != nil { + return err + } + component.Value = &resp + } else { + var resolved ResponseRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err := loader.resolveResponseRef(doc, &resolved, componentPath); err != nil { + return err + } + component.Value = resolved.Value + } + } + value := component.Value + if value == nil { + return nil + } + + for _, header := range value.Headers { + if err := loader.resolveHeaderRef(doc, header, documentPath); err != nil { + return err + } + } + for _, contentType := range value.Content { + if contentType == nil { + continue + } + examples := make([]string, 0, len(contentType.Examples)) + for name := range contentType.Examples { + examples = append(examples, name) + } + sort.Strings(examples) + for _, name := range examples { + example := contentType.Examples[name] + if err := loader.resolveExampleRef(doc, example, documentPath); err != nil { + return err + } + contentType.Examples[name] = example + } + if schema := contentType.Schema; schema != nil { + if err := loader.resolveSchemaRef(doc, schema, documentPath, []string{}); err != nil { + return err + } + contentType.Schema = schema + } + } + for _, link := range value.Links { + if err := loader.resolveLinkRef(doc, link, documentPath); err != nil { + return err + } + } + return nil +} + +func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPath *url.URL, visited []string) (err error) { + if component == nil { + return errors.New("invalid schema: value MUST be an object") + } + + if component.Value != nil { + if loader.visitedSchema == nil { + loader.visitedSchema = make(map[*Schema]struct{}) + } + if _, ok := loader.visitedSchema[component.Value]; ok { + return nil + } + loader.visitedSchema[component.Value] = struct{}{} + } + + ref := component.Ref + if ref != "" { + if isSingleRefElement(ref) { + var schema Schema + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &schema); err != nil { + return err + } + component.Value = &schema + } else { + if visitedLimit(visited, ref) { + visited = append(visited, ref) + return fmt.Errorf("%s - %s", CircularReferenceError, strings.Join(visited, " -> ")) + } + visited = append(visited, ref) + + var resolved SchemaRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err := loader.resolveSchemaRef(doc, &resolved, componentPath, visited); err != nil { + return err + } + component.Value = resolved.Value + } + if loader.visitedSchema == nil { + loader.visitedSchema = make(map[*Schema]struct{}) + } + loader.visitedSchema[component.Value] = struct{}{} + } + value := component.Value + if value == nil { + return nil + } + + // ResolveRefs referred schemas + if v := value.Items; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + for _, v := range value.Properties { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.AdditionalProperties.Schema; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + if v := value.Not; v != nil { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + for _, v := range value.AllOf { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + for _, v := range value.AnyOf { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + for _, v := range value.OneOf { + if err := loader.resolveSchemaRef(doc, v, documentPath, visited); err != nil { + return err + } + } + return nil +} + +func (loader *Loader) resolveSecuritySchemeRef(doc *T, component *SecuritySchemeRef, documentPath *url.URL) (err error) { + if component != nil && component.Value != nil { + if loader.visitedSecurityScheme == nil { + loader.visitedSecurityScheme = make(map[*SecurityScheme]struct{}) + } + if _, ok := loader.visitedSecurityScheme[component.Value]; ok { + return nil + } + loader.visitedSecurityScheme[component.Value] = struct{}{} + } + + if component == nil { + return errors.New("invalid securityScheme: value MUST be an object") + } + if ref := component.Ref; ref != "" { + if isSingleRefElement(ref) { + var scheme SecurityScheme + if _, err = loader.loadSingleElementFromURI(ref, documentPath, &scheme); err != nil { + return err + } + component.Value = &scheme + } else { + var resolved SecuritySchemeRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err := loader.resolveSecuritySchemeRef(doc, &resolved, componentPath); err != nil { + return err + } + component.Value = resolved.Value + } + } + return nil +} + +func (loader *Loader) resolveExampleRef(doc *T, component *ExampleRef, documentPath *url.URL) (err error) { + if component != nil && component.Value != nil { + if loader.visitedExample == nil { + loader.visitedExample = make(map[*Example]struct{}) + } + if _, ok := loader.visitedExample[component.Value]; ok { + return nil + } + loader.visitedExample[component.Value] = struct{}{} + } + + if component == nil { + return errors.New("invalid example: value MUST be an object") + } + if ref := component.Ref; ref != "" { + if isSingleRefElement(ref) { + var example Example + if _, err = loader.loadSingleElementFromURI(ref, documentPath, &example); err != nil { + return err + } + component.Value = &example + } else { + var resolved ExampleRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err := loader.resolveExampleRef(doc, &resolved, componentPath); err != nil { + return err + } + component.Value = resolved.Value + } + } + return nil +} + +func (loader *Loader) resolveCallbackRef(doc *T, component *CallbackRef, documentPath *url.URL) (err error) { + if component != nil && component.Value != nil { + if loader.visitedCallback == nil { + loader.visitedCallback = make(map[*Callback]struct{}) + } + if _, ok := loader.visitedCallback[component.Value]; ok { + return nil + } + loader.visitedCallback[component.Value] = struct{}{} + } + + if component == nil { + return errors.New("invalid callback: value MUST be an object") + } + if ref := component.Ref; ref != "" { + if isSingleRefElement(ref) { + var resolved Callback + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { + return err + } + component.Value = &resolved + } else { + var resolved CallbackRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err = loader.resolveCallbackRef(doc, &resolved, componentPath); err != nil { + return err + } + component.Value = resolved.Value + } + } + value := component.Value + if value == nil { + return nil + } + + for _, pathItem := range *value { + if err = loader.resolvePathItemRef(doc, pathItem, documentPath); err != nil { + return err + } + } + return nil +} + +func (loader *Loader) resolveLinkRef(doc *T, component *LinkRef, documentPath *url.URL) (err error) { + if component != nil && component.Value != nil { + if loader.visitedLink == nil { + loader.visitedLink = make(map[*Link]struct{}) + } + if _, ok := loader.visitedLink[component.Value]; ok { + return nil + } + loader.visitedLink[component.Value] = struct{}{} + } + + if component == nil { + return errors.New("invalid link: value MUST be an object") + } + if ref := component.Ref; ref != "" { + if isSingleRefElement(ref) { + var link Link + if _, err = loader.loadSingleElementFromURI(ref, documentPath, &link); err != nil { + return err + } + component.Value = &link + } else { + var resolved LinkRef + doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) + if err != nil { + return err + } + if err := loader.resolveLinkRef(doc, &resolved, componentPath); err != nil { + return err + } + component.Value = resolved.Value + } + } + return nil +} + +func (loader *Loader) resolvePathItemRef(doc *T, pathItem *PathItem, documentPath *url.URL) (err error) { + if pathItem == nil { + return errors.New("invalid path item: value MUST be an object") + } + ref := pathItem.Ref + if ref != "" { + if pathItem.Summary != "" || + pathItem.Description != "" || + pathItem.Connect != nil || + pathItem.Delete != nil || + pathItem.Get != nil || + pathItem.Head != nil || + pathItem.Options != nil || + pathItem.Patch != nil || + pathItem.Post != nil || + pathItem.Put != nil || + pathItem.Trace != nil || + len(pathItem.Servers) != 0 || + len(pathItem.Parameters) != 0 { + return nil + } + if isSingleRefElement(ref) { + var p PathItem + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &p); err != nil { + return err + } + *pathItem = p + } else { + var resolved PathItem + if doc, documentPath, err = loader.resolveComponent(doc, ref, documentPath, &resolved); err != nil { + return err + } + *pathItem = resolved + } + pathItem.Ref = ref + } + + for _, parameter := range pathItem.Parameters { + if err = loader.resolveParameterRef(doc, parameter, documentPath); err != nil { + return + } + } + for _, operation := range pathItem.Operations() { + for _, parameter := range operation.Parameters { + if err = loader.resolveParameterRef(doc, parameter, documentPath); err != nil { + return + } + } + if requestBody := operation.RequestBody; requestBody != nil { + if err = loader.resolveRequestBodyRef(doc, requestBody, documentPath); err != nil { + return + } + } + for _, response := range operation.Responses { + if err = loader.resolveResponseRef(doc, response, documentPath); err != nil { + return + } + } + for _, callback := range operation.Callbacks { + if err = loader.resolveCallbackRef(doc, callback, documentPath); err != nil { + return + } + } + } + return +} + +func unescapeRefString(ref string) string { + return strings.Replace(strings.Replace(ref, "~1", "/", -1), "~0", "~", -1) +} + +func visitedLimit(visited []string, ref string) bool { + visitedCount := 0 + for _, v := range visited { + if v == ref { + visitedCount++ + if visitedCount >= CircularReferenceCounter { + return true + } + } + } + return false +} diff --git a/openapi3/swagger_loader_empty_response_description_test.go b/openapi3/loader_empty_response_description_test.go similarity index 79% rename from openapi3/swagger_loader_empty_response_description_test.go rename to openapi3/loader_empty_response_description_test.go index 5199ac169..3c4b6bffd 100644 --- a/openapi3/swagger_loader_empty_response_description_test.go +++ b/openapi3/loader_empty_response_description_test.go @@ -1,11 +1,10 @@ -package openapi3_test +package openapi3 import ( "encoding/json" "strings" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) @@ -34,8 +33,8 @@ func TestJSONSpecResponseDescriptionEmptiness(t *testing.T) { { spec := []byte(spec) - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(spec) + loader := NewLoader() + doc, err := loader.LoadFromData(spec) require.NoError(t, err) got := doc.Paths["/path1"].Get.Responses["200"].Value.Description expected := "" @@ -47,8 +46,8 @@ func TestJSONSpecResponseDescriptionEmptiness(t *testing.T) { { spec := []byte(strings.Replace(spec, `"description": ""`, `"description": "My response"`, 1)) - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(spec) + loader := NewLoader() + doc, err := loader.LoadFromData(spec) require.NoError(t, err) got := doc.Paths["/path1"].Get.Responses["200"].Value.Description expected := "My response" @@ -58,9 +57,9 @@ func TestJSONSpecResponseDescriptionEmptiness(t *testing.T) { require.NoError(t, err) } - noDescriptionIsInvalid := func(data []byte) *openapi3.Swagger { - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(data) + noDescriptionIsInvalid := func(data []byte) *T { + loader := NewLoader() + doc, err := loader.LoadFromData(data) require.NoError(t, err) got := doc.Paths["/path1"].Get.Responses["200"].Value.Description require.Nil(t, got) @@ -70,7 +69,7 @@ func TestJSONSpecResponseDescriptionEmptiness(t *testing.T) { return doc } - var docWithNoResponseDescription *openapi3.Swagger + var docWithNoResponseDescription *T { spec := []byte(strings.Replace(spec, `"description": ""`, ``, 1)) docWithNoResponseDescription = noDescriptionIsInvalid(spec) diff --git a/openapi3/loader_http_error_test.go b/openapi3/loader_http_error_test.go new file mode 100644 index 000000000..5f7f137c8 --- /dev/null +++ b/openapi3/loader_http_error_test.go @@ -0,0 +1,99 @@ +package openapi3 + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadReferenceFromRemoteURLFailsWithHttpError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "") + })) + defer ts.Close() + + spec := []byte(` +{ + "openapi": "3.0.0", + "info": { + "title": "", + "version": "1" + }, + "paths": { + "/test": { + "post": { + "responses": { + "default": { + "description": "test", + "headers": { + "X-TEST-HEADER": { + "$ref": "` + ts.URL + `/components.openapi.json#/components/headers/CustomTestHeader" + } + } + } + } + } + } + } +}`) + + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) + + require.Nil(t, doc) + require.EqualError(t, err, fmt.Sprintf("error resolving reference \"%s/components.openapi.json#/components/headers/CustomTestHeader\": error loading \"%s/components.openapi.json\": request returned status code 400", ts.URL, ts.URL)) + + doc, err = loader.LoadFromData(spec) + require.Nil(t, doc) + require.EqualError(t, err, fmt.Sprintf("error resolving reference \"%s/components.openapi.json#/components/headers/CustomTestHeader\": error loading \"%s/components.openapi.json\": request returned status code 400", ts.URL, ts.URL)) +} + +func TestLoadFromRemoteURLFailsWithHttpError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "") + })) + defer ts.Close() + + spec := []byte(` +{ + "openapi": "3.0.0", + "info": { + "title": "", + "version": "1" + }, + "paths": { + "/test": { + "post": { + "responses": { + "default": { + "description": "test", + "headers": { + "X-TEST-HEADER": { + "$ref": "` + ts.URL + `/components.openapi.json" + } + } + } + } + } + } + } +}`) + + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) + + require.Nil(t, doc) + require.EqualError(t, err, fmt.Sprintf("error loading \"%s/components.openapi.json\": request returned status code 400", ts.URL)) + + doc, err = loader.LoadFromData(spec) + require.Nil(t, doc) + require.EqualError(t, err, fmt.Sprintf("error loading \"%s/components.openapi.json\": request returned status code 400", ts.URL)) +} diff --git a/openapi3/swagger_loader_issue212_test.go b/openapi3/loader_issue212_test.go similarity index 94% rename from openapi3/swagger_loader_issue212_test.go rename to openapi3/loader_issue212_test.go index 1999db4d3..252d0d224 100644 --- a/openapi3/swagger_loader_issue212_test.go +++ b/openapi3/loader_issue212_test.go @@ -72,8 +72,8 @@ components: pattern: ^\/images\/[0-9a-f]{64}$ ` - loader := NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData([]byte(spec)) + loader := NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) require.NoError(t, err) err = doc.Validate(loader.Context) require.NoError(t, err) @@ -81,7 +81,7 @@ components: expected, err := json.Marshal(&Schema{ Type: "object", Required: []string{"id", "uri"}, - Properties: map[string]*SchemaRef{ + Properties: Schemas{ "id": {Value: &Schema{Type: "string"}}, "uri": {Value: &Schema{Type: "string"}}, }, diff --git a/openapi3/loader_issue220_test.go b/openapi3/loader_issue220_test.go new file mode 100644 index 000000000..57a44d5d0 --- /dev/null +++ b/openapi3/loader_issue220_test.go @@ -0,0 +1,27 @@ +package openapi3 + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue220(t *testing.T) { + for _, specPath := range []string{ + "testdata/my-openapi.json", + filepath.FromSlash("testdata/my-openapi.json"), + } { + t.Logf("specPath: %q", specPath) + + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(specPath) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + require.Equal(t, "integer", doc.Paths["/foo"].Get.Responses["200"].Value.Content["application/json"].Schema.Value.Properties["bar"].Value.Type) + } +} diff --git a/openapi3/loader_issue235_test.go b/openapi3/loader_issue235_test.go new file mode 100644 index 000000000..4cb54eff1 --- /dev/null +++ b/openapi3/loader_issue235_test.go @@ -0,0 +1,25 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue235OK(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/issue235.spec0.yml") + require.NoError(t, err) + err = doc.Validate(loader.Context) + require.NoError(t, err) +} + +func TestIssue235CircularDep(t *testing.T) { + t.Skip("TODO: return an error on circular dependencies between external files of a spec") + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/issue235.spec0-typo.yml") + require.Nil(t, doc) + require.Error(t, err) +} diff --git a/openapi3/loader_outside_refs_test.go b/openapi3/loader_outside_refs_test.go new file mode 100644 index 000000000..5cec93452 --- /dev/null +++ b/openapi3/loader_outside_refs_test.go @@ -0,0 +1,20 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadOutsideRefs(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/303bis/service.yaml") + require.NoError(t, err) + require.NotNil(t, doc) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + require.Equal(t, "string", doc.Paths["/service"].Get.Responses["200"].Value.Content["application/json"].Schema.Value.Items.Value.AllOf[0].Value.Properties["created_at"].Value.Type) +} diff --git a/openapi3/swagger_loader_paths_test.go b/openapi3/loader_paths_test.go similarity index 64% rename from openapi3/swagger_loader_paths_test.go rename to openapi3/loader_paths_test.go index e805d3ae3..f7edc7374 100644 --- a/openapi3/swagger_loader_paths_test.go +++ b/openapi3/loader_paths_test.go @@ -1,10 +1,9 @@ -package openapi3_test +package openapi3 import ( "strings" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) @@ -14,7 +13,6 @@ openapi: "3.0" info: version: "1.0" title: sample -basePath: /adc/v1 paths: PATH: get: @@ -24,11 +22,11 @@ paths: ` for path, expectedErr := range map[string]string{ - "foo/bar": "Error when validating Paths: Path 'foo/bar' does not start with '/'", + "foo/bar": "invalid paths: path \"foo/bar\" does not start with a forward slash (/)", "/foo/bar": "", } { - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData([]byte(strings.Replace(spec, "PATH", path, 1))) + loader := NewLoader() + doc, err := loader.LoadFromData([]byte(strings.Replace(spec, "PATH", path, 1))) require.NoError(t, err) err = doc.Validate(loader.Context) if expectedErr != "" { diff --git a/openapi3/loader_read_from_uri_func_test.go b/openapi3/loader_read_from_uri_func_test.go new file mode 100644 index 000000000..8fee2f4c2 --- /dev/null +++ b/openapi3/loader_read_from_uri_func_test.go @@ -0,0 +1,73 @@ +package openapi3 + +import ( + "fmt" + "io/ioutil" + "net/url" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoaderReadFromURIFunc(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *Loader, url *url.URL) ([]byte, error) { + return ioutil.ReadFile(filepath.Join("testdata", url.Path)) + } + doc, err := loader.LoadFromFile("recursiveRef/openapi.yml") + require.NoError(t, err) + require.NotNil(t, doc) + require.NoError(t, doc.Validate(loader.Context)) + require.Equal(t, "bar", doc.Paths["/foo"].Get.Responses.Get(200).Value.Content.Get("application/json").Schema.Value.Properties["foo2"].Value.Properties["foo"].Value.Properties["bar"].Value.Example) +} + +type multipleSourceLoaderExample struct { + Sources map[string][]byte +} + +func (l *multipleSourceLoaderExample) LoadFromURI( + loader *Loader, + location *url.URL, +) ([]byte, error) { + source := l.resolveSourceFromURI(location) + if source == nil { + return nil, fmt.Errorf("unsupported URI: %q", location.String()) + } + return source, nil +} + +func (l *multipleSourceLoaderExample) resolveSourceFromURI(location fmt.Stringer) []byte { + return l.Sources[location.String()] +} + +func TestResolveSchemaExternalRef(t *testing.T) { + rootLocation := &url.URL{Scheme: "http", Host: "example.com", Path: "spec.json"} + externalLocation := &url.URL{Scheme: "http", Host: "example.com", Path: "external.json"} + rootSpec := []byte(fmt.Sprintf( + `{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1","description":"An API"},"paths":{},"components":{"schemas":{"Root":{"allOf":[{"$ref":"%s#/components/schemas/External"}]}}}}`, + externalLocation.String(), + )) + externalSpec := []byte(`{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1","description":"External Spec"},"paths":{},"components":{"schemas":{"External":{"type":"string"}}}}`) + multipleSourceLoader := &multipleSourceLoaderExample{ + Sources: map[string][]byte{ + rootLocation.String(): rootSpec, + externalLocation.String(): externalSpec, + }, + } + loader := &Loader{ + IsExternalRefsAllowed: true, + ReadFromURIFunc: multipleSourceLoader.LoadFromURI, + } + + doc, err := loader.LoadFromURI(rootLocation) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + refRootVisited := doc.Components.Schemas["Root"].Value.AllOf[0] + require.Equal(t, fmt.Sprintf("%s#/components/schemas/External", externalLocation.String()), refRootVisited.Ref) + require.NotNil(t, refRootVisited.Value) +} diff --git a/openapi3/loader_recursive_ref_test.go b/openapi3/loader_recursive_ref_test.go new file mode 100644 index 000000000..924cb6be8 --- /dev/null +++ b/openapi3/loader_recursive_ref_test.go @@ -0,0 +1,51 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoaderSupportsRecursiveReference(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/recursiveRef/openapi.yml") + require.NoError(t, err) + err = doc.Validate(loader.Context) + require.NoError(t, err) + require.Equal(t, "bar", doc.Paths["/foo"].Get.Responses.Get(200).Value.Content.Get("application/json").Schema.Value.Properties["foo2"].Value.Properties["foo"].Value.Properties["bar"].Value.Example) + require.Equal(t, "ErrorDetails", doc.Paths["/foo"].Get.Responses.Get(400).Value.Content.Get("application/json").Schema.Value.Title) + require.Equal(t, "ErrorDetails", doc.Paths["/double-ref-foo"].Get.Responses.Get(400).Value.Content.Get("application/json").Schema.Value.Title) +} + +func TestIssue447(t *testing.T) { + loader := NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.0.1 +info: + title: Recursive refs example + version: "1.0" +paths: {} +components: + schemas: + Complex: + type: object + properties: + parent: + $ref: '#/components/schemas/Complex' +`)) + require.NoError(t, err) + err = doc.Validate(loader.Context) + require.NoError(t, err) + require.Equal(t, "object", doc.Components. + // Complex + Schemas["Complex"]. + // parent + Value.Properties["parent"]. + // parent + Value.Properties["parent"]. + // parent + Value.Properties["parent"]. + // type + Value.Type) +} diff --git a/openapi3/swagger_loader_relative_refs_test.go b/openapi3/loader_relative_refs_test.go similarity index 68% rename from openapi3/swagger_loader_relative_refs_test.go rename to openapi3/loader_relative_refs_test.go index 5ad7585ae..50d2c7d24 100644 --- a/openapi3/swagger_loader_relative_refs_test.go +++ b/openapi3/loader_relative_refs_test.go @@ -1,132 +1,130 @@ -package openapi3_test +package openapi3 import ( "fmt" - "net/url" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) type refTestDataEntry struct { name string contentTemplate string - testFunc func(t *testing.T, swagger *openapi3.Swagger) + testFunc func(t *testing.T, doc *T) } type refTestDataEntryWithErrorMessage struct { name string contentTemplate string errorMessage *string - testFunc func(t *testing.T, swagger *openapi3.Swagger) + testFunc func(t *testing.T, doc *T) } var refTestDataEntries = []refTestDataEntry{ { name: "SchemaRef", contentTemplate: externalSchemaRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.Schemas["TestSchema"].Value.Type) - require.Equal(t, "string", swagger.Components.Schemas["TestSchema"].Value.Type) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.Schemas["TestSchema"].Value.Type) + require.Equal(t, "string", doc.Components.Schemas["TestSchema"].Value.Type) }, }, { name: "ResponseRef", contentTemplate: externalResponseRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { + testFunc: func(t *testing.T, doc *T) { desc := "description" - require.Equal(t, &desc, swagger.Components.Responses["TestResponse"].Value.Description) + require.Equal(t, &desc, doc.Components.Responses["TestResponse"].Value.Description) }, }, { name: "ParameterRef", contentTemplate: externalParameterRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.Parameters["TestParameter"].Value.Name) - require.Equal(t, "id", swagger.Components.Parameters["TestParameter"].Value.Name) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.Parameters["TestParameter"].Value.Name) + require.Equal(t, "id", doc.Components.Parameters["TestParameter"].Value.Name) }, }, { name: "ExampleRef", contentTemplate: externalExampleRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.Examples["TestExample"].Value.Description) - require.Equal(t, "description", swagger.Components.Examples["TestExample"].Value.Description) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.Examples["TestExample"].Value.Description) + require.Equal(t, "description", doc.Components.Examples["TestExample"].Value.Description) }, }, { name: "RequestBodyRef", contentTemplate: externalRequestBodyRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.RequestBodies["TestRequestBody"].Value.Content) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.RequestBodies["TestRequestBody"].Value.Content) }, }, { name: "SecuritySchemeRef", contentTemplate: externalSecuritySchemeRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.SecuritySchemes["TestSecurityScheme"].Value.Description) - require.Equal(t, "description", swagger.Components.SecuritySchemes["TestSecurityScheme"].Value.Description) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.SecuritySchemes["TestSecurityScheme"].Value.Description) + require.Equal(t, "description", doc.Components.SecuritySchemes["TestSecurityScheme"].Value.Description) }, }, { name: "ExternalHeaderRef", contentTemplate: externalHeaderRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.Headers["TestHeader"].Value.Description) - require.Equal(t, "description", swagger.Components.Headers["TestHeader"].Value.Description) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.Headers["TestHeader"].Value.Description) + require.Equal(t, "description", doc.Components.Headers["TestHeader"].Value.Description) }, }, { name: "PathParameterRef", contentTemplate: externalPathParameterRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test/{id}"].Parameters[0].Value.Name) - require.Equal(t, "id", swagger.Paths["/test/{id}"].Parameters[0].Value.Name) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test/{id}"].Parameters[0].Value.Name) + require.Equal(t, "id", doc.Paths["/test/{id}"].Parameters[0].Value.Name) }, }, { name: "PathOperationParameterRef", contentTemplate: externalPathOperationParameterRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test/{id}"].Get.Parameters[0].Value) - require.Equal(t, "id", swagger.Paths["/test/{id}"].Get.Parameters[0].Value.Name) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test/{id}"].Get.Parameters[0].Value) + require.Equal(t, "id", doc.Paths["/test/{id}"].Get.Parameters[0].Value.Name) }, }, { name: "PathOperationRequestBodyRef", contentTemplate: externalPathOperationRequestBodyRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test"].Post.RequestBody.Value) - require.NotNil(t, swagger.Paths["/test"].Post.RequestBody.Value.Content) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test"].Post.RequestBody.Value) + require.NotNil(t, doc.Paths["/test"].Post.RequestBody.Value.Content) }, }, { name: "PathOperationResponseRef", contentTemplate: externalPathOperationResponseRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test"].Post.Responses["default"].Value) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test"].Post.Responses["default"].Value) desc := "description" - require.Equal(t, &desc, swagger.Paths["/test"].Post.Responses["default"].Value.Description) + require.Equal(t, &desc, doc.Paths["/test"].Post.Responses["default"].Value.Description) }, }, { name: "PathOperationParameterSchemaRef", contentTemplate: externalPathOperationParameterSchemaRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test/{id}"].Get.Parameters[0].Value.Schema.Value) - require.Equal(t, "string", swagger.Paths["/test/{id}"].Get.Parameters[0].Value.Schema.Value.Type) - require.Equal(t, "id", swagger.Paths["/test/{id}"].Get.Parameters[0].Value.Name) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test/{id}"].Get.Parameters[0].Value.Schema.Value) + require.Equal(t, "string", doc.Paths["/test/{id}"].Get.Parameters[0].Value.Schema.Value.Type) + require.Equal(t, "id", doc.Paths["/test/{id}"].Get.Parameters[0].Value.Name) }, }, { name: "PathOperationParameterRefWithContentInQuery", contentTemplate: externalPathOperationParameterWithContentInQueryTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - schemaRef := swagger.Paths["/test/{id}"].Get.Parameters[0].Value.Content["application/json"].Schema + testFunc: func(t *testing.T, doc *T) { + schemaRef := doc.Paths["/test/{id}"].Get.Parameters[0].Value.Content["application/json"].Schema require.NotNil(t, schemaRef.Value) require.Equal(t, "string", schemaRef.Value.Type) }, @@ -135,53 +133,53 @@ var refTestDataEntries = []refTestDataEntry{ { name: "PathOperationRequestBodyExampleRef", contentTemplate: externalPathOperationRequestBodyExampleRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test"].Post.RequestBody.Value.Content["application/json"].Examples["application/json"].Value) - require.Equal(t, "description", swagger.Paths["/test"].Post.RequestBody.Value.Content["application/json"].Examples["application/json"].Value.Description) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test"].Post.RequestBody.Value.Content["application/json"].Examples["application/json"].Value) + require.Equal(t, "description", doc.Paths["/test"].Post.RequestBody.Value.Content["application/json"].Examples["application/json"].Value.Description) }, }, { name: "PathOperationReqestBodyContentSchemaRef", contentTemplate: externalPathOperationReqestBodyContentSchemaRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test"].Post.RequestBody.Value.Content["application/json"].Schema.Value) - require.Equal(t, "string", swagger.Paths["/test"].Post.RequestBody.Value.Content["application/json"].Schema.Value.Type) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test"].Post.RequestBody.Value.Content["application/json"].Schema.Value) + require.Equal(t, "string", doc.Paths["/test"].Post.RequestBody.Value.Content["application/json"].Schema.Value.Type) }, }, { name: "PathOperationResponseExampleRef", contentTemplate: externalPathOperationResponseExampleRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test"].Post.Responses["default"].Value) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test"].Post.Responses["default"].Value) desc := "testdescription" - require.Equal(t, &desc, swagger.Paths["/test"].Post.Responses["default"].Value.Description) - require.Equal(t, "description", swagger.Paths["/test"].Post.Responses["default"].Value.Content["application/json"].Examples["application/json"].Value.Description) + require.Equal(t, &desc, doc.Paths["/test"].Post.Responses["default"].Value.Description) + require.Equal(t, "description", doc.Paths["/test"].Post.Responses["default"].Value.Content["application/json"].Examples["application/json"].Value.Description) }, }, { name: "PathOperationResponseSchemaRef", contentTemplate: externalPathOperationResponseSchemaRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test"].Post.Responses["default"].Value) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test"].Post.Responses["default"].Value) desc := "testdescription" - require.Equal(t, &desc, swagger.Paths["/test"].Post.Responses["default"].Value.Description) - require.Equal(t, "string", swagger.Paths["/test"].Post.Responses["default"].Value.Content["application/json"].Schema.Value.Type) + require.Equal(t, &desc, doc.Paths["/test"].Post.Responses["default"].Value.Description) + require.Equal(t, "string", doc.Paths["/test"].Post.Responses["default"].Value.Content["application/json"].Schema.Value.Type) }, }, { name: "ComponentHeaderSchemaRef", contentTemplate: externalComponentHeaderSchemaRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.Headers["TestHeader"].Value) - require.Equal(t, "string", swagger.Components.Headers["TestHeader"].Value.Schema.Value.Type) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.Headers["TestHeader"].Value) + require.Equal(t, "string", doc.Components.Headers["TestHeader"].Value.Schema.Value.Type) }, }, { name: "RequestResponseHeaderRef", contentTemplate: externalRequestResponseHeaderRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) - require.Equal(t, "description", swagger.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) + require.Equal(t, "description", doc.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) }, }, } @@ -190,48 +188,48 @@ var refTestDataEntriesResponseError = []refTestDataEntryWithErrorMessage{ { name: "CannotContainBothSchemaAndContentInAParameter", contentTemplate: externalCannotContainBothSchemaAndContentInAParameter, - errorMessage: &(&struct{ x string }{"Cannot contain both schema and content in a parameter"}).x, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { + errorMessage: &(&struct{ x string }{"cannot contain both schema and content in a parameter"}).x, + testFunc: func(t *testing.T, doc *T) { }, }, } func TestLoadFromDataWithExternalRef(t *testing.T) { for _, td := range refTestDataEntries { - t.Logf("testcase '%s'", td.name) + t.Logf("testcase %q", td.name) spec := []byte(fmt.Sprintf(td.contentTemplate, "components.openapi.json")) - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) + doc, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) require.NoError(t, err) - td.testFunc(t, swagger) + td.testFunc(t, doc) } } func TestLoadFromDataWithExternalRefResponseError(t *testing.T) { for _, td := range refTestDataEntriesResponseError { - t.Logf("testcase '%s'", td.name) + t.Logf("testcase %q", td.name) spec := []byte(fmt.Sprintf(td.contentTemplate, "components.openapi.json")) - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) + doc, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) require.EqualError(t, err, *td.errorMessage) - td.testFunc(t, swagger) + td.testFunc(t, doc) } } func TestLoadFromDataWithExternalNestedRef(t *testing.T) { for _, td := range refTestDataEntries { - t.Logf("testcase '%s'", td.name) + t.Logf("testcase %q", td.name) spec := []byte(fmt.Sprintf(td.contentTemplate, "nesteddir/nestedcomponents.openapi.json")) - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) + doc, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) require.NoError(t, err) - td.testFunc(t, swagger) + td.testFunc(t, doc) } } @@ -725,116 +723,116 @@ var relativeDocRefsTestDataEntries = []refTestDataEntry{ { name: "SchemaRef", contentTemplate: relativeSchemaDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.Schemas["TestSchema"].Value.Type) - require.Equal(t, "string", swagger.Components.Schemas["TestSchema"].Value.Type) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.Schemas["TestSchema"].Value.Type) + require.Equal(t, "string", doc.Components.Schemas["TestSchema"].Value.Type) }, }, { name: "ResponseRef", contentTemplate: relativeResponseDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { + testFunc: func(t *testing.T, doc *T) { desc := "description" - require.Equal(t, &desc, swagger.Components.Responses["TestResponse"].Value.Description) + require.Equal(t, &desc, doc.Components.Responses["TestResponse"].Value.Description) }, }, { name: "ParameterRef", contentTemplate: relativeParameterDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.Parameters["TestParameter"].Value.Name) - require.Equal(t, "param", swagger.Components.Parameters["TestParameter"].Value.Name) - require.Equal(t, true, swagger.Components.Parameters["TestParameter"].Value.Required) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.Parameters["TestParameter"].Value.Name) + require.Equal(t, "param", doc.Components.Parameters["TestParameter"].Value.Name) + require.Equal(t, true, doc.Components.Parameters["TestParameter"].Value.Required) }, }, { name: "ExampleRef", contentTemplate: relativeExampleDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, "param", swagger.Components.Examples["TestExample"].Value.Summary) - require.NotNil(t, "param", swagger.Components.Examples["TestExample"].Value.Value) - require.Equal(t, "An example", swagger.Components.Examples["TestExample"].Value.Summary) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, "param", doc.Components.Examples["TestExample"].Value.Summary) + require.NotNil(t, "param", doc.Components.Examples["TestExample"].Value.Value) + require.Equal(t, "An example", doc.Components.Examples["TestExample"].Value.Summary) }, }, { name: "RequestRef", contentTemplate: relativeRequestDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, "param", swagger.Components.RequestBodies["TestRequestBody"].Value.Description) - require.Equal(t, "example request", swagger.Components.RequestBodies["TestRequestBody"].Value.Description) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, "param", doc.Components.RequestBodies["TestRequestBody"].Value.Description) + require.Equal(t, "example request", doc.Components.RequestBodies["TestRequestBody"].Value.Description) }, }, { name: "HeaderRef", contentTemplate: relativeHeaderDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, "param", swagger.Components.Headers["TestHeader"].Value.Description) - require.Equal(t, "description", swagger.Components.Headers["TestHeader"].Value.Description) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, "param", doc.Components.Headers["TestHeader"].Value.Description) + require.Equal(t, "description", doc.Components.Headers["TestHeader"].Value.Description) }, }, { name: "HeaderRef", contentTemplate: relativeHeaderDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, "param", swagger.Components.Headers["TestHeader"].Value.Description) - require.Equal(t, "description", swagger.Components.Headers["TestHeader"].Value.Description) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, "param", doc.Components.Headers["TestHeader"].Value.Description) + require.Equal(t, "description", doc.Components.Headers["TestHeader"].Value.Description) }, }, { name: "SecuritySchemeRef", contentTemplate: relativeSecuritySchemeDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Components.SecuritySchemes["TestSecurityScheme"].Value.Type) - require.NotNil(t, swagger.Components.SecuritySchemes["TestSecurityScheme"].Value.Scheme) - require.Equal(t, "http", swagger.Components.SecuritySchemes["TestSecurityScheme"].Value.Type) - require.Equal(t, "basic", swagger.Components.SecuritySchemes["TestSecurityScheme"].Value.Scheme) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Components.SecuritySchemes["TestSecurityScheme"].Value.Type) + require.NotNil(t, doc.Components.SecuritySchemes["TestSecurityScheme"].Value.Scheme) + require.Equal(t, "http", doc.Components.SecuritySchemes["TestSecurityScheme"].Value.Type) + require.Equal(t, "basic", doc.Components.SecuritySchemes["TestSecurityScheme"].Value.Scheme) }, }, { name: "PathRef", contentTemplate: relativePathDocsRefTemplate, - testFunc: func(t *testing.T, swagger *openapi3.Swagger) { - require.NotNil(t, swagger.Paths["/pets"]) - require.NotNil(t, swagger.Paths["/pets"].Get.Responses["200"]) - require.NotNil(t, swagger.Paths["/pets"].Get.Responses["200"].Value.Content["application/json"]) + testFunc: func(t *testing.T, doc *T) { + require.NotNil(t, doc.Paths["/pets"]) + require.NotNil(t, doc.Paths["/pets"].Get.Responses["200"]) + require.NotNil(t, doc.Paths["/pets"].Get.Responses["200"].Value.Content["application/json"]) }, }, } func TestLoadSpecWithRelativeDocumentRefs(t *testing.T) { for _, td := range relativeDocRefsTestDataEntries { - t.Logf("testcase '%s'", td.name) + t.Logf("testcase %q", td.name) spec := []byte(td.contentTemplate) - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromDataWithPath(spec, &url.URL{Path: "testdata/"}) + doc, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/"}) require.NoError(t, err) - td.testFunc(t, swagger) + td.testFunc(t, doc) } } const relativeSchemaDocsRefTemplate = ` openapi: 3.0.0 -info: +info: title: "" version: "1.0" paths: {} -components: - schemas: - TestSchema: +components: + schemas: + TestSchema: $ref: relativeDocs/CustomTestSchema.yml ` const relativeResponseDocsRefTemplate = ` openapi: 3.0.0 -info: +info: title: "" version: "1.0" paths: {} -components: - responses: - TestResponse: +components: + responses: + TestResponse: $ref: relativeDocs/CustomTestResponse.yml ` @@ -846,7 +844,7 @@ info: paths: {} components: parameters: - TestParameter: + TestParameter: $ref: relativeDocs/CustomTestParameter.yml ` @@ -908,21 +906,23 @@ paths: ` func TestLoadSpecWithRelativeDocumentRefs2(t *testing.T) { - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromFile("testdata/relativeDocsUseDocumentPath/openapi/openapi.yml") + doc, err := loader.LoadFromFile("testdata/relativeDocsUseDocumentPath/openapi/openapi.yml") require.NoError(t, err) // path in nested directory // check parameter - nestedDirPath := swagger.Paths["/pets/{id}"] + nestedDirPath := doc.Paths["/pets/{id}"] require.Equal(t, "param", nestedDirPath.Patch.Parameters[0].Value.Name) require.Equal(t, "path", nestedDirPath.Patch.Parameters[0].Value.In) require.Equal(t, true, nestedDirPath.Patch.Parameters[0].Value.Required) // check header require.Equal(t, "header", nestedDirPath.Patch.Responses["200"].Value.Headers["X-Rate-Limit-Reset"].Value.Description) + require.Equal(t, "header1", nestedDirPath.Patch.Responses["200"].Value.Headers["X-Another"].Value.Description) + require.Equal(t, "header2", nestedDirPath.Patch.Responses["200"].Value.Headers["X-And-Another"].Value.Description) // check request body require.Equal(t, "example request", nestedDirPath.Patch.RequestBody.Value.Description) @@ -934,13 +934,15 @@ func TestLoadSpecWithRelativeDocumentRefs2(t *testing.T) { // path in more nested directory // check parameter - moreNestedDirPath := swagger.Paths["/pets/{id}/{city}"] + moreNestedDirPath := doc.Paths["/pets/{id}/{city}"] require.Equal(t, "param", moreNestedDirPath.Patch.Parameters[0].Value.Name) require.Equal(t, "path", moreNestedDirPath.Patch.Parameters[0].Value.In) require.Equal(t, true, moreNestedDirPath.Patch.Parameters[0].Value.Required) // check header require.Equal(t, "header", nestedDirPath.Patch.Responses["200"].Value.Headers["X-Rate-Limit-Reset"].Value.Description) + require.Equal(t, "header1", nestedDirPath.Patch.Responses["200"].Value.Headers["X-Another"].Value.Description) + require.Equal(t, "header2", nestedDirPath.Patch.Responses["200"].Value.Headers["X-And-Another"].Value.Description) // check request body require.Equal(t, "example request", moreNestedDirPath.Patch.RequestBody.Value.Description) diff --git a/openapi3/swagger_loader_test.go b/openapi3/loader_test.go similarity index 51% rename from openapi3/swagger_loader_test.go rename to openapi3/loader_test.go index 920911a17..9756f55fc 100644 --- a/openapi3/swagger_loader_test.go +++ b/openapi3/loader_test.go @@ -1,4 +1,4 @@ -package openapi3_test +package openapi3 import ( "fmt" @@ -6,9 +6,9 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) @@ -54,52 +54,77 @@ paths: $ref: '#/components/schemas/ErrorModel' `) - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(spec) + loader := NewLoader() + + doc, err := loader.LoadFromData(spec) require.NoError(t, err) require.Equal(t, "An API", doc.Info.Title) require.Equal(t, 2, len(doc.Components.Schemas)) require.Equal(t, 1, len(doc.Paths)) - def := doc.Paths["/items"].Put.Responses.Default().Value - desc := "unexpected error" - require.Equal(t, &desc, def.Description) + require.Equal(t, "unexpected error", *doc.Paths["/items"].Put.Responses.Default().Value.Description) + err = doc.Validate(loader.Context) require.NoError(t, err) } -func ExampleSwaggerLoader() { - source := `{"info":{"description":"An API"}}` - swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromData([]byte(source)) +func TestIssue731(t *testing.T) { + spec := []byte(` +openapi: 3.0.0 +info: + title: An API + version: v1 +paths: + /items: + put: + description: '' + requestBody: + required: true + # Note mis-indented content block + content: + application/json: + schema: + type: object + responses: + default: + description: unexpected error + content: + application/json: + schema: + type: object +`[1:]) + + loader := NewLoader() + + doc, err := loader.LoadFromData(spec) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.ErrorContains(t, err, `content of the request body is required`) +} + +func ExampleLoader() { + const source = `{"info":{"description":"An API"}}` + doc, err := NewLoader().LoadFromData([]byte(source)) if err != nil { panic(err) } - fmt.Print(swagger.Info.Description) - // Output: - // An API + fmt.Print(doc.Info.Description) + // Output: An API } func TestResolveSchemaRef(t *testing.T) { - source := []byte(`{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1",description":"An API"},"paths":{},"components":{"schemas":{"B":{"type":"string"},"A":{"allOf":[{"$ref":"#/components/schemas/B"}]}}}}`) - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(source) + source := []byte(`{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1","description":"An API"},"paths":{},"components":{"schemas":{"B":{"type":"string"},"A":{"allOf":[{"$ref":"#/components/schemas/B"}]}}}}`) + loader := NewLoader() + doc, err := loader.LoadFromData(source) require.NoError(t, err) err = doc.Validate(loader.Context) - require.NoError(t, err) + refAVisited := doc.Components.Schemas["A"].Value.AllOf[0] require.Equal(t, "#/components/schemas/B", refAVisited.Ref) require.NotNil(t, refAVisited.Value) } -func TestResolveSchemaRefWithNullSchemaRef(t *testing.T) { - source := []byte(`{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1","description":"An API"},"paths":{"/foo":{"post":{"requestBody":{"content":{"application/json":{"schema":null}}}}}}}`) - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(source) - require.NoError(t, err) - err = doc.Validate(loader.Context) - require.EqualError(t, err, "Error when validating Paths: Found unresolved ref: ''") -} - func TestResolveResponseExampleRef(t *testing.T) { source := []byte(` openapi: 3.0.1 @@ -122,8 +147,8 @@ paths: examples: test: $ref: '#/components/examples/test'`) - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(source) + loader := NewLoader() + doc, err := loader.LoadFromData(source) require.NoError(t, err) err = doc.Validate(loader.Context) @@ -134,76 +159,12 @@ paths: require.Equal(t, example.Value.Value.(map[string]interface{})["error"].(bool), false) } -type sourceExample struct { - Location *url.URL - Spec []byte -} - -type multipleSourceSwaggerLoaderExample struct { - Sources []*sourceExample -} - -func (l *multipleSourceSwaggerLoaderExample) LoadSwaggerFromURI( - loader *openapi3.SwaggerLoader, - location *url.URL, -) (*openapi3.Swagger, error) { - source := l.resolveSourceFromURI(location) - if source == nil { - return nil, fmt.Errorf("Unsupported URI: '%s'", location.String()) - } - return loader.LoadSwaggerFromData(source.Spec) -} - -func (l *multipleSourceSwaggerLoaderExample) resolveSourceFromURI(location fmt.Stringer) *sourceExample { - locationString := location.String() - for _, v := range l.Sources { - if v.Location.String() == locationString { - return v - } - } - return nil -} - -func TestResolveSchemaExternalRef(t *testing.T) { - rootLocation := &url.URL{Scheme: "http", Host: "example.com", Path: "spec.json"} - externalLocation := &url.URL{Scheme: "http", Host: "example.com", Path: "external.json"} - rootSpec := []byte(fmt.Sprintf( - `{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1","description":"An API"},"paths":{},"components":{"schemas":{"Root":{"allOf":[{"$ref":"%s#/components/schemas/External"}]}}}}`, - externalLocation.String(), - )) - externalSpec := []byte(`{"openapi":"3.0.0","info":{"title":"MyAPI","version":"0.1","description":"External Spec"},"paths":{},"components":{"schemas":{"External":{"type":"string"}}}}`) - multipleSourceLoader := &multipleSourceSwaggerLoaderExample{ - Sources: []*sourceExample{ - { - Location: rootLocation, - Spec: rootSpec, - }, - { - Location: externalLocation, - Spec: externalSpec, - }, - }, - } - loader := &openapi3.SwaggerLoader{ - IsExternalRefsAllowed: true, - LoadSwaggerFromURIFunc: multipleSourceLoader.LoadSwaggerFromURI, - } - doc, err := loader.LoadSwaggerFromURI(rootLocation) - require.NoError(t, err) - err = doc.Validate(loader.Context) - - require.NoError(t, err) - refRootVisited := doc.Components.Schemas["Root"].Value.AllOf[0] - require.Equal(t, fmt.Sprintf("%s#/components/schemas/External", externalLocation.String()), refRootVisited.Ref) - require.NotNil(t, refRootVisited.Value) -} - func TestLoadErrorOnRefMisuse(t *testing.T) { spec := []byte(` openapi: '3.0.0' servers: [{url: /}] info: - title: '' + title: Some API version: '1' components: schemas: @@ -213,6 +174,7 @@ paths: put: description: '' requestBody: + # Uses a schema ref instead of a requestBody ref. $ref: '#/components/schemas/Thing' responses: '201': @@ -223,8 +185,8 @@ paths: $ref: '#/components/schemas/Thing' `) - loader := openapi3.NewSwaggerLoader() - _, err := loader.LoadSwaggerFromData(spec) + loader := NewLoader() + _, err := loader.LoadFromData(spec) require.Error(t, err) } @@ -251,11 +213,11 @@ paths: description: Test call. `) - loader := openapi3.NewSwaggerLoader() - swagger, err := loader.LoadSwaggerFromData(spec) + loader := NewLoader() + doc, err := loader.LoadFromData(spec) require.NoError(t, err) - require.NotNil(t, swagger.Paths["/"].Parameters[0].Value) + require.NotNil(t, doc.Paths["/"].Parameters[0].Value) } func TestLoadRequestExampleRef(t *testing.T) { @@ -283,57 +245,101 @@ paths: description: Test call. `) - loader := openapi3.NewSwaggerLoader() - swagger, err := loader.LoadSwaggerFromData(spec) + loader := NewLoader() + doc, err := loader.LoadFromData(spec) require.NoError(t, err) - require.NotNil(t, swagger.Paths["/"].Post.RequestBody.Value.Content.Get("application/json").Examples["test"]) + require.NotNil(t, doc.Paths["/"].Post.RequestBody.Value.Content.Get("application/json").Examples["test"]) } -func createTestServer(handler http.Handler) *httptest.Server { +func createTestServer(t *testing.T, handler http.Handler) *httptest.Server { ts := httptest.NewUnstartedServer(handler) - l, _ := net.Listen("tcp", addr) + l, err := net.Listen("tcp", addr) + require.NoError(t, err) ts.Listener.Close() ts.Listener = l return ts } func TestLoadFromRemoteURL(t *testing.T) { - fs := http.FileServer(http.Dir("testdata")) - ts := createTestServer(fs) + ts := createTestServer(t, fs) ts.Start() defer ts.Close() - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true url, err := url.Parse("http://" + addr + "/test.openapi.json") require.NoError(t, err) - swagger, err := loader.LoadSwaggerFromURI(url) + doc, err := loader.LoadFromURI(url) require.NoError(t, err) - require.Equal(t, "string", swagger.Components.Schemas["TestSchema"].Value.Type) + require.Equal(t, "string", doc.Components.Schemas["TestSchema"].Value.Type) } -func TestLoadFileWithExternalSchemaRef(t *testing.T) { - loader := openapi3.NewSwaggerLoader() +func TestLoadWithReferenceInReference(t *testing.T) { + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromFile("testdata/testref.openapi.json") + doc, err := loader.LoadFromFile("testdata/refInRef/openapi.json") require.NoError(t, err) + require.NotNil(t, doc) + err = doc.Validate(loader.Context) + require.NoError(t, err) + require.Equal(t, "string", doc.Paths["/api/test/ref/in/ref"].Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties["definition_reference"].Value.Type) +} - require.NotNil(t, swagger.Components.Schemas["AnotherTestSchema"].Value.Type) +func TestLoadWithRecursiveReferenceInLocalReferenceInParentSubdir(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/refInLocalRefInParentsSubdir/spec/openapi.json") + require.NoError(t, err) + require.NotNil(t, doc) + err = doc.Validate(loader.Context) + require.NoError(t, err) + require.Equal(t, "object", doc.Paths["/api/test/ref/in/ref"].Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties["definition_reference"].Value.Type) +} + +func TestLoadWithRecursiveReferenceInRefrerenceInLocalReference(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/refInLocalRef/openapi.json") + require.NoError(t, err) + require.NotNil(t, doc) + err = doc.Validate(loader.Context) + require.NoError(t, err) + require.Equal(t, "integer", doc.Paths["/api/test/ref/in/ref"].Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties["data"].Value.Properties["definition_reference"].Value.Properties["ref_prop_part"].Value.Properties["idPart"].Value.Type) + require.Equal(t, "int64", doc.Paths["/api/test/ref/in/ref"].Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties["data"].Value.Properties["definition_reference"].Value.Properties["ref_prop_part"].Value.Properties["idPart"].Value.Format) +} + +func TestLoadWithReferenceInReferenceInProperty(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/refInRefInProperty/openapi.yaml") + require.NoError(t, err) + require.NotNil(t, doc) + err = doc.Validate(loader.Context) + require.NoError(t, err) + require.Equal(t, "Problem details", doc.Paths["/api/test/ref/in/ref/in/property"].Post.Responses["401"].Value.Content["application/json"].Schema.Value.Properties["error"].Value.Title) +} + +func TestLoadFileWithExternalSchemaRef(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile("testdata/testref.openapi.json") + require.NoError(t, err) + require.NotNil(t, doc.Components.Schemas["AnotherTestSchema"].Value.Type) } func TestLoadFileWithExternalSchemaRefSingleComponent(t *testing.T) { - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromFile("testdata/testrefsinglecomponent.openapi.json") + doc, err := loader.LoadFromFile("testdata/testrefsinglecomponent.openapi.json") require.NoError(t, err) - require.NotNil(t, swagger.Components.Responses["SomeResponse"]) + require.NotNil(t, doc.Components.Responses["SomeResponse"]) desc := "this is a single response definition" - require.Equal(t, &desc, swagger.Components.Responses["SomeResponse"].Value.Description) + require.Equal(t, &desc, doc.Components.Responses["SomeResponse"].Value.Description) } func TestLoadRequestResponseHeaderRef(t *testing.T) { @@ -369,12 +375,12 @@ func TestLoadRequestResponseHeaderRef(t *testing.T) { } }`) - loader := openapi3.NewSwaggerLoader() - swagger, err := loader.LoadSwaggerFromData(spec) + loader := NewLoader() + doc, err := loader.LoadFromData(spec) require.NoError(t, err) - require.NotNil(t, swagger.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) - require.Equal(t, "testheader", swagger.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) + require.NotNil(t, doc.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) + require.Equal(t, "testheader", doc.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) } func TestLoadFromDataWithExternalRequestResponseHeaderRemoteRef(t *testing.T) { @@ -404,45 +410,45 @@ func TestLoadFromDataWithExternalRequestResponseHeaderRemoteRef(t *testing.T) { }`) fs := http.FileServer(http.Dir("testdata")) - ts := createTestServer(fs) + ts := createTestServer(t, fs) ts.Start() defer ts.Close() - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) + doc, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) require.NoError(t, err) - require.NotNil(t, swagger.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) - require.Equal(t, "description", swagger.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) + require.NotNil(t, doc.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) + require.Equal(t, "description", doc.Paths["/test"].Post.Responses["default"].Value.Headers["X-TEST-HEADER"].Value.Description) } func TestLoadYamlFile(t *testing.T) { - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromFile("testdata/test.openapi.yml") + doc, err := loader.LoadFromFile("testdata/test.openapi.yml") require.NoError(t, err) - require.Equal(t, "OAI Specification in YAML", swagger.Info.Title) + require.Equal(t, "OAI Specification in YAML", doc.Info.Title) } func TestLoadYamlFileWithExternalSchemaRef(t *testing.T) { - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromFile("testdata/testref.openapi.yml") + doc, err := loader.LoadFromFile("testdata/testref.openapi.yml") require.NoError(t, err) - require.NotNil(t, swagger.Components.Schemas["AnotherTestSchema"].Value.Type) + require.NotNil(t, doc.Components.Schemas["AnotherTestSchema"].Value.Type) } func TestLoadYamlFileWithExternalPathRef(t *testing.T) { - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() loader.IsExternalRefsAllowed = true - swagger, err := loader.LoadSwaggerFromFile("testdata/pathref.openapi.yml") + doc, err := loader.LoadFromFile("testdata/pathref.openapi.yml") require.NoError(t, err) - require.NotNil(t, swagger.Paths["/test"].Get.Responses["200"].Value.Content["application/json"].Schema.Value.Type) - require.Equal(t, "string", swagger.Paths["/test"].Get.Responses["200"].Value.Content["application/json"].Schema.Value.Type) + require.NotNil(t, doc.Paths["/test"].Get.Responses["200"].Value.Content["application/json"].Schema.Value.Type) + require.Equal(t, "string", doc.Paths["/test"].Get.Responses["200"].Value.Content["application/json"].Schema.Value.Type) } func TestResolveResponseLinkRef(t *testing.T) { @@ -465,6 +471,7 @@ paths: parameters: - name: id, in: path + required: true schema: type: string responses: @@ -476,8 +483,8 @@ paths: father: $ref: '#/components/links/Father' `) - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(source) + loader := NewLoader() + doc, err := loader.LoadFromData(source) require.NoError(t, err) err = doc.Validate(loader.Context) @@ -490,6 +497,20 @@ paths: require.Equal(t, "link to to the father", link.Description) } +func TestLinksFromOAISpec(t *testing.T) { + loader := NewLoader() + doc, err := loader.LoadFromFile("testdata/link-example.yaml") + require.NoError(t, err) + err = doc.Validate(loader.Context) + require.NoError(t, err) + response := doc.Paths[`/2.0/repositories/{username}/{slug}`].Get.Responses.Get(200).Value + link := response.Links[`repositoryPullRequests`].Value + require.Equal(t, map[string]interface{}{ + "username": "$response.body#/owner/username", + "slug": "$response.body#/slug", + }, link.Parameters) +} + func TestResolveNonComponentsRef(t *testing.T) { spec := []byte(` openapi: 3.0.0 @@ -545,9 +566,44 @@ paths: $ref: '#/components/schemas/ErrorModel' `) - loader := openapi3.NewSwaggerLoader() - doc, err := loader.LoadSwaggerFromData(spec) + loader := NewLoader() + doc, err := loader.LoadFromData(spec) require.NoError(t, err) err = doc.Validate(loader.Context) require.NoError(t, err) } + +func TestServersVariables(t *testing.T) { + const spec = ` +openapi: 3.0.1 +info: + title: My API + version: 1.0.0 +paths: {} +servers: +- @@@ +` + for value, expected := range map[string]string{ + `{url: /}`: "", + `{url: "http://{x}.{y}.example.com"}`: "invalid servers: server has undeclared variables", + `{url: "http://{x}.y}.example.com"}`: "invalid servers: server URL has mismatched { and }", + `{url: "http://{x.example.com"}`: "invalid servers: server URL has mismatched { and }", + `{url: "http://{x}.example.com", variables: {x: {default: "www"}}}`: "", + `{url: "http://{x}.example.com", variables: {x: {default: "www", enum: ["www"]}}}`: "", + `{url: "http://{x}.example.com", variables: {x: {enum: ["www"]}}}`: `invalid servers: field default is required in {"enum":["www"]}`, + `{url: "http://www.example.com", variables: {x: {enum: ["www"]}}}`: "invalid servers: server has undeclared variables", + `{url: "http://{y}.example.com", variables: {x: {enum: ["www"]}}}`: "invalid servers: server has undeclared variables", + } { + t.Run(value, func(t *testing.T) { + loader := NewLoader() + doc, err := loader.LoadFromData([]byte(strings.Replace(spec, "@@@", value, 1))) + require.NoError(t, err) + err = doc.Validate(loader.Context) + if expected == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, expected) + } + }) + } +} diff --git a/openapi3/loader_uri_reader.go b/openapi3/loader_uri_reader.go new file mode 100644 index 000000000..92ac043f9 --- /dev/null +++ b/openapi3/loader_uri_reader.go @@ -0,0 +1,112 @@ +package openapi3 + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "path/filepath" + "sync" +) + +// ReadFromURIFunc defines a function which reads the contents of a resource +// located at a URI. +type ReadFromURIFunc func(loader *Loader, url *url.URL) ([]byte, error) + +var uriMu = &sync.RWMutex{} + +// ErrURINotSupported indicates the ReadFromURIFunc does not know how to handle a +// given URI. +var ErrURINotSupported = errors.New("unsupported URI") + +// ReadFromURIs returns a ReadFromURIFunc which tries to read a URI using the +// given reader functions, in the same order. If a reader function does not +// support the URI and returns ErrURINotSupported, the next function is checked +// until a match is found, or the URI is not supported by any. +func ReadFromURIs(readers ...ReadFromURIFunc) ReadFromURIFunc { + return func(loader *Loader, url *url.URL) ([]byte, error) { + for i := range readers { + buf, err := readers[i](loader, url) + if err == ErrURINotSupported { + continue + } else if err != nil { + return nil, err + } + return buf, nil + } + return nil, ErrURINotSupported + } +} + +// DefaultReadFromURI returns a caching ReadFromURIFunc which can read remote +// HTTP URIs and local file URIs. +var DefaultReadFromURI = URIMapCache(ReadFromURIs(ReadFromHTTP(http.DefaultClient), ReadFromFile)) + +// ReadFromHTTP returns a ReadFromURIFunc which uses the given http.Client to +// read the contents from a remote HTTP URI. This client may be customized to +// implement timeouts, RFC 7234 caching, etc. +func ReadFromHTTP(cl *http.Client) ReadFromURIFunc { + return func(loader *Loader, location *url.URL) ([]byte, error) { + if location.Scheme == "" || location.Host == "" { + return nil, ErrURINotSupported + } + req, err := http.NewRequest("GET", location.String(), nil) + if err != nil { + return nil, err + } + resp, err := cl.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode > 399 { + return nil, fmt.Errorf("error loading %q: request returned status code %d", location.String(), resp.StatusCode) + } + return ioutil.ReadAll(resp.Body) + } +} + +// ReadFromFile is a ReadFromURIFunc which reads local file URIs. +func ReadFromFile(loader *Loader, location *url.URL) ([]byte, error) { + if location.Host != "" { + return nil, ErrURINotSupported + } + if location.Scheme != "" && location.Scheme != "file" { + return nil, ErrURINotSupported + } + return ioutil.ReadFile(location.Path) +} + +// URIMapCache returns a ReadFromURIFunc that caches the contents read from URI +// locations in a simple map. This cache implementation is suitable for +// short-lived processes such as command-line tools which process OpenAPI +// documents. +func URIMapCache(reader ReadFromURIFunc) ReadFromURIFunc { + cache := map[string][]byte{} + return func(loader *Loader, location *url.URL) (buf []byte, err error) { + if location.Scheme == "" || location.Scheme == "file" { + if !filepath.IsAbs(location.Path) { + // Do not cache relative file paths; this can cause trouble if + // the current working directory changes when processing + // multiple top-level documents. + return reader(loader, location) + } + } + uri := location.String() + var ok bool + uriMu.RLock() + if buf, ok = cache[uri]; ok { + uriMu.RUnlock() + return + } + uriMu.RUnlock() + if buf, err = reader(loader, location); err != nil { + return + } + uriMu.Lock() + defer uriMu.Unlock() + cache[uri] = buf + return + } +} diff --git a/openapi3/media_type.go b/openapi3/media_type.go index 3b8be81c7..2a9b4721c 100644 --- a/openapi3/media_type.go +++ b/openapi3/media_type.go @@ -2,20 +2,27 @@ package openapi3 import ( "context" + "encoding/json" + "errors" + "fmt" + "sort" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" ) // MediaType is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#media-type-object type MediaType struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` - Schema *SchemaRef `json:"schema,omitempty" yaml:"schema,omitempty"` - Example interface{} `json:"example,omitempty" yaml:"example,omitempty"` - Examples map[string]*ExampleRef `json:"examples,omitempty" yaml:"examples,omitempty"` - Encoding map[string]*Encoding `json:"encoding,omitempty" yaml:"encoding,omitempty"` + Schema *SchemaRef `json:"schema,omitempty" yaml:"schema,omitempty"` + Example interface{} `json:"example,omitempty" yaml:"example,omitempty"` + Examples Examples `json:"examples,omitempty" yaml:"examples,omitempty"` + Encoding map[string]*Encoding `json:"encoding,omitempty" yaml:"encoding,omitempty"` } +var _ jsonpointer.JSONPointable = (*MediaType)(nil) + func NewMediaType() *MediaType { return &MediaType{} } @@ -24,9 +31,7 @@ func (mediaType *MediaType) WithSchema(schema *Schema) *MediaType { if schema == nil { mediaType.Schema = nil } else { - mediaType.Schema = &SchemaRef{ - Value: schema, - } + mediaType.Schema = &SchemaRef{Value: schema} } return mediaType } @@ -58,22 +63,105 @@ func (mediaType *MediaType) WithEncoding(name string, enc *Encoding) *MediaType return mediaType } -func (mediaType *MediaType) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(mediaType) +// MarshalJSON returns the JSON encoding of MediaType. +func (mediaType MediaType) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 4+len(mediaType.Extensions)) + for k, v := range mediaType.Extensions { + m[k] = v + } + if x := mediaType.Schema; x != nil { + m["schema"] = x + } + if x := mediaType.Example; x != nil { + m["example"] = x + } + if x := mediaType.Examples; len(x) != 0 { + m["examples"] = x + } + if x := mediaType.Encoding; len(x) != 0 { + m["encoding"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets MediaType to a copy of data. func (mediaType *MediaType) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, mediaType) + type MediaTypeBis MediaType + var x MediaTypeBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "schema") + delete(x.Extensions, "example") + delete(x.Extensions, "examples") + delete(x.Extensions, "encoding") + *mediaType = MediaType(x) + return nil } -func (mediaType *MediaType) Validate(c context.Context) error { +// Validate returns an error if MediaType does not comply with the OpenAPI spec. +func (mediaType *MediaType) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if mediaType == nil { return nil } if schema := mediaType.Schema; schema != nil { - if err := schema.Validate(c); err != nil { + if err := schema.Validate(ctx); err != nil { return err } + + if mediaType.Example != nil && mediaType.Examples != nil { + return errors.New("example and examples are mutually exclusive") + } + + if vo := getValidationOptions(ctx); !vo.examplesValidationDisabled { + if example := mediaType.Example; example != nil { + if err := validateExampleValue(ctx, example, schema.Value); err != nil { + return fmt.Errorf("invalid example: %w", err) + } + } + + if examples := mediaType.Examples; examples != nil { + names := make([]string, 0, len(examples)) + for name := range examples { + names = append(names, name) + } + sort.Strings(names) + for _, k := range names { + v := examples[k] + if err := v.Validate(ctx); err != nil { + return fmt.Errorf("example %s: %w", k, err) + } + if err := validateExampleValue(ctx, v.Value.Value, schema.Value); err != nil { + return fmt.Errorf("example %s: %w", k, err) + } + } + } + } } - return nil + + return validateExtensions(ctx, mediaType.Extensions) +} + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (mediaType MediaType) JSONLookup(token string) (interface{}, error) { + switch token { + case "schema": + if mediaType.Schema != nil { + if mediaType.Schema.Ref != "" { + return &Ref{Ref: mediaType.Schema.Ref}, nil + } + return mediaType.Schema.Value, nil + } + case "example": + return mediaType.Example, nil + case "examples": + return mediaType.Examples, nil + case "encoding": + return mediaType.Encoding, nil + } + v, _, err := jsonpointer.GetForToken(mediaType.Extensions, token) + return v, err } diff --git a/openapi3/media_type_test.go b/openapi3/media_type_test.go index 9d5092802..099c4b667 100644 --- a/openapi3/media_type_test.go +++ b/openapi3/media_type_test.go @@ -1,11 +1,10 @@ -package openapi3_test +package openapi3 import ( "context" "encoding/json" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) @@ -16,13 +15,13 @@ func TestMediaTypeJSON(t *testing.T) { require.NotEmpty(t, data) t.Log("Unmarshal *openapi3.MediaType from JSON") - docA := &openapi3.MediaType{} + docA := &MediaType{} err = json.Unmarshal(mediaTypeJSON, &docA) require.NoError(t, err) require.NotEmpty(t, data) t.Log("Validate *openapi3.MediaType") - err = docA.Validate(context.TODO()) + err = docA.Validate(context.Background()) require.NoError(t, err) t.Log("Ensure representations match") @@ -52,22 +51,22 @@ var mediaTypeJSON = []byte(` } `) -func mediaType() *openapi3.MediaType { +func mediaType() *MediaType { example := map[string]string{"name": "Some example"} - return &openapi3.MediaType{ - Schema: &openapi3.SchemaRef{ - Value: &openapi3.Schema{ + return &MediaType{ + Schema: &SchemaRef{ + Value: &Schema{ Description: "Some schema", }, }, - Encoding: map[string]*openapi3.Encoding{ + Encoding: map[string]*Encoding{ "someEncoding": { ContentType: "application/xml; charset=utf-8", }, }, - Examples: map[string]*openapi3.ExampleRef{ + Examples: map[string]*ExampleRef{ "someExample": { - Value: openapi3.NewExample(example), + Value: NewExample(example), }, }, } diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go new file mode 100644 index 000000000..8b8f71bb7 --- /dev/null +++ b/openapi3/openapi3.go @@ -0,0 +1,156 @@ +package openapi3 + +import ( + "context" + "encoding/json" + "errors" + "fmt" +) + +// T is the root of an OpenAPI v3 document +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#openapi-object +type T struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + OpenAPI string `json:"openapi" yaml:"openapi"` // Required + Components *Components `json:"components,omitempty" yaml:"components,omitempty"` + Info *Info `json:"info" yaml:"info"` // Required + Paths Paths `json:"paths" yaml:"paths"` // Required + Security SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"` + Servers Servers `json:"servers,omitempty" yaml:"servers,omitempty"` + Tags Tags `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + + visited visitedComponent +} + +// MarshalJSON returns the JSON encoding of T. +func (doc T) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 4+len(doc.Extensions)) + for k, v := range doc.Extensions { + m[k] = v + } + m["openapi"] = doc.OpenAPI + if x := doc.Components; x != nil { + m["components"] = x + } + m["info"] = doc.Info + m["paths"] = doc.Paths + if x := doc.Security; len(x) != 0 { + m["security"] = x + } + if x := doc.Servers; len(x) != 0 { + m["servers"] = x + } + if x := doc.Tags; len(x) != 0 { + m["tags"] = x + } + if x := doc.ExternalDocs; x != nil { + m["externalDocs"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets T to a copy of data. +func (doc *T) UnmarshalJSON(data []byte) error { + type TBis T + var x TBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "openapi") + delete(x.Extensions, "components") + delete(x.Extensions, "info") + delete(x.Extensions, "paths") + delete(x.Extensions, "security") + delete(x.Extensions, "servers") + delete(x.Extensions, "tags") + delete(x.Extensions, "externalDocs") + *doc = T(x) + return nil +} + +func (doc *T) AddOperation(path string, method string, operation *Operation) { + if doc.Paths == nil { + doc.Paths = make(Paths) + } + pathItem := doc.Paths[path] + if pathItem == nil { + pathItem = &PathItem{} + doc.Paths[path] = pathItem + } + pathItem.SetOperation(method, operation) +} + +func (doc *T) AddServer(server *Server) { + doc.Servers = append(doc.Servers, server) +} + +// Validate returns an error if T does not comply with the OpenAPI spec. +// Validations Options can be provided to modify the validation behavior. +func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if doc.OpenAPI == "" { + return errors.New("value of openapi must be a non-empty string") + } + + var wrap func(error) error + // NOTE: only mention info/components/paths/... key in this func's errors. + + wrap = func(e error) error { return fmt.Errorf("invalid components: %w", e) } + if v := doc.Components; v != nil { + if err := v.Validate(ctx); err != nil { + return wrap(err) + } + } + + wrap = func(e error) error { return fmt.Errorf("invalid info: %w", e) } + if v := doc.Info; v != nil { + if err := v.Validate(ctx); err != nil { + return wrap(err) + } + } else { + return wrap(errors.New("must be an object")) + } + + wrap = func(e error) error { return fmt.Errorf("invalid paths: %w", e) } + if v := doc.Paths; v != nil { + if err := v.Validate(ctx); err != nil { + return wrap(err) + } + } else { + return wrap(errors.New("must be an object")) + } + + wrap = func(e error) error { return fmt.Errorf("invalid security: %w", e) } + if v := doc.Security; v != nil { + if err := v.Validate(ctx); err != nil { + return wrap(err) + } + } + + wrap = func(e error) error { return fmt.Errorf("invalid servers: %w", e) } + if v := doc.Servers; v != nil { + if err := v.Validate(ctx); err != nil { + return wrap(err) + } + } + + wrap = func(e error) error { return fmt.Errorf("invalid tags: %w", e) } + if v := doc.Tags; v != nil { + if err := v.Validate(ctx); err != nil { + return wrap(err) + } + } + + wrap = func(e error) error { return fmt.Errorf("invalid external docs: %w", e) } + if v := doc.ExternalDocs; v != nil { + if err := v.Validate(ctx); err != nil { + return wrap(err) + } + } + + return validateExtensions(ctx, doc.Extensions) +} diff --git a/openapi3/swagger_test.go b/openapi3/openapi3_test.go similarity index 61% rename from openapi3/swagger_test.go rename to openapi3/openapi3_test.go index d4c433983..e01af82ba 100644 --- a/openapi3/swagger_test.go +++ b/openapi3/openapi3_test.go @@ -1,39 +1,38 @@ -package openapi3_test +package openapi3 import ( "context" "encoding/json" - "errors" + "strings" "testing" - "github.com/getkin/kin-openapi/openapi3" - "github.com/ghodss/yaml" + "github.com/invopop/yaml" "github.com/stretchr/testify/require" ) func TestRefsJSON(t *testing.T) { - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() - t.Log("Marshal *openapi3.Swagger to JSON") + t.Log("Marshal *T to JSON") data, err := json.Marshal(spec()) require.NoError(t, err) require.NotEmpty(t, data) - t.Log("Unmarshal *openapi3.Swagger from JSON") - docA := &openapi3.Swagger{} + t.Log("Unmarshal *T from JSON") + docA := &T{} err = json.Unmarshal(specJSON, &docA) require.NoError(t, err) require.NotEmpty(t, data) - t.Log("Resolve refs in unmarshalled *openapi3.Swagger") + t.Log("Resolve refs in unmarshalled *T") err = loader.ResolveRefsIn(docA, nil) require.NoError(t, err) - t.Log("Resolve refs in marshalled *openapi3.Swagger") - docB, err := loader.LoadSwaggerFromData(data) + t.Log("Resolve refs in marshalled *T") + docB, err := loader.LoadFromData(data) require.NoError(t, err) require.NotEmpty(t, docB) - t.Log("Validate *openapi3.Swagger") + t.Log("Validate *T") err = docA.Validate(loader.Context) require.NoError(t, err) err = docB.Validate(loader.Context) @@ -50,28 +49,28 @@ func TestRefsJSON(t *testing.T) { } func TestRefsYAML(t *testing.T) { - loader := openapi3.NewSwaggerLoader() + loader := NewLoader() - t.Log("Marshal *openapi3.Swagger to YAML") + t.Log("Marshal *T to YAML") data, err := yaml.Marshal(spec()) require.NoError(t, err) require.NotEmpty(t, data) - t.Log("Unmarshal *openapi3.Swagger from YAML") - docA := &openapi3.Swagger{} + t.Log("Unmarshal *T from YAML") + docA := &T{} err = yaml.Unmarshal(specYAML, &docA) require.NoError(t, err) require.NotEmpty(t, data) - t.Log("Resolve refs in unmarshalled *openapi3.Swagger") + t.Log("Resolve refs in unmarshalled *T") err = loader.ResolveRefsIn(docA, nil) require.NoError(t, err) - t.Log("Resolve refs in marshalled *openapi3.Swagger") - docB, err := loader.LoadSwaggerFromData(data) + t.Log("Resolve refs in marshalled *T") + docB, err := loader.LoadFromData(data) require.NoError(t, err) require.NotEmpty(t, docB) - t.Log("Validate *openapi3.Swagger") + t.Log("Validate *T") err = docA.Validate(loader.Context) require.NoError(t, err) err = docB.Validate(loader.Context) @@ -124,6 +123,7 @@ components: requestBodies: someRequestBody: description: Some request body + content: {} responses: someResponse: description: Some response @@ -131,7 +131,8 @@ components: someSchema: description: Some schema headers: - otherHeader: {} + otherHeader: + schema: {type: string} someHeader: "$ref": "#/components/headers/otherHeader" examples: @@ -194,7 +195,8 @@ var specJSON = []byte(` }, "requestBodies": { "someRequestBody": { - "description": "Some request body" + "description": "Some request body", + "content": {} } }, "responses": { @@ -208,7 +210,11 @@ var specJSON = []byte(` } }, "headers": { - "otherHeader": {}, + "otherHeader": { + "schema": { + "type": "string" + } + }, "someHeader": { "$ref": "#/components/headers/otherHeader" } @@ -238,53 +244,54 @@ var specJSON = []byte(` } `) -func spec() *openapi3.Swagger { - parameter := &openapi3.Parameter{ +func spec() *T { + parameter := &Parameter{ Description: "Some parameter", Name: "example", In: "query", - Schema: &openapi3.SchemaRef{ + Schema: &SchemaRef{ Ref: "#/components/schemas/someSchema", }, } - requestBody := &openapi3.RequestBody{ + requestBody := &RequestBody{ Description: "Some request body", + Content: NewContent(), } responseDescription := "Some response" - response := &openapi3.Response{ + response := &Response{ Description: &responseDescription, } - schema := &openapi3.Schema{ + schema := &Schema{ Description: "Some schema", } example := map[string]string{"name": "Some example"} - return &openapi3.Swagger{ + return &T{ OpenAPI: "3.0", - Info: &openapi3.Info{ + Info: &Info{ Title: "MyAPI", Version: "0.1", }, - Paths: openapi3.Paths{ - "/hello": &openapi3.PathItem{ - Post: &openapi3.Operation{ - Parameters: openapi3.Parameters{ + Paths: Paths{ + "/hello": &PathItem{ + Post: &Operation{ + Parameters: Parameters{ { Ref: "#/components/parameters/someParameter", Value: parameter, }, }, - RequestBody: &openapi3.RequestBodyRef{ + RequestBody: &RequestBodyRef{ Ref: "#/components/requestBodies/someRequestBody", Value: requestBody, }, - Responses: openapi3.Responses{ - "200": &openapi3.ResponseRef{ + Responses: Responses{ + "200": &ResponseRef{ Ref: "#/components/responses/someResponse", Value: response, }, }, }, - Parameters: openapi3.Parameters{ + Parameters: Parameters{ { Ref: "#/components/parameters/someParameter", Value: parameter, @@ -292,49 +299,49 @@ func spec() *openapi3.Swagger { }, }, }, - Components: openapi3.Components{ - Parameters: map[string]*openapi3.ParameterRef{ + Components: &Components{ + Parameters: ParametersMap{ "someParameter": { Value: parameter, }, }, - RequestBodies: map[string]*openapi3.RequestBodyRef{ + RequestBodies: RequestBodies{ "someRequestBody": { Value: requestBody, }, }, - Responses: map[string]*openapi3.ResponseRef{ + Responses: Responses{ "someResponse": { Value: response, }, }, - Schemas: map[string]*openapi3.SchemaRef{ + Schemas: Schemas{ "someSchema": { Value: schema, }, }, - Headers: map[string]*openapi3.HeaderRef{ + Headers: Headers{ "someHeader": { Ref: "#/components/headers/otherHeader", }, "otherHeader": { - Value: &openapi3.Header{}, + Value: &Header{Parameter{Schema: &SchemaRef{Value: NewStringSchema()}}}, }, }, - Examples: map[string]*openapi3.ExampleRef{ + Examples: Examples{ "someExample": { Ref: "#/components/examples/otherExample", }, "otherExample": { - Value: openapi3.NewExample(example), + Value: NewExample(example), }, }, - SecuritySchemes: map[string]*openapi3.SecuritySchemeRef{ + SecuritySchemes: SecuritySchemes{ "someSecurityScheme": { Ref: "#/components/securitySchemes/otherSecurityScheme", }, "otherSecurityScheme": { - Value: &openapi3.SecurityScheme{ + Value: &SecurityScheme{ Description: "Some security scheme", Type: "apiKey", In: "query", @@ -346,117 +353,16 @@ func spec() *openapi3.Swagger { } } -// TestValidation tests validation of properties in the root of the OpenAPI -// file. func TestValidation(t *testing.T) { - tests := []struct { - name string - input []byte - expectedError error - }{ - { - "when no OpenAPI property is supplied", - []byte(` -info: - title: "Hello World REST APIs" - version: "1.0" -paths: - "/api/v2/greetings.json": - get: - operationId: listGreetings - responses: - 200: - description: "List different greetings" - "/api/v2/greetings/{id}.json": - parameters: - - name: id - in: path - required: true - schema: - type: string - example: "greeting" - get: - operationId: showGreeting - responses: - 200: - description: "Get a single greeting object" -`), - errors.New("Variable 'openapi' must be a non-empty JSON string"), - }, - { - "when an empty OpenAPI property is supplied", - []byte(` -openapi: '' -info: - title: "Hello World REST APIs" - version: "1.0" -paths: - "/api/v2/greetings.json": - get: - operationId: listGreetings - responses: - 200: - description: "List different greetings" - "/api/v2/greetings/{id}.json": - parameters: - - name: id - in: path - required: true - schema: - type: string - example: "greeting" - get: - operationId: showGreeting - responses: - 200: - description: "Get a single greeting object" -`), - errors.New("Variable 'openapi' must be a non-empty JSON string"), - }, - { - "when the Info property is not supplied", - []byte(` -openapi: '1.0' -paths: - "/api/v2/greetings.json": - get: - operationId: listGreetings - responses: - 200: - description: "List different greetings" - "/api/v2/greetings/{id}.json": - parameters: - - name: id - in: path - required: true - schema: - type: string - example: "greeting" - get: - operationId: showGreeting - responses: - 200: - description: "Get a single greeting object" -`), - errors.New("Variable 'info' must be a JSON object"), - }, - { - "when the Paths property is not supplied", - []byte(` -openapi: '1.0' -info: - title: "Hello World REST APIs" - version: "1.0" -`), - errors.New("Variable 'paths' must be a JSON object"), - }, - { - "when a valid spec is supplied", - []byte(` + version := ` openapi: 3.0.2 +` + info := ` info: title: "Hello World REST APIs" version: "1.0" +` + paths := ` paths: "/api/v2/greetings.json": get: @@ -477,6 +383,18 @@ paths: responses: 200: description: "Get a single greeting object" +` + externalDocs := ` +externalDocs: + url: https://root-ext-docs.com +` + tags := ` +tags: + - name: "pet" + externalDocs: + url: https://tags-ext-docs.com +` + spec := version + info + paths + externalDocs + tags + ` components: schemas: GreetingObject: @@ -490,21 +408,63 @@ components: properties: description: type: string -`), - nil, +` + + tests := []struct { + name string + spec string + expectedErr string + }{ + { + name: "no errors", + spec: spec, + }, + { + name: "version is missing", + spec: strings.Replace(spec, version, "", 1), + expectedErr: "value of openapi must be a non-empty string", + }, + { + name: "version is empty string", + spec: strings.Replace(spec, version, "openapi: ''", 1), + expectedErr: "value of openapi must be a non-empty string", + }, + { + name: "info section is missing", + spec: strings.Replace(spec, info, ``, 1), + expectedErr: "invalid info: must be an object", + }, + { + name: "paths section is missing", + spec: strings.Replace(spec, paths, ``, 1), + expectedErr: "invalid paths: must be an object", + }, + { + name: "externalDocs section is invalid", + spec: strings.Replace(spec, externalDocs, + strings.ReplaceAll(externalDocs, "url: https://root-ext-docs.com", "url: ''"), 1), + expectedErr: "invalid external docs: url is required", + }, + { + name: "tags section is invalid", + spec: strings.Replace(spec, tags, + strings.ReplaceAll(tags, "url: https://tags-ext-docs.com", "url: ''"), 1), + expectedErr: "invalid tags: invalid external docs: url is required", }, } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - doc := &openapi3.Swagger{} - err := yaml.Unmarshal(test.input, &doc) + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + doc := &T{} + err := yaml.Unmarshal([]byte(tt.spec), &doc) require.NoError(t, err) - c := context.Background() - validationErr := doc.Validate(c) - - require.Equal(t, test.expectedError, validationErr, "expected errors (or lack of) to match") + err = doc.Validate(context.Background()) + if tt.expectedErr != "" { + require.EqualError(t, err, tt.expectedErr) + } else { + require.NoError(t, err) + } }) } } diff --git a/openapi3/operation.go b/openapi3/operation.go index 0841863be..645c0805f 100644 --- a/openapi3/operation.go +++ b/openapi3/operation.go @@ -2,15 +2,18 @@ package openapi3 import ( "context" + "encoding/json" "errors" + "fmt" "strconv" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" ) // Operation represents "operation" specified by" OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#operation-object type Operation struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` // Optional tags for documentation. Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` @@ -34,7 +37,7 @@ type Operation struct { Responses Responses `json:"responses" yaml:"responses"` // Required // Optional callbacks - Callbacks map[string]*CallbackRef `json:"callbacks,omitempty" yaml:"callbacks,omitempty"` + Callbacks Callbacks `json:"callbacks,omitempty" yaml:"callbacks,omitempty"` Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` @@ -47,16 +50,115 @@ type Operation struct { ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` } +var _ jsonpointer.JSONPointable = (*Operation)(nil) + func NewOperation() *Operation { return &Operation{} } -func (operation *Operation) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(operation) +// MarshalJSON returns the JSON encoding of Operation. +func (operation Operation) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 12+len(operation.Extensions)) + for k, v := range operation.Extensions { + m[k] = v + } + if x := operation.Tags; len(x) != 0 { + m["tags"] = x + } + if x := operation.Summary; x != "" { + m["summary"] = x + } + if x := operation.Description; x != "" { + m["description"] = x + } + if x := operation.OperationID; x != "" { + m["operationId"] = x + } + if x := operation.Parameters; len(x) != 0 { + m["parameters"] = x + } + if x := operation.RequestBody; x != nil { + m["requestBody"] = x + } + m["responses"] = operation.Responses + if x := operation.Callbacks; len(x) != 0 { + m["callbacks"] = x + } + if x := operation.Deprecated; x { + m["deprecated"] = x + } + if x := operation.Security; x != nil { + m["security"] = x + } + if x := operation.Servers; x != nil { + m["servers"] = x + } + if x := operation.ExternalDocs; x != nil { + m["externalDocs"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets Operation to a copy of data. func (operation *Operation) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, operation) + type OperationBis Operation + var x OperationBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "tags") + delete(x.Extensions, "summary") + delete(x.Extensions, "description") + delete(x.Extensions, "operationId") + delete(x.Extensions, "parameters") + delete(x.Extensions, "requestBody") + delete(x.Extensions, "responses") + delete(x.Extensions, "callbacks") + delete(x.Extensions, "deprecated") + delete(x.Extensions, "security") + delete(x.Extensions, "servers") + delete(x.Extensions, "externalDocs") + *operation = Operation(x) + return nil +} + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (operation Operation) JSONLookup(token string) (interface{}, error) { + switch token { + case "requestBody": + if operation.RequestBody != nil { + if operation.RequestBody.Ref != "" { + return &Ref{Ref: operation.RequestBody.Ref}, nil + } + return operation.RequestBody.Value, nil + } + case "tags": + return operation.Tags, nil + case "summary": + return operation.Summary, nil + case "description": + return operation.Description, nil + case "operationID": + return operation.OperationID, nil + case "parameters": + return operation.Parameters, nil + case "responses": + return operation.Responses, nil + case "callbacks": + return operation.Callbacks, nil + case "deprecated": + return operation.Deprecated, nil + case "security": + return operation.Security, nil + case "servers": + return operation.Servers, nil + case "externalDocs": + return operation.ExternalDocs, nil + } + + v, _, err := jsonpointer.GetForToken(operation.Extensions, token) + return v, err } func (operation *Operation) AddParameter(p *Parameter) { @@ -71,34 +173,44 @@ func (operation *Operation) AddResponse(status int, response *Response) { responses = NewResponses() operation.Responses = responses } - if status == 0 { - responses["default"] = &ResponseRef{ - Value: response, - } - } else { - responses[strconv.FormatInt(int64(status), 10)] = &ResponseRef{ - Value: response, - } + code := "default" + if status != 0 { + code = strconv.FormatInt(int64(status), 10) + } + responses[code] = &ResponseRef{ + Value: response, } } -func (operation *Operation) Validate(c context.Context) error { +// Validate returns an error if Operation does not comply with the OpenAPI spec. +func (operation *Operation) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if v := operation.Parameters; v != nil { - if err := v.Validate(c); err != nil { + if err := v.Validate(ctx); err != nil { return err } } + if v := operation.RequestBody; v != nil { - if err := v.Validate(c); err != nil { + if err := v.Validate(ctx); err != nil { return err } } + if v := operation.Responses; v != nil { - if err := v.Validate(c); err != nil { + if err := v.Validate(ctx); err != nil { return err } } else { - return errors.New("Variable 'Responses' must be a JSON object") + return errors.New("value of responses must be an object") } - return nil + + if v := operation.ExternalDocs; v != nil { + if err := v.Validate(ctx); err != nil { + return fmt.Errorf("invalid external docs: %w", err) + } + } + + return validateExtensions(ctx, operation.Extensions) } diff --git a/openapi3/operation_test.go b/openapi3/operation_test.go index 22f325ec5..50684a3ae 100644 --- a/openapi3/operation_test.go +++ b/openapi3/operation_test.go @@ -53,7 +53,7 @@ func TestOperationValidation(t *testing.T) { { "when no Responses object is provided", operationWithoutResponses(), - errors.New("Variable 'Responses' must be a JSON object"), + errors.New("value of responses must be an object"), }, { "when a Responses object is provided", diff --git a/openapi3/parameter.go b/openapi3/parameter.go index 30ec868b6..ec1893e9a 100644 --- a/openapi3/parameter.go +++ b/openapi3/parameter.go @@ -2,15 +2,56 @@ package openapi3 import ( "context" + "encoding/json" "errors" "fmt" + "sort" + "strconv" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" ) +type ParametersMap map[string]*ParameterRef + +var _ jsonpointer.JSONPointable = (*ParametersMap)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (p ParametersMap) JSONLookup(token string) (interface{}, error) { + ref, ok := p[token] + if ref == nil || ok == false { + return nil, fmt.Errorf("object has no field %q", token) + } + + if ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + // Parameters is specified by OpenAPI/Swagger 3.0 standard. type Parameters []*ParameterRef +var _ jsonpointer.JSONPointable = (*Parameters)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (p Parameters) JSONLookup(token string) (interface{}, error) { + index, err := strconv.Atoi(token) + if err != nil { + return nil, err + } + + if index < 0 || index >= len(p) { + return nil, fmt.Errorf("index %d out of bounds of array of length %d", index, len(p)) + } + + ref := p[index] + + if ref != nil && ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + func NewParameters() Parameters { return make(Parameters, 0, 4) } @@ -26,46 +67,49 @@ func (parameters Parameters) GetByInAndName(in string, name string) *Parameter { return nil } -func (parameters Parameters) Validate(c context.Context) error { - m := make(map[string]struct{}) - for _, item := range parameters { - if err := item.Validate(c); err != nil { - return err - } - if v := item.Value; v != nil { - in := v.In - name := v.Name - key := in + ":" + name - if _, exists := m[key]; exists { - return fmt.Errorf("More than one '%s' parameter has name '%s'", in, name) - } - m[key] = struct{}{} - if err := item.Validate(c); err != nil { - return err +// Validate returns an error if Parameters does not comply with the OpenAPI spec. +func (parameters Parameters) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + dupes := make(map[string]struct{}) + for _, parameterRef := range parameters { + if v := parameterRef.Value; v != nil { + key := v.In + ":" + v.Name + if _, ok := dupes[key]; ok { + return fmt.Errorf("more than one %q parameter has name %q", v.In, v.Name) } + dupes[key] = struct{}{} + } + + if err := parameterRef.Validate(ctx); err != nil { + return err } } return nil } // Parameter is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#parameter-object type Parameter struct { - ExtensionProps - Name string `json:"name,omitempty" yaml:"name,omitempty"` - In string `json:"in,omitempty" yaml:"in,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Style string `json:"style,omitempty" yaml:"style,omitempty"` - Explode *bool `json:"explode,omitempty" yaml:"explode,omitempty"` - AllowEmptyValue bool `json:"allowEmptyValue,omitempty" yaml:"allowEmptyValue,omitempty"` - AllowReserved bool `json:"allowReserved,omitempty" yaml:"allowReserved,omitempty"` - Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` - Required bool `json:"required,omitempty" yaml:"required,omitempty"` - Schema *SchemaRef `json:"schema,omitempty" yaml:"schema,omitempty"` - Example interface{} `json:"example,omitempty" yaml:"example,omitempty"` - Examples map[string]*ExampleRef `json:"examples,omitempty" yaml:"examples,omitempty"` - Content Content `json:"content,omitempty" yaml:"content,omitempty"` + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Name string `json:"name,omitempty" yaml:"name,omitempty"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Style string `json:"style,omitempty" yaml:"style,omitempty"` + Explode *bool `json:"explode,omitempty" yaml:"explode,omitempty"` + AllowEmptyValue bool `json:"allowEmptyValue,omitempty" yaml:"allowEmptyValue,omitempty"` + AllowReserved bool `json:"allowReserved,omitempty" yaml:"allowReserved,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Schema *SchemaRef `json:"schema,omitempty" yaml:"schema,omitempty"` + Example interface{} `json:"example,omitempty" yaml:"example,omitempty"` + Examples Examples `json:"examples,omitempty" yaml:"examples,omitempty"` + Content Content `json:"content,omitempty" yaml:"content,omitempty"` } +var _ jsonpointer.JSONPointable = (*Parameter)(nil) + const ( ParameterInPath = "path" ParameterInQuery = "query" @@ -123,12 +167,121 @@ func (parameter *Parameter) WithSchema(value *Schema) *Parameter { return parameter } -func (parameter *Parameter) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(parameter) +// MarshalJSON returns the JSON encoding of Parameter. +func (parameter Parameter) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 13+len(parameter.Extensions)) + for k, v := range parameter.Extensions { + m[k] = v + } + + if x := parameter.Name; x != "" { + m["name"] = x + } + if x := parameter.In; x != "" { + m["in"] = x + } + if x := parameter.Description; x != "" { + m["description"] = x + } + if x := parameter.Style; x != "" { + m["style"] = x + } + if x := parameter.Explode; x != nil { + m["explode"] = x + } + if x := parameter.AllowEmptyValue; x { + m["allowEmptyValue"] = x + } + if x := parameter.AllowReserved; x { + m["allowReserved"] = x + } + if x := parameter.Deprecated; x { + m["deprecated"] = x + } + if x := parameter.Required; x { + m["required"] = x + } + if x := parameter.Schema; x != nil { + m["schema"] = x + } + if x := parameter.Example; x != nil { + m["example"] = x + } + if x := parameter.Examples; len(x) != 0 { + m["examples"] = x + } + if x := parameter.Content; len(x) != 0 { + m["content"] = x + } + + return json.Marshal(m) } +// UnmarshalJSON sets Parameter to a copy of data. func (parameter *Parameter) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, parameter) + type ParameterBis Parameter + var x ParameterBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + + delete(x.Extensions, "name") + delete(x.Extensions, "in") + delete(x.Extensions, "description") + delete(x.Extensions, "style") + delete(x.Extensions, "explode") + delete(x.Extensions, "allowEmptyValue") + delete(x.Extensions, "allowReserved") + delete(x.Extensions, "deprecated") + delete(x.Extensions, "required") + delete(x.Extensions, "schema") + delete(x.Extensions, "example") + delete(x.Extensions, "examples") + delete(x.Extensions, "content") + + *parameter = Parameter(x) + return nil +} + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (parameter Parameter) JSONLookup(token string) (interface{}, error) { + switch token { + case "schema": + if parameter.Schema != nil { + if parameter.Schema.Ref != "" { + return &Ref{Ref: parameter.Schema.Ref}, nil + } + return parameter.Schema.Value, nil + } + case "name": + return parameter.Name, nil + case "in": + return parameter.In, nil + case "description": + return parameter.Description, nil + case "style": + return parameter.Style, nil + case "explode": + return parameter.Explode, nil + case "allowEmptyValue": + return parameter.AllowEmptyValue, nil + case "allowReserved": + return parameter.AllowReserved, nil + case "deprecated": + return parameter.Deprecated, nil + case "required": + return parameter.Required, nil + case "example": + return parameter.Example, nil + case "examples": + return parameter.Examples, nil + case "content": + return parameter.Content, nil + } + + v, _, err := jsonpointer.GetForToken(parameter.Extensions, token) + return v, err } // SerializationMethod returns a parameter's serialization method. @@ -161,9 +314,12 @@ func (parameter *Parameter) SerializationMethod() (*SerializationMethod, error) } } -func (parameter *Parameter) Validate(c context.Context) error { +// Validate returns an error if Parameter does not comply with the OpenAPI spec. +func (parameter *Parameter) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if parameter.Name == "" { - return errors.New("Parameter name can't be blank") + return errors.New("parameter name can't be blank") } in := parameter.In switch in { @@ -173,7 +329,11 @@ func (parameter *Parameter) Validate(c context.Context) error { ParameterInHeader, ParameterInCookie: default: - return fmt.Errorf("Parameter can't have 'in' value '%s'", parameter.In) + return fmt.Errorf("parameter can't have 'in' value %q", parameter.In) + } + + if in == ParameterInPath && !parameter.Required { + return fmt.Errorf("path parameter %q must be required", parameter.Name) } // Validate a parameter's serialization method. @@ -206,27 +366,53 @@ func (parameter *Parameter) Validate(c context.Context) error { smSupported = true } if !smSupported { - e := fmt.Errorf("Serialization method with style=%q and explode=%v is not supported by a %s parameter", sm.Style, sm.Explode, in) - return fmt.Errorf("Parameter '%v' schema is invalid: %v", parameter.Name, e) + e := fmt.Errorf("serialization method with style=%q and explode=%v is not supported by a %s parameter", sm.Style, sm.Explode, in) + return fmt.Errorf("parameter %q schema is invalid: %w", parameter.Name, e) } - if parameter.Schema != nil && parameter.Content != nil { - return fmt.Errorf("Parameter '%v' schema is invalid: %v", parameter.Name, - errors.New("Cannot contain both schema and content in a parameter")) + if (parameter.Schema == nil) == (parameter.Content == nil) { + e := errors.New("parameter must contain exactly one of content and schema") + return fmt.Errorf("parameter %q schema is invalid: %w", parameter.Name, e) } - if parameter.Schema == nil && parameter.Content == nil { - return fmt.Errorf("Parameter '%v' schema is invalid: %v", parameter.Name, - errors.New("A parameter MUST contain either a schema property, or a content property")) + + if content := parameter.Content; content != nil { + if err := content.Validate(ctx); err != nil { + return fmt.Errorf("parameter %q content is invalid: %w", parameter.Name, err) + } } + if schema := parameter.Schema; schema != nil { - if err := schema.Validate(c); err != nil { - return fmt.Errorf("Parameter '%v' schema is invalid: %v", parameter.Name, err) + if err := schema.Validate(ctx); err != nil { + return fmt.Errorf("parameter %q schema is invalid: %w", parameter.Name, err) } - } - if content := parameter.Content; content != nil { - if err := content.Validate(c); err != nil { - return fmt.Errorf("Parameter content is invalid: %v", err) + if parameter.Example != nil && parameter.Examples != nil { + return fmt.Errorf("parameter %q example and examples are mutually exclusive", parameter.Name) + } + + if vo := getValidationOptions(ctx); vo.examplesValidationDisabled { + return nil + } + if example := parameter.Example; example != nil { + if err := validateExampleValue(ctx, example, schema.Value); err != nil { + return fmt.Errorf("invalid example: %w", err) + } + } else if examples := parameter.Examples; examples != nil { + names := make([]string, 0, len(examples)) + for name := range examples { + names = append(names, name) + } + sort.Strings(names) + for _, k := range names { + v := examples[k] + if err := v.Validate(ctx); err != nil { + return fmt.Errorf("%s: %w", k, err) + } + if err := validateExampleValue(ctx, v.Value.Value, schema.Value); err != nil { + return fmt.Errorf("%s: %w", k, err) + } + } } } - return nil + + return validateExtensions(ctx, parameter.Extensions) } diff --git a/openapi3/parameter_issue223_test.go b/openapi3/parameter_issue223_test.go new file mode 100644 index 000000000..9b86954f4 --- /dev/null +++ b/openapi3/parameter_issue223_test.go @@ -0,0 +1,116 @@ +package openapi3 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPathParametersMatchPath(t *testing.T) { + spec := ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + # <------------------ no parameters + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string +` + + doc, err := NewLoader().LoadFromData([]byte(spec)) + require.NoError(t, err) + err = doc.Validate(context.Background()) + require.EqualError(t, err, `invalid paths: operation GET /pets/{petId} must define exactly all path parameters (missing: [petId])`) +} diff --git a/openapi3/path_item.go b/openapi3/path_item.go index 2ed578aa0..fab75d93c 100644 --- a/openapi3/path_item.go +++ b/openapi3/path_item.go @@ -2,14 +2,17 @@ package openapi3 import ( "context" + "encoding/json" "fmt" "net/http" - - "github.com/getkin/kin-openapi/jsoninfo" + "sort" ) +// PathItem is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#path-item-object type PathItem struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` @@ -26,16 +29,86 @@ type PathItem struct { Parameters Parameters `json:"parameters,omitempty" yaml:"parameters,omitempty"` } -func (pathItem *PathItem) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(pathItem) +// MarshalJSON returns the JSON encoding of PathItem. +func (pathItem PathItem) MarshalJSON() ([]byte, error) { + if ref := pathItem.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + + m := make(map[string]interface{}, 13+len(pathItem.Extensions)) + for k, v := range pathItem.Extensions { + m[k] = v + } + if x := pathItem.Summary; x != "" { + m["summary"] = x + } + if x := pathItem.Description; x != "" { + m["description"] = x + } + if x := pathItem.Connect; x != nil { + m["connect"] = x + } + if x := pathItem.Delete; x != nil { + m["delete"] = x + } + if x := pathItem.Get; x != nil { + m["get"] = x + } + if x := pathItem.Head; x != nil { + m["head"] = x + } + if x := pathItem.Options; x != nil { + m["options"] = x + } + if x := pathItem.Patch; x != nil { + m["patch"] = x + } + if x := pathItem.Post; x != nil { + m["post"] = x + } + if x := pathItem.Put; x != nil { + m["put"] = x + } + if x := pathItem.Trace; x != nil { + m["trace"] = x + } + if x := pathItem.Servers; len(x) != 0 { + m["servers"] = x + } + if x := pathItem.Parameters; len(x) != 0 { + m["parameters"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets PathItem to a copy of data. func (pathItem *PathItem) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, pathItem) + type PathItemBis PathItem + var x PathItemBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "$ref") + delete(x.Extensions, "summary") + delete(x.Extensions, "description") + delete(x.Extensions, "connect") + delete(x.Extensions, "delete") + delete(x.Extensions, "get") + delete(x.Extensions, "head") + delete(x.Extensions, "options") + delete(x.Extensions, "patch") + delete(x.Extensions, "post") + delete(x.Extensions, "put") + delete(x.Extensions, "trace") + delete(x.Extensions, "servers") + delete(x.Extensions, "parameters") + *pathItem = PathItem(x) + return nil } func (pathItem *PathItem) Operations() map[string]*Operation { - operations := make(map[string]*Operation, 4) + operations := make(map[string]*Operation) if v := pathItem.Connect; v != nil { operations[http.MethodConnect] = v } @@ -87,7 +160,7 @@ func (pathItem *PathItem) GetOperation(method string) *Operation { case http.MethodTrace: return pathItem.Trace default: - panic(fmt.Errorf("Unsupported HTTP method '%s'", method)) + panic(fmt.Errorf("unsupported HTTP method %q", method)) } } @@ -112,15 +185,27 @@ func (pathItem *PathItem) SetOperation(method string, operation *Operation) { case http.MethodTrace: pathItem.Trace = operation default: - panic(fmt.Errorf("Unsupported HTTP method '%s'", method)) + panic(fmt.Errorf("unsupported HTTP method %q", method)) } } -func (pathItem *PathItem) Validate(c context.Context) error { - for _, operation := range pathItem.Operations() { - if err := operation.Validate(c); err != nil { - return err +// Validate returns an error if PathItem does not comply with the OpenAPI spec. +func (pathItem *PathItem) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + operations := pathItem.Operations() + + methods := make([]string, 0, len(operations)) + for method := range operations { + methods = append(methods, method) + } + sort.Strings(methods) + for _, method := range methods { + operation := operations[method] + if err := operation.Validate(ctx); err != nil { + return fmt.Errorf("invalid operation %s: %v", method, err) } } - return nil + + return validateExtensions(ctx, pathItem.Extensions) } diff --git a/openapi3/paths.go b/openapi3/paths.go index d738e9dac..0986b0557 100644 --- a/openapi3/paths.go +++ b/openapi3/paths.go @@ -3,40 +3,153 @@ package openapi3 import ( "context" "fmt" + "sort" "strings" ) -// Paths is specified by OpenAPI/Swagger standard version 3.0. +// Paths is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#paths-object type Paths map[string]*PathItem -func (paths Paths) Validate(c context.Context) error { - normalizedPaths := make(map[string]string) - for path, pathItem := range paths { - normalizedPath := normalizePathKey(path) - if oldPath, exists := normalizedPaths[normalizedPath]; exists { - return fmt.Errorf("Conflicting paths '%v' and '%v'", path, oldPath) - } +// Validate returns an error if Paths does not comply with the OpenAPI spec. +func (paths Paths) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + normalizedPaths := make(map[string]string, len(paths)) + + keys := make([]string, 0, len(paths)) + for key := range paths { + keys = append(keys, key) + } + sort.Strings(keys) + for _, path := range keys { + pathItem := paths[path] if path == "" || path[0] != '/' { - return fmt.Errorf("Path '%v' does not start with '/'", path) + return fmt.Errorf("path %q does not start with a forward slash (/)", path) + } + + if pathItem == nil { + pathItem = &PathItem{} + paths[path] = pathItem + } + + normalizedPath, _, varsInPath := normalizeTemplatedPath(path) + if oldPath, ok := normalizedPaths[normalizedPath]; ok { + return fmt.Errorf("conflicting paths %q and %q", path, oldPath) } normalizedPaths[path] = path - if err := pathItem.Validate(c); err != nil { - return err + + var commonParams []string + for _, parameterRef := range pathItem.Parameters { + if parameterRef != nil { + if parameter := parameterRef.Value; parameter != nil && parameter.In == ParameterInPath { + commonParams = append(commonParams, parameter.Name) + } + } + } + operations := pathItem.Operations() + methods := make([]string, 0, len(operations)) + for method := range operations { + methods = append(methods, method) } + sort.Strings(methods) + for _, method := range methods { + operation := operations[method] + var setParams []string + for _, parameterRef := range operation.Parameters { + if parameterRef != nil { + if parameter := parameterRef.Value; parameter != nil && parameter.In == ParameterInPath { + setParams = append(setParams, parameter.Name) + } + } + } + if expected := len(setParams) + len(commonParams); expected != len(varsInPath) { + expected -= len(varsInPath) + if expected < 0 { + expected *= -1 + } + missing := make(map[string]struct{}, expected) + definedParams := append(setParams, commonParams...) + for _, name := range definedParams { + if _, ok := varsInPath[name]; !ok { + missing[name] = struct{}{} + } + } + for name := range varsInPath { + got := false + for _, othername := range definedParams { + if othername == name { + got = true + break + } + } + if !got { + missing[name] = struct{}{} + } + } + if len(missing) != 0 { + missings := make([]string, 0, len(missing)) + for name := range missing { + missings = append(missings, name) + } + return fmt.Errorf("operation %s %s must define exactly all path parameters (missing: %v)", method, path, missings) + } + } + } + + if err := pathItem.Validate(ctx); err != nil { + return fmt.Errorf("invalid path %s: %v", path, err) + } + } + + if err := paths.validateUniqueOperationIDs(); err != nil { + return err } + return nil } +// InMatchingOrder returns paths in the order they are matched against URLs. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#paths-object +// When matching URLs, concrete (non-templated) paths would be matched +// before their templated counterparts. +func (paths Paths) InMatchingOrder() []string { + // NOTE: sorting by number of variables ASC then by descending lexicographical + // order seems to be a good heuristic. + if paths == nil { + return nil + } + + vars := make(map[int][]string) + max := 0 + for path := range paths { + count := strings.Count(path, "}") + vars[count] = append(vars[count], path) + if count > max { + max = count + } + } + + ordered := make([]string, 0, len(paths)) + for c := 0; c <= max; c++ { + if ps, ok := vars[c]; ok { + sort.Sort(sort.Reverse(sort.StringSlice(ps))) + ordered = append(ordered, ps...) + } + } + return ordered +} + // Find returns a path that matches the key. // // The method ignores differences in template variable names (except possible "*" suffix). // // For example: // -// paths := openapi3.Paths { -// "/person/{personName}": &openapi3.PathItem{}, -// } -// pathItem := path.Find("/person/{name}") +// paths := openapi3.Paths { +// "/person/{personName}": &openapi3.PathItem{}, +// } +// pathItem := path.Find("/person/{name}") // // would return the correct path item. func (paths Paths) Find(key string) *PathItem { @@ -46,51 +159,85 @@ func (paths Paths) Find(key string) *PathItem { return pathItem } - // Use normalized keys - normalizedSearchedPath := normalizePathKey(key) + normalizedPath, expected, _ := normalizeTemplatedPath(key) for path, pathItem := range paths { - normalizedPath := normalizePathKey(path) - if normalizedPath == normalizedSearchedPath { + pathNormalized, got, _ := normalizeTemplatedPath(path) + if got == expected && pathNormalized == normalizedPath { return pathItem } } return nil } -func normalizePathKey(key string) string { - // If the argument has no path variables, return the argument - if strings.IndexByte(key, '{') < 0 { - return key +func (paths Paths) validateUniqueOperationIDs() error { + operationIDs := make(map[string]string) + for urlPath, pathItem := range paths { + if pathItem == nil { + continue + } + for httpMethod, operation := range pathItem.Operations() { + if operation == nil || operation.OperationID == "" { + continue + } + endpoint := httpMethod + " " + urlPath + if endpointDup, ok := operationIDs[operation.OperationID]; ok { + if endpoint > endpointDup { // For make error message a bit more deterministic. May be useful for tests. + endpoint, endpointDup = endpointDup, endpoint + } + return fmt.Errorf("operations %q and %q have the same operation id %q", + endpoint, endpointDup, operation.OperationID) + } + operationIDs[operation.OperationID] = endpoint + } + } + return nil +} + +func normalizeTemplatedPath(path string) (string, uint, map[string]struct{}) { + if strings.IndexByte(path, '{') < 0 { + return path, 0, nil } - // Allocate buffer - buf := make([]byte, 0, len(key)) + var buffTpl strings.Builder + buffTpl.Grow(len(path)) - // Visit each byte - isVariable := false - for i := 0; i < len(key); i++ { - c := key[i] + var ( + cc rune + count uint + isVariable bool + vars = make(map[string]struct{}) + buffVar strings.Builder + ) + for i, c := range path { if isVariable { if c == '}' { - // End path variables + // End path variable + isVariable = false + + vars[buffVar.String()] = struct{}{} + buffVar = strings.Builder{} + // First append possible '*' before this character // The character '}' will be appended - if i > 0 && key[i-1] == '*' { - buf = append(buf, '*') + if i > 0 && cc == '*' { + buffTpl.WriteRune(cc) } - isVariable = false } else { - // Skip this character + buffVar.WriteRune(c) continue } + } else if c == '{' { // Begin path variable - // The character '{' will be appended isVariable = true + + // The character '{' will be appended + count++ } // Append the character - buf = append(buf, c) + buffTpl.WriteRune(c) + cc = c } - return string(buf) + return buffTpl.String(), count, vars } diff --git a/openapi3/paths_test.go b/openapi3/paths_test.go new file mode 100644 index 000000000..4ff9fba00 --- /dev/null +++ b/openapi3/paths_test.go @@ -0,0 +1,94 @@ +package openapi3 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPathsValidate(t *testing.T) { + tests := []struct { + name string + spec string + wantErr string + }{ + { + name: "ok, empty paths", + spec: ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +paths: + /pets: +`, + }, + { + name: "operation ids are not unique, same path", + spec: ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +paths: + /pets: + post: + operationId: createPet + responses: + 201: + description: "entity created" + delete: + operationId: createPet + responses: + 204: + description: "entity deleted" +`, + wantErr: `operations "DELETE /pets" and "POST /pets" have the same operation id "createPet"`, + }, + { + name: "operation ids are not unique, different paths", + spec: ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +paths: + /pets: + post: + operationId: createPet + responses: + 201: + description: "entity created" + /users: + post: + operationId: createPet + responses: + 201: + description: "entity created" +`, + wantErr: `operations "POST /pets" and "POST /users" have the same operation id "createPet"`, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + doc, err := NewLoader().LoadFromData([]byte(tt.spec)) + require.NoError(t, err) + + err = doc.Paths.Validate(context.Background()) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Equal(t, tt.wantErr, err.Error()) + }) + } +} diff --git a/openapi3/race_test.go b/openapi3/race_test.go new file mode 100644 index 000000000..c617cfe49 --- /dev/null +++ b/openapi3/race_test.go @@ -0,0 +1,28 @@ +package openapi3_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestRaceyPatternSchema(t *testing.T) { + schema := openapi3.Schema{ + Pattern: "^test|for|race|condition$", + Type: "string", + } + + err := schema.Validate(context.Background()) + require.NoError(t, err) + + visit := func() { + err := schema.VisitJSONString("test") + require.NoError(t, err) + } + + go visit() + visit() +} diff --git a/openapi3/ref.go b/openapi3/ref.go new file mode 100644 index 000000000..a937de4a5 --- /dev/null +++ b/openapi3/ref.go @@ -0,0 +1,7 @@ +package openapi3 + +// Ref is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object +type Ref struct { + Ref string `json:"$ref" yaml:"$ref"` +} diff --git a/openapi3/refs.go b/openapi3/refs.go index 9790b4705..15f5179da 100644 --- a/openapi3/refs.go +++ b/openapi3/refs.go @@ -2,198 +2,694 @@ package openapi3 import ( "context" + "encoding/json" + "fmt" + "sort" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" + "github.com/perimeterx/marshmallow" ) +// CallbackRef represents either a Callback or a $ref to a Callback. +// When serializing and both fields are set, Ref is preferred over Value. type CallbackRef struct { Ref string Value *Callback + extra []string } -func (value *CallbackRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +var _ jsonpointer.JSONPointable = (*CallbackRef)(nil) + +// MarshalYAML returns the YAML encoding of CallbackRef. +func (x CallbackRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *CallbackRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of CallbackRef. +func (x CallbackRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return json.Marshal(x.Value) +} + +// UnmarshalJSON sets CallbackRef to a copy of data. +func (x *CallbackRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if CallbackRef does not comply with the OpenAPI spec. +func (x *CallbackRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *CallbackRef) Validate(c context.Context) error { - v := value.Value - if v == nil { - return foundUnresolvedRef(value.Ref) +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *CallbackRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil } - return v.Validate(c) + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } +// ExampleRef represents either a Example or a $ref to a Example. +// When serializing and both fields are set, Ref is preferred over Value. type ExampleRef struct { Ref string Value *Example + extra []string } -func (value *ExampleRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +var _ jsonpointer.JSONPointable = (*ExampleRef)(nil) + +// MarshalYAML returns the YAML encoding of ExampleRef. +func (x ExampleRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *ExampleRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of ExampleRef. +func (x ExampleRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return x.Value.MarshalJSON() +} + +// UnmarshalJSON sets ExampleRef to a copy of data. +func (x *ExampleRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if ExampleRef does not comply with the OpenAPI spec. +func (x *ExampleRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *ExampleRef) Validate(c context.Context) error { - return nil +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *ExampleRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } +// HeaderRef represents either a Header or a $ref to a Header. +// When serializing and both fields are set, Ref is preferred over Value. type HeaderRef struct { Ref string Value *Header + extra []string } -func (value *HeaderRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +var _ jsonpointer.JSONPointable = (*HeaderRef)(nil) + +// MarshalYAML returns the YAML encoding of HeaderRef. +func (x HeaderRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *HeaderRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of HeaderRef. +func (x HeaderRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return x.Value.MarshalJSON() +} + +// UnmarshalJSON sets HeaderRef to a copy of data. +func (x *HeaderRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if HeaderRef does not comply with the OpenAPI spec. +func (x *HeaderRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *HeaderRef) Validate(c context.Context) error { - v := value.Value - if v == nil { - return foundUnresolvedRef(value.Ref) +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *HeaderRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil } - return v.Validate(c) + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } +// LinkRef represents either a Link or a $ref to a Link. +// When serializing and both fields are set, Ref is preferred over Value. type LinkRef struct { Ref string Value *Link + extra []string } -func (value *LinkRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +var _ jsonpointer.JSONPointable = (*LinkRef)(nil) + +// MarshalYAML returns the YAML encoding of LinkRef. +func (x LinkRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *LinkRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of LinkRef. +func (x LinkRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return x.Value.MarshalJSON() +} + +// UnmarshalJSON sets LinkRef to a copy of data. +func (x *LinkRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if LinkRef does not comply with the OpenAPI spec. +func (x *LinkRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *LinkRef) Validate(c context.Context) error { - v := value.Value - if v == nil { - return foundUnresolvedRef(value.Ref) +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *LinkRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil } - return v.Validate(c) + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } +// ParameterRef represents either a Parameter or a $ref to a Parameter. +// When serializing and both fields are set, Ref is preferred over Value. type ParameterRef struct { Ref string Value *Parameter + extra []string } -func (value *ParameterRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +var _ jsonpointer.JSONPointable = (*ParameterRef)(nil) + +// MarshalYAML returns the YAML encoding of ParameterRef. +func (x ParameterRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *ParameterRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of ParameterRef. +func (x ParameterRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return x.Value.MarshalJSON() +} + +// UnmarshalJSON sets ParameterRef to a copy of data. +func (x *ParameterRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if ParameterRef does not comply with the OpenAPI spec. +func (x *ParameterRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *ParameterRef) Validate(c context.Context) error { - v := value.Value - if v == nil { - return foundUnresolvedRef(value.Ref) +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *ParameterRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil } - return v.Validate(c) + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } -type ResponseRef struct { +// RequestBodyRef represents either a RequestBody or a $ref to a RequestBody. +// When serializing and both fields are set, Ref is preferred over Value. +type RequestBodyRef struct { Ref string - Value *Response + Value *RequestBody + extra []string } -func (value *ResponseRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +var _ jsonpointer.JSONPointable = (*RequestBodyRef)(nil) + +// MarshalYAML returns the YAML encoding of RequestBodyRef. +func (x RequestBodyRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *ResponseRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of RequestBodyRef. +func (x RequestBodyRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return x.Value.MarshalJSON() +} + +// UnmarshalJSON sets RequestBodyRef to a copy of data. +func (x *RequestBodyRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if RequestBodyRef does not comply with the OpenAPI spec. +func (x *RequestBodyRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *ResponseRef) Validate(c context.Context) error { - v := value.Value - if v == nil { - return foundUnresolvedRef(value.Ref) +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *RequestBodyRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil } - return v.Validate(c) + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } -type RequestBodyRef struct { +// ResponseRef represents either a Response or a $ref to a Response. +// When serializing and both fields are set, Ref is preferred over Value. +type ResponseRef struct { Ref string - Value *RequestBody + Value *Response + extra []string } -func (value *RequestBodyRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +var _ jsonpointer.JSONPointable = (*ResponseRef)(nil) + +// MarshalYAML returns the YAML encoding of ResponseRef. +func (x ResponseRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *RequestBodyRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of ResponseRef. +func (x ResponseRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return x.Value.MarshalJSON() +} + +// UnmarshalJSON sets ResponseRef to a copy of data. +func (x *ResponseRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if ResponseRef does not comply with the OpenAPI spec. +func (x *ResponseRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *RequestBodyRef) Validate(c context.Context) error { - v := value.Value - if v == nil { - return foundUnresolvedRef(value.Ref) +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *ResponseRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil } - return v.Validate(c) + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } +// SchemaRef represents either a Schema or a $ref to a Schema. +// When serializing and both fields are set, Ref is preferred over Value. type SchemaRef struct { Ref string Value *Schema + extra []string } -func NewSchemaRef(ref string, value *Schema) *SchemaRef { - return &SchemaRef{ - Ref: ref, - Value: value, - } -} +var _ jsonpointer.JSONPointable = (*SchemaRef)(nil) -func (value *SchemaRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +// MarshalYAML returns the YAML encoding of SchemaRef. +func (x SchemaRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *SchemaRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of SchemaRef. +func (x SchemaRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return x.Value.MarshalJSON() +} + +// UnmarshalJSON sets SchemaRef to a copy of data. +func (x *SchemaRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if SchemaRef does not comply with the OpenAPI spec. +func (x *SchemaRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *SchemaRef) Validate(c context.Context) error { - v := value.Value - if v == nil { - return foundUnresolvedRef(value.Ref) +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *SchemaRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil } - return v.Validate(c) + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } +// SecuritySchemeRef represents either a SecurityScheme or a $ref to a SecurityScheme. +// When serializing and both fields are set, Ref is preferred over Value. type SecuritySchemeRef struct { Ref string Value *SecurityScheme + extra []string } -func (value *SecuritySchemeRef) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalRef(value.Ref, value.Value) +var _ jsonpointer.JSONPointable = (*SecuritySchemeRef)(nil) + +// MarshalYAML returns the YAML encoding of SecuritySchemeRef. +func (x SecuritySchemeRef) MarshalYAML() (interface{}, error) { + if ref := x.Ref; ref != "" { + return &Ref{Ref: ref}, nil + } + return x.Value, nil } -func (value *SecuritySchemeRef) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalRef(data, &value.Ref, &value.Value) +// MarshalJSON returns the JSON encoding of SecuritySchemeRef. +func (x SecuritySchemeRef) MarshalJSON() ([]byte, error) { + if ref := x.Ref; ref != "" { + return json.Marshal(Ref{Ref: ref}) + } + return x.Value.MarshalJSON() +} + +// UnmarshalJSON sets SecuritySchemeRef to a copy of data. +func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error { + var refOnly Ref + if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { + x.Ref = refOnly.Ref + if len(extra) != 0 { + x.extra = make([]string, 0, len(extra)) + for key := range extra { + x.extra = append(x.extra, key) + } + sort.Strings(x.extra) + } + return nil + } + return json.Unmarshal(data, &x.Value) +} + +// Validate returns an error if SecuritySchemeRef does not comply with the OpenAPI spec. +func (x *SecuritySchemeRef) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if extra := x.extra; len(extra) != 0 { + extras := make([]string, 0, len(extra)) + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + if allowed == nil { + allowed = make(map[string]struct{}, 0) + } + for _, ex := range extra { + if _, ok := allowed[ex]; !ok { + extras = append(extras, ex) + } + } + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + } + if v := x.Value; v != nil { + return v.Validate(ctx) + } + return foundUnresolvedRef(x.Ref) } -func (value *SecuritySchemeRef) Validate(c context.Context) error { - v := value.Value - if v == nil { - return foundUnresolvedRef(value.Ref) +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (x *SecuritySchemeRef) JSONLookup(token string) (interface{}, error) { + if token == "$ref" { + return x.Ref, nil } - return v.Validate(c) + ptr, _, err := jsonpointer.GetForToken(x.Value, token) + return ptr, err } diff --git a/openapi3/refs_test.go b/openapi3/refs_test.go new file mode 100644 index 000000000..e714da455 --- /dev/null +++ b/openapi3/refs_test.go @@ -0,0 +1,275 @@ +package openapi3 + +import ( + "reflect" + "testing" + + "github.com/go-openapi/jsonpointer" + "github.com/stretchr/testify/require" +) + +func TestIssue222(t *testing.T) { + spec := ` +openapi: 3.0.0 +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: 'http://petstore.swagger.io/v1' +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': # <--------------- PANIC HERE + + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '/pets/{petId}': + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string +` + + _, err := NewLoader().LoadFromData([]byte(spec)) + require.EqualError(t, err, `invalid response: value MUST be an object`) +} + +func TestIssue247(t *testing.T) { + spec := `openapi: 3.0.2 +info: + title: Swagger Petstore - OpenAPI 3.0 + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.5 +servers: +- url: /api/v3 +tags: +- name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io +- name: store + description: Operations about user +- name: user + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: http://swagger.io +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "405": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + OneOfTest: + type: object + oneOf: + - type: string + - type: integer + format: int32 + ` + root, err := NewLoader().LoadFromData([]byte(spec)) + require.NoError(t, err) + + ptr, err := jsonpointer.New("/paths/~1pet/put/responses/200/content") + require.NoError(t, err) + v, kind, err := ptr.Get(root) + require.NoError(t, err) + require.Equal(t, reflect.TypeOf(Content{}).Kind(), kind) + require.IsType(t, Content{}, v) + + ptr, err = jsonpointer.New("/paths/~1pet/put/responses/200/content/application~1json/schema") + require.NoError(t, err) + v, kind, err = ptr.Get(root) + require.NoError(t, err) + require.Equal(t, reflect.Ptr, kind) + require.IsType(t, &Ref{}, v) + require.Equal(t, "#/components/schemas/Pet", v.(*Ref).Ref) + + ptr, err = jsonpointer.New("/components/schemas/Pets/items") + require.NoError(t, err) + v, kind, err = ptr.Get(root) + require.NoError(t, err) + require.Equal(t, reflect.Ptr, kind) + require.IsType(t, &Ref{}, v) + require.Equal(t, "#/components/schemas/Pet", v.(*Ref).Ref) + + ptr, err = jsonpointer.New("/components/schemas/Error/properties/code") + require.NoError(t, err) + v, kind, err = ptr.Get(root) + require.NoError(t, err) + require.Equal(t, reflect.Ptr, kind) + require.IsType(t, &Schema{}, v) + require.Equal(t, "integer", v.(*Schema).Type) + + ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/0") + require.NoError(t, err) + v, kind, err = ptr.Get(root) + require.NoError(t, err) + require.Equal(t, reflect.Ptr, kind) + require.IsType(t, &Schema{}, v) + require.Equal(t, "string", v.(*Schema).Type) + + ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/1") + require.NoError(t, err) + v, kind, err = ptr.Get(root) + require.NoError(t, err) + require.Equal(t, reflect.Ptr, kind) + require.IsType(t, &Schema{}, v) + require.Equal(t, "integer", v.(*Schema).Type) + + ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/5") + require.NoError(t, err) + _, _, err = ptr.Get(root) + require.Error(t, err) + + ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/-1") + require.NoError(t, err) + _, _, err = ptr.Get(root) + require.Error(t, err) +} diff --git a/openapi3/request_body.go b/openapi3/request_body.go index 6e8c8fc72..de8919f41 100644 --- a/openapi3/request_body.go +++ b/openapi3/request_body.go @@ -2,16 +2,38 @@ package openapi3 import ( "context" + "encoding/json" + "errors" + "fmt" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" ) +type RequestBodies map[string]*RequestBodyRef + +var _ jsonpointer.JSONPointable = (*RequestBodyRef)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (r RequestBodies) JSONLookup(token string) (interface{}, error) { + ref, ok := r[token] + if ok == false { + return nil, fmt.Errorf("object has no field %q", token) + } + + if ref != nil && ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + // RequestBody is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#request-body-object type RequestBody struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` Required bool `json:"required,omitempty" yaml:"required,omitempty"` - Content Content `json:"content,omitempty" yaml:"content,omitempty"` + Content Content `json:"content" yaml:"content"` } func NewRequestBody() *RequestBody { @@ -33,6 +55,16 @@ func (requestBody *RequestBody) WithContent(content Content) *RequestBody { return requestBody } +func (requestBody *RequestBody) WithSchemaRef(value *SchemaRef, consumes []string) *RequestBody { + requestBody.Content = NewContentWithSchemaRef(value, consumes) + return requestBody +} + +func (requestBody *RequestBody) WithSchema(value *Schema, consumes []string) *RequestBody { + requestBody.Content = NewContentWithSchema(value, consumes) + return requestBody +} + func (requestBody *RequestBody) WithJSONSchemaRef(value *SchemaRef) *RequestBody { requestBody.Content = NewContentWithJSONSchemaRef(value) return requestBody @@ -43,6 +75,16 @@ func (requestBody *RequestBody) WithJSONSchema(value *Schema) *RequestBody { return requestBody } +func (requestBody *RequestBody) WithFormDataSchemaRef(value *SchemaRef) *RequestBody { + requestBody.Content = NewContentWithFormDataSchemaRef(value) + return requestBody +} + +func (requestBody *RequestBody) WithFormDataSchema(value *Schema) *RequestBody { + requestBody.Content = NewContentWithFormDataSchema(value) + return requestBody +} + func (requestBody *RequestBody) GetMediaType(mediaType string) *MediaType { m := requestBody.Content if m == nil { @@ -51,19 +93,54 @@ func (requestBody *RequestBody) GetMediaType(mediaType string) *MediaType { return m[mediaType] } -func (requestBody *RequestBody) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(requestBody) +// MarshalJSON returns the JSON encoding of RequestBody. +func (requestBody RequestBody) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 3+len(requestBody.Extensions)) + for k, v := range requestBody.Extensions { + m[k] = v + } + if x := requestBody.Description; x != "" { + m["description"] = requestBody.Description + } + if x := requestBody.Required; x { + m["required"] = x + } + if x := requestBody.Content; true { + m["content"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets RequestBody to a copy of data. func (requestBody *RequestBody) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, requestBody) + type RequestBodyBis RequestBody + var x RequestBodyBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "description") + delete(x.Extensions, "required") + delete(x.Extensions, "content") + *requestBody = RequestBody(x) + return nil } -func (requestBody *RequestBody) Validate(c context.Context) error { - if v := requestBody.Content; v != nil { - if err := v.Validate(c); err != nil { - return err - } +// Validate returns an error if RequestBody does not comply with the OpenAPI spec. +func (requestBody *RequestBody) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if requestBody.Content == nil { + return errors.New("content of the request body is required") } - return nil + + if vo := getValidationOptions(ctx); !vo.examplesValidationDisabled { + vo.examplesValidationAsReq, vo.examplesValidationAsRes = true, false + } + + if err := requestBody.Content.Validate(ctx); err != nil { + return err + } + + return validateExtensions(ctx, requestBody.Extensions) } diff --git a/openapi3/response.go b/openapi3/response.go index db83db71f..b85c9145c 100644 --- a/openapi3/response.go +++ b/openapi3/response.go @@ -2,17 +2,25 @@ package openapi3 import ( "context" + "encoding/json" "errors" + "fmt" + "sort" "strconv" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" ) // Responses is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#responses-object type Responses map[string]*ResponseRef +var _ jsonpointer.JSONPointable = (*Responses)(nil) + func NewResponses() Responses { - return make(Responses, 8) + r := make(Responses) + r["default"] = &ResponseRef{Value: NewResponse().WithDescription("")} + return r } func (responses Responses) Default() *ResponseRef { @@ -23,22 +31,50 @@ func (responses Responses) Get(status int) *ResponseRef { return responses[strconv.FormatInt(int64(status), 10)] } -func (responses Responses) Validate(c context.Context) error { - for _, v := range responses { - if err := v.Validate(c); err != nil { +// Validate returns an error if Responses does not comply with the OpenAPI spec. +func (responses Responses) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if len(responses) == 0 { + return errors.New("the responses object MUST contain at least one response code") + } + + keys := make([]string, 0, len(responses)) + for key := range responses { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + v := responses[key] + if err := v.Validate(ctx); err != nil { return err } } return nil } +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (responses Responses) JSONLookup(token string) (interface{}, error) { + ref, ok := responses[token] + if ok == false { + return nil, fmt.Errorf("invalid token reference: %q", token) + } + + if ref != nil && ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + // Response is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object type Response struct { - ExtensionProps - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Headers map[string]*HeaderRef `json:"headers,omitempty" yaml:"headers,omitempty"` - Content Content `json:"content,omitempty" yaml:"content,omitempty"` - Links map[string]*LinkRef `json:"links,omitempty" yaml:"links,omitempty"` + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Headers Headers `json:"headers,omitempty" yaml:"headers,omitempty"` + Content Content `json:"content,omitempty" yaml:"content,omitempty"` + Links Links `json:"links,omitempty" yaml:"links,omitempty"` } func NewResponse() *Response { @@ -65,23 +101,83 @@ func (response *Response) WithJSONSchemaRef(schema *SchemaRef) *Response { return response } -func (response *Response) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(response) +// MarshalJSON returns the JSON encoding of Response. +func (response Response) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 4+len(response.Extensions)) + for k, v := range response.Extensions { + m[k] = v + } + if x := response.Description; x != nil { + m["description"] = x + } + if x := response.Headers; len(x) != 0 { + m["headers"] = x + } + if x := response.Content; len(x) != 0 { + m["content"] = x + } + if x := response.Links; len(x) != 0 { + m["links"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets Response to a copy of data. func (response *Response) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, response) + type ResponseBis Response + var x ResponseBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "description") + delete(x.Extensions, "headers") + delete(x.Extensions, "content") + delete(x.Extensions, "links") + *response = Response(x) + return nil } -func (response *Response) Validate(c context.Context) error { +// Validate returns an error if Response does not comply with the OpenAPI spec. +func (response *Response) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if response.Description == nil { - return errors.New("A short description of the response is required") + return errors.New("a short description of the response is required") + } + if vo := getValidationOptions(ctx); !vo.examplesValidationDisabled { + vo.examplesValidationAsReq, vo.examplesValidationAsRes = false, true } if content := response.Content; content != nil { - if err := content.Validate(c); err != nil { + if err := content.Validate(ctx); err != nil { return err } } - return nil + + headers := make([]string, 0, len(response.Headers)) + for name := range response.Headers { + headers = append(headers, name) + } + sort.Strings(headers) + for _, name := range headers { + header := response.Headers[name] + if err := header.Validate(ctx); err != nil { + return err + } + } + + links := make([]string, 0, len(response.Links)) + for name := range response.Links { + links = append(links, name) + } + sort.Strings(links) + for _, name := range links { + link := response.Links[name] + if err := link.Validate(ctx); err != nil { + return err + } + } + + return validateExtensions(ctx, response.Extensions) } diff --git a/openapi3/response_issue224_test.go b/openapi3/response_issue224_test.go new file mode 100644 index 000000000..5de0525e4 --- /dev/null +++ b/openapi3/response_issue224_test.go @@ -0,0 +1,461 @@ +package openapi3 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEmptyResponsesAreInvalid(t *testing.T) { + spec := `{ + "openapi": "3.0.0", + "servers": [ + { + "url": "http://petstore.swagger.io/v2" + } + ], + "info": { + "description": ":dog: :cat: :rabbit: This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key to test the authorization filters.", + "version": "1.0.0", + "title": "Swagger Petstore", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "http://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders" + }, + { + "name": "user", + "description": "Operations about user", + "externalDocs": { + "description": "Find out more about our store", + "url": "http://swagger.io" + } + } + ], + "paths": { + "/pet": { + "post": { + "tags": [ + "pet" + ], + "summary": "Add a new pet to the store", + "description": "", + "operationId": "addPet", + "responses": { + + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/Pet" + }, + "parameters": [] + }, + "put": { + "tags": [ + "pet" + ], + "summary": "Update an existing pet", + "description": "", + "operationId": "updatePet", + "responses": { + + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/Pet" + }, + "parameters": [] + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pet by ID", + "description": "Returns a single pet", + "operationId": "getPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + + }, + "security": [ + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Updates a pet in the store with form data", + "description": "", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "name": { + "description": "Updated name of the pet", + "type": "string" + }, + "status": { + "description": "Updated status of the pet", + "type": "string" + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "pet" + ], + "summary": "Deletes a pet", + "description": "", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + } + }, + "externalDocs": { + "description": "See AsyncAPI example", + "url": "https://mermade.github.io/shins/asyncapi.html" + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "petId": { + "type": "integer", + "format": "int64" + }, + "quantity": { + "type": "integer", + "format": "int32" + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "enum": [ + "placed", + "approved", + "delivered" + ] + }, + "complete": { + "type": "boolean", + "default": false + } + }, + "xml": { + "name": "Order" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "Category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "username": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "userStatus": { + "type": "integer", + "format": "int32", + "description": "User Status" + } + }, + "xml": { + "name": "User" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "Tag" + } + }, + "Pet": { + "type": "object", + "required": [ + "name", + "photoUrls" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "name": { + "type": "string", + "example": "doggie" + }, + "photoUrls": { + "type": "array", + "xml": { + "name": "photoUrl", + "wrapped": true + }, + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "xml": { + "name": "tag", + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ] + } + }, + "xml": { + "name": "Pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "requestBodies": { + "Pet": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "description": "Pet object that needs to be added to the store", + "required": true + }, + "UserArray": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "description": "List of user object", + "required": true + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "http://petstore.swagger.io/oauth/dialog", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + }, + "links": {}, + "callbacks": {} + }, + "security": [] +} +` + + doc, err := NewLoader().LoadFromData([]byte(spec)) + require.NoError(t, err) + require.Equal(t, doc.ExternalDocs.Description, "See AsyncAPI example") + err = doc.Validate(context.Background()) + require.EqualError(t, err, `invalid paths: invalid path /pet: invalid operation POST: the responses object MUST contain at least one response code`) +} diff --git a/openapi3/schema.go b/openapi3/schema.go index 427c376a4..4bfbca0bd 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -8,24 +8,45 @@ import ( "fmt" "math" "math/big" + "reflect" "regexp" + "sort" "strconv" + "strings" "unicode/utf16" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" + "github.com/mohae/deepcopy" +) + +const ( + TypeArray = "array" + TypeBoolean = "boolean" + TypeInteger = "integer" + TypeNumber = "number" + TypeObject = "object" + TypeString = "string" + + // constants for integer formats + formatMinInt32 = float64(math.MinInt32) + formatMaxInt32 = float64(math.MaxInt32) + formatMinInt64 = float64(math.MinInt64) + formatMaxInt64 = float64(math.MaxInt64) ) var ( // SchemaErrorDetailsDisabled disables printing of details about schema errors. SchemaErrorDetailsDisabled = false - //SchemaFormatValidationDisabled disables validation of schema type formats. - SchemaFormatValidationDisabled = false + errSchema = errors.New("input does not match the schema") - errSchema = errors.New("Input does not match the schema") + // ErrOneOfConflict is the SchemaError Origin when data matches more than one oneOf schema + ErrOneOfConflict = errors.New("input matches more than one oneOf schemas") - ErrSchemaInputNaN = errors.New("NaN is not allowed") - ErrSchemaInputInf = errors.New("Inf is not allowed") + // ErrSchemaInputNaN may be returned when validating a number + ErrSchemaInputNaN = errors.New("floating point NaN is not allowed") + // ErrSchemaInputInf may be returned when validating a number + ErrSchemaInputInf = errors.New("floating point Inf is not allowed") ) // Float64Ptr is a helper for defining OpenAPI schemas. @@ -48,13 +69,62 @@ func Uint64Ptr(value uint64) *uint64 { return &value } +// NewSchemaRef simply builds a SchemaRef +func NewSchemaRef(ref string, value *Schema) *SchemaRef { + return &SchemaRef{ + Ref: ref, + Value: value, + } +} + +type Schemas map[string]*SchemaRef + +var _ jsonpointer.JSONPointable = (*Schemas)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (s Schemas) JSONLookup(token string) (interface{}, error) { + ref, ok := s[token] + if ref == nil || ok == false { + return nil, fmt.Errorf("object has no field %q", token) + } + + if ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + +type SchemaRefs []*SchemaRef + +var _ jsonpointer.JSONPointable = (*SchemaRefs)(nil) + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (s SchemaRefs) JSONLookup(token string) (interface{}, error) { + i, err := strconv.ParseUint(token, 10, 64) + if err != nil { + return nil, err + } + + if i >= uint64(len(s)) { + return nil, fmt.Errorf("index out of range: %d", i) + } + + ref := s[i] + + if ref == nil || ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + // Schema is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object type Schema struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` - OneOf []*SchemaRef `json:"oneOf,omitempty" yaml:"oneOf,omitempty"` - AnyOf []*SchemaRef `json:"anyOf,omitempty" yaml:"anyOf,omitempty"` - AllOf []*SchemaRef `json:"allOf,omitempty" yaml:"allOf,omitempty"` + OneOf SchemaRefs `json:"oneOf,omitempty" yaml:"oneOf,omitempty"` + AnyOf SchemaRefs `json:"anyOf,omitempty" yaml:"anyOf,omitempty"` + AllOf SchemaRefs `json:"allOf,omitempty" yaml:"allOf,omitempty"` Not *SchemaRef `json:"not,omitempty" yaml:"not,omitempty"` Type string `json:"type,omitempty" yaml:"type,omitempty"` Title string `json:"title,omitempty" yaml:"title,omitempty"` @@ -65,18 +135,18 @@ type Schema struct { Example interface{} `json:"example,omitempty" yaml:"example,omitempty"` ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` - // Object-related, here for struct compactness - AdditionalPropertiesAllowed *bool `json:"-" multijson:"additionalProperties,omitempty" yaml:"-"` // Array-related, here for struct compactness UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` // Number-related, here for struct compactness ExclusiveMin bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` ExclusiveMax bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` // Properties - Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` - ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` - WriteOnly bool `json:"writeOnly,omitempty" yaml:"writeOnly,omitempty"` - XML interface{} `json:"xml,omitempty" yaml:"xml,omitempty"` + Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` + ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + WriteOnly bool `json:"writeOnly,omitempty" yaml:"writeOnly,omitempty"` + AllowEmptyValue bool `json:"allowEmptyValue,omitempty" yaml:"allowEmptyValue,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + XML *XML `json:"xml,omitempty" yaml:"xml,omitempty"` // Number Min *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` @@ -87,7 +157,7 @@ type Schema struct { MinLength uint64 `json:"minLength,omitempty" yaml:"minLength,omitempty"` MaxLength *uint64 `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"` - compiledPattern *compiledPattern + compiledPattern *regexp.Regexp // Array MinItems uint64 `json:"minItems,omitempty" yaml:"minItems,omitempty"` @@ -95,24 +165,362 @@ type Schema struct { Items *SchemaRef `json:"items,omitempty" yaml:"items,omitempty"` // Object - Required []string `json:"required,omitempty" yaml:"required,omitempty"` - Properties map[string]*SchemaRef `json:"properties,omitempty" yaml:"properties,omitempty"` - MinProps uint64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` - MaxProps *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` - AdditionalProperties *SchemaRef `json:"-" multijson:"additionalProperties,omitempty" yaml:"-"` - Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"` + Required []string `json:"required,omitempty" yaml:"required,omitempty"` + Properties Schemas `json:"properties,omitempty" yaml:"properties,omitempty"` + MinProps uint64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` + MaxProps *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` + AdditionalProperties AdditionalProperties `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` + Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"` +} + +type AdditionalProperties struct { + Has *bool + Schema *SchemaRef +} + +// MarshalJSON returns the JSON encoding of AdditionalProperties. +func (addProps AdditionalProperties) MarshalJSON() ([]byte, error) { + if x := addProps.Has; x != nil { + if *x { + return []byte("true"), nil + } + return []byte("false"), nil + } + if x := addProps.Schema; x != nil { + return json.Marshal(x) + } + return nil, nil +} + +// UnmarshalJSON sets AdditionalProperties to a copy of data. +func (addProps *AdditionalProperties) UnmarshalJSON(data []byte) error { + var x interface{} + if err := json.Unmarshal(data, &x); err != nil { + return err + } + switch y := x.(type) { + case nil: + case bool: + addProps.Has = &y + case map[string]interface{}: + if len(y) == 0 { + addProps.Schema = &SchemaRef{Value: &Schema{}} + } else { + buf := new(bytes.Buffer) + json.NewEncoder(buf).Encode(y) + if err := json.NewDecoder(buf).Decode(&addProps.Schema); err != nil { + return err + } + } + default: + return errors.New("cannot unmarshal additionalProperties: value must be either a schema object or a boolean") + } + return nil } +var _ jsonpointer.JSONPointable = (*Schema)(nil) + func NewSchema() *Schema { return &Schema{} } -func (schema *Schema) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(schema) +// MarshalJSON returns the JSON encoding of Schema. +func (schema Schema) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 36+len(schema.Extensions)) + for k, v := range schema.Extensions { + m[k] = v + } + + if x := schema.OneOf; len(x) != 0 { + m["oneOf"] = x + } + if x := schema.AnyOf; len(x) != 0 { + m["anyOf"] = x + } + if x := schema.AllOf; len(x) != 0 { + m["allOf"] = x + } + if x := schema.Not; x != nil { + m["not"] = x + } + if x := schema.Type; len(x) != 0 { + m["type"] = x + } + if x := schema.Title; len(x) != 0 { + m["title"] = x + } + if x := schema.Format; len(x) != 0 { + m["format"] = x + } + if x := schema.Description; len(x) != 0 { + m["description"] = x + } + if x := schema.Enum; len(x) != 0 { + m["enum"] = x + } + if x := schema.Default; x != nil { + m["default"] = x + } + if x := schema.Example; x != nil { + m["example"] = x + } + if x := schema.ExternalDocs; x != nil { + m["externalDocs"] = x + } + + // Array-related + if x := schema.UniqueItems; x { + m["uniqueItems"] = x + } + // Number-related + if x := schema.ExclusiveMin; x { + m["exclusiveMinimum"] = x + } + if x := schema.ExclusiveMax; x { + m["exclusiveMaximum"] = x + } + // Properties + if x := schema.Nullable; x { + m["nullable"] = x + } + if x := schema.ReadOnly; x { + m["readOnly"] = x + } + if x := schema.WriteOnly; x { + m["writeOnly"] = x + } + if x := schema.AllowEmptyValue; x { + m["allowEmptyValue"] = x + } + if x := schema.Deprecated; x { + m["deprecated"] = x + } + if x := schema.XML; x != nil { + m["xml"] = x + } + + // Number + if x := schema.Min; x != nil { + m["minimum"] = x + } + if x := schema.Max; x != nil { + m["maximum"] = x + } + if x := schema.MultipleOf; x != nil { + m["multipleOf"] = x + } + + // String + if x := schema.MinLength; x != 0 { + m["minLength"] = x + } + if x := schema.MaxLength; x != nil { + m["maxLength"] = x + } + if x := schema.Pattern; x != "" { + m["pattern"] = x + } + + // Array + if x := schema.MinItems; x != 0 { + m["minItems"] = x + } + if x := schema.MaxItems; x != nil { + m["maxItems"] = x + } + if x := schema.Items; x != nil { + m["items"] = x + } + + // Object + if x := schema.Required; len(x) != 0 { + m["required"] = x + } + if x := schema.Properties; len(x) != 0 { + m["properties"] = x + } + if x := schema.MinProps; x != 0 { + m["minProperties"] = x + } + if x := schema.MaxProps; x != nil { + m["maxProperties"] = x + } + if x := schema.AdditionalProperties; x.Has != nil || x.Schema != nil { + m["additionalProperties"] = &x + } + if x := schema.Discriminator; x != nil { + m["discriminator"] = x + } + + return json.Marshal(m) } +// UnmarshalJSON sets Schema to a copy of data. func (schema *Schema) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, schema) + type SchemaBis Schema + var x SchemaBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + + delete(x.Extensions, "oneOf") + delete(x.Extensions, "anyOf") + delete(x.Extensions, "allOf") + delete(x.Extensions, "not") + delete(x.Extensions, "type") + delete(x.Extensions, "title") + delete(x.Extensions, "format") + delete(x.Extensions, "description") + delete(x.Extensions, "enum") + delete(x.Extensions, "default") + delete(x.Extensions, "example") + delete(x.Extensions, "externalDocs") + + // Array-related + delete(x.Extensions, "uniqueItems") + // Number-related + delete(x.Extensions, "exclusiveMinimum") + delete(x.Extensions, "exclusiveMaximum") + // Properties + delete(x.Extensions, "nullable") + delete(x.Extensions, "readOnly") + delete(x.Extensions, "writeOnly") + delete(x.Extensions, "allowEmptyValue") + delete(x.Extensions, "deprecated") + delete(x.Extensions, "xml") + + // Number + delete(x.Extensions, "minimum") + delete(x.Extensions, "maximum") + delete(x.Extensions, "multipleOf") + + // String + delete(x.Extensions, "minLength") + delete(x.Extensions, "maxLength") + delete(x.Extensions, "pattern") + + // Array + delete(x.Extensions, "minItems") + delete(x.Extensions, "maxItems") + delete(x.Extensions, "items") + + // Object + delete(x.Extensions, "required") + delete(x.Extensions, "properties") + delete(x.Extensions, "minProperties") + delete(x.Extensions, "maxProperties") + delete(x.Extensions, "additionalProperties") + delete(x.Extensions, "discriminator") + + *schema = Schema(x) + + if schema.Format == "date" { + // This is a fix for: https://github.com/getkin/kin-openapi/issues/697 + if eg, ok := schema.Example.(string); ok { + schema.Example = strings.TrimSuffix(eg, "T00:00:00Z") + } + } + return nil +} + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (schema Schema) JSONLookup(token string) (interface{}, error) { + switch token { + case "additionalProperties": + if addProps := schema.AdditionalProperties.Has; addProps != nil { + return *addProps, nil + } + if addProps := schema.AdditionalProperties.Schema; addProps != nil { + if addProps.Ref != "" { + return &Ref{Ref: addProps.Ref}, nil + } + return addProps.Value, nil + } + case "not": + if schema.Not != nil { + if schema.Not.Ref != "" { + return &Ref{Ref: schema.Not.Ref}, nil + } + return schema.Not.Value, nil + } + case "items": + if schema.Items != nil { + if schema.Items.Ref != "" { + return &Ref{Ref: schema.Items.Ref}, nil + } + return schema.Items.Value, nil + } + case "oneOf": + return schema.OneOf, nil + case "anyOf": + return schema.AnyOf, nil + case "allOf": + return schema.AllOf, nil + case "type": + return schema.Type, nil + case "title": + return schema.Title, nil + case "format": + return schema.Format, nil + case "description": + return schema.Description, nil + case "enum": + return schema.Enum, nil + case "default": + return schema.Default, nil + case "example": + return schema.Example, nil + case "externalDocs": + return schema.ExternalDocs, nil + case "uniqueItems": + return schema.UniqueItems, nil + case "exclusiveMin": + return schema.ExclusiveMin, nil + case "exclusiveMax": + return schema.ExclusiveMax, nil + case "nullable": + return schema.Nullable, nil + case "readOnly": + return schema.ReadOnly, nil + case "writeOnly": + return schema.WriteOnly, nil + case "allowEmptyValue": + return schema.AllowEmptyValue, nil + case "xml": + return schema.XML, nil + case "deprecated": + return schema.Deprecated, nil + case "min": + return schema.Min, nil + case "max": + return schema.Max, nil + case "multipleOf": + return schema.MultipleOf, nil + case "minLength": + return schema.MinLength, nil + case "maxLength": + return schema.MaxLength, nil + case "pattern": + return schema.Pattern, nil + case "minItems": + return schema.MinItems, nil + case "maxItems": + return schema.MaxItems, nil + case "required": + return schema.Required, nil + case "properties": + return schema.Properties, nil + case "minProps": + return schema.MinProps, nil + case "maxProps": + return schema.MaxProps, nil + case "discriminator": + return schema.Discriminator, nil + } + + v, _, err := jsonpointer.GetForToken(schema.Extensions, token) + return v, err } func (schema *Schema) NewRef() *SchemaRef { @@ -153,81 +561,76 @@ func NewAllOfSchema(schemas ...*Schema) *Schema { func NewBoolSchema() *Schema { return &Schema{ - Type: "boolean", + Type: TypeBoolean, } } func NewFloat64Schema() *Schema { return &Schema{ - Type: "number", + Type: TypeNumber, } } func NewIntegerSchema() *Schema { return &Schema{ - Type: "integer", + Type: TypeInteger, } } func NewInt32Schema() *Schema { return &Schema{ - Type: "integer", + Type: TypeInteger, Format: "int32", } } func NewInt64Schema() *Schema { return &Schema{ - Type: "integer", + Type: TypeInteger, Format: "int64", } } func NewStringSchema() *Schema { return &Schema{ - Type: "string", + Type: TypeString, } } func NewDateTimeSchema() *Schema { return &Schema{ - Type: "string", + Type: TypeString, Format: "date-time", } } func NewUUIDSchema() *Schema { return &Schema{ - Type: "string", + Type: TypeString, Format: "uuid", } } func NewBytesSchema() *Schema { return &Schema{ - Type: "string", + Type: TypeString, Format: "byte", } } func NewArraySchema() *Schema { return &Schema{ - Type: "array", + Type: TypeArray, } } func NewObjectSchema() *Schema { return &Schema{ - Type: "object", - Properties: make(map[string]*SchemaRef), + Type: TypeObject, + Properties: make(Schemas), } } -type compiledPattern struct { - Regexp *regexp.Regexp - ErrReason string -} - func (schema *Schema) WithNullable() *Schema { schema.Nullable = true return schema @@ -242,6 +645,7 @@ func (schema *Schema) WithMax(value float64) *Schema { schema.Max = &value return schema } + func (schema *Schema) WithExclusiveMin(value bool) *Schema { schema.ExclusiveMin = value return schema @@ -308,6 +712,7 @@ func (schema *Schema) WithMaxLengthDecodedBase64(i int64) *Schema { func (schema *Schema) WithPattern(pattern string) *Schema { schema.Pattern = pattern + schema.compiledPattern = nil return schema } @@ -344,7 +749,7 @@ func (schema *Schema) WithProperty(name string, propertySchema *Schema) *Schema func (schema *Schema) WithPropertyRef(name string, ref *SchemaRef) *Schema { properties := schema.Properties if properties == nil { - properties = make(map[string]*SchemaRef) + properties = make(Schemas) schema.Properties = properties } properties[name] = ref @@ -352,7 +757,7 @@ func (schema *Schema) WithPropertyRef(name string, ref *SchemaRef) *Schema { } func (schema *Schema) WithProperties(properties map[string]*Schema) *Schema { - result := make(map[string]*SchemaRef, len(properties)) + result := make(Schemas, len(properties)) for k, v := range properties { result[k] = &SchemaRef{ Value: v, @@ -375,27 +780,28 @@ func (schema *Schema) WithMaxProperties(i int64) *Schema { } func (schema *Schema) WithAnyAdditionalProperties() *Schema { - schema.AdditionalProperties = nil - t := true - schema.AdditionalPropertiesAllowed = &t + schema.AdditionalProperties = AdditionalProperties{Has: BoolPtr(true)} + return schema +} + +func (schema *Schema) WithoutAdditionalProperties() *Schema { + schema.AdditionalProperties = AdditionalProperties{Has: BoolPtr(false)} return schema } func (schema *Schema) WithAdditionalProperties(v *Schema) *Schema { - if v == nil { - schema.AdditionalProperties = nil - } else { - schema.AdditionalProperties = &SchemaRef{ - Value: v, - } + schema.AdditionalProperties = AdditionalProperties{} + if v != nil { + schema.AdditionalProperties.Schema = &SchemaRef{Value: v} } return schema } +// IsEmpty tells whether schema is equivalent to the empty schema `{}`. func (schema *Schema) IsEmpty() bool { if schema.Type != "" || schema.Format != "" || len(schema.Enum) != 0 || schema.UniqueItems || schema.ExclusiveMin || schema.ExclusiveMax || - !schema.Nullable || + schema.Nullable || schema.ReadOnly || schema.WriteOnly || schema.AllowEmptyValue || schema.Min != nil || schema.Max != nil || schema.MultipleOf != nil || schema.MinLength != 0 || schema.MaxLength != nil || schema.Pattern != "" || schema.MinItems != 0 || schema.MaxItems != nil || @@ -406,10 +812,10 @@ func (schema *Schema) IsEmpty() bool { if n := schema.Not; n != nil && !n.Value.IsEmpty() { return false } - if ap := schema.AdditionalProperties; ap != nil && !ap.Value.IsEmpty() { + if ap := schema.AdditionalProperties.Schema; ap != nil && !ap.Value.IsEmpty() { return false } - if apa := schema.AdditionalPropertiesAllowed; apa != nil && !*apa { + if apa := schema.AdditionalProperties.Has; apa != nil && !*apa { return false } if items := schema.Items; items != nil && !items.Value.IsEmpty() { @@ -438,25 +844,33 @@ func (schema *Schema) IsEmpty() bool { return true } -func (schema *Schema) Validate(c context.Context) error { - return schema.validate(c, []*Schema{}) +// Validate returns an error if Schema does not comply with the OpenAPI spec. +func (schema *Schema) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + return schema.validate(ctx, []*Schema{}) } -func (schema *Schema) validate(c context.Context, stack []*Schema) (err error) { +func (schema *Schema) validate(ctx context.Context, stack []*Schema) error { + validationOpts := getValidationOptions(ctx) + for _, existing := range stack { if existing == schema { - return + return nil } } stack = append(stack, schema) + if schema.ReadOnly && schema.WriteOnly { + return errors.New("a property MUST NOT be marked as both readOnly and writeOnly being true") + } + for _, item := range schema.OneOf { v := item.Value if v == nil { return foundUnresolvedRef(item.Ref) } - if err = v.validate(c, stack); err == nil { - return + if err := v.validate(ctx, stack); err != nil { + return err } } @@ -465,8 +879,8 @@ func (schema *Schema) validate(c context.Context, stack []*Schema) (err error) { if v == nil { return foundUnresolvedRef(item.Ref) } - if err = v.validate(c, stack); err != nil { - return + if err := v.validate(ctx, stack); err != nil { + return err } } @@ -475,8 +889,8 @@ func (schema *Schema) validate(c context.Context, stack []*Schema) (err error) { if v == nil { return foundUnresolvedRef(item.Ref) } - if err = v.validate(c, stack); err != nil { - return + if err := v.validate(ctx, stack); err != nil { + return err } } @@ -485,60 +899,69 @@ func (schema *Schema) validate(c context.Context, stack []*Schema) (err error) { if v == nil { return foundUnresolvedRef(ref.Ref) } - if err = v.validate(c, stack); err != nil { - return + if err := v.validate(ctx, stack); err != nil { + return err } } schemaType := schema.Type switch schemaType { case "": - case "boolean": - case "number": + case TypeBoolean: + case TypeNumber: if format := schema.Format; len(format) > 0 { switch format { case "float", "double": default: - if !SchemaFormatValidationDisabled { + if validationOpts.schemaFormatValidationEnabled { return unsupportedFormat(format) } } } - case "integer": + case TypeInteger: if format := schema.Format; len(format) > 0 { switch format { case "int32", "int64": default: - if !SchemaFormatValidationDisabled { + if validationOpts.schemaFormatValidationEnabled { return unsupportedFormat(format) } } } - case "string": + case TypeString: if format := schema.Format; len(format) > 0 { switch format { - // Supported by OpenAPIv3.0.1: + // Supported by OpenAPIv3.0.3: + // https://spec.openapis.org/oas/v3.0.3 case "byte", "binary", "date", "date-time", "password": - // In JSON Draft-07 (not validated yet though): - case "regex": - case "time", "email", "idn-email": - case "hostname", "idn-hostname", "ipv4", "ipv6": - case "uri", "uri-reference", "iri", "iri-reference", "uri-template": - case "json-pointer", "relative-json-pointer": + // In JSON Draft-07 (not validated yet though): + // https://json-schema.org/draft-07/json-schema-release-notes.html#formats + case "iri", "iri-reference", "uri-template", "idn-email", "idn-hostname": + case "json-pointer", "relative-json-pointer", "regex", "time": + // In JSON Draft 2019-09 (not validated yet though): + // https://json-schema.org/draft/2019-09/release-notes.html#format-vocabulary + case "duration", "uuid": + // Defined in some other specification + case "email", "hostname", "ipv4", "ipv6", "uri", "uri-reference": default: // Try to check for custom defined formats - if _, ok := SchemaStringFormats[format]; !ok && !SchemaFormatValidationDisabled { + if _, ok := SchemaStringFormats[format]; !ok && validationOpts.schemaFormatValidationEnabled { return unsupportedFormat(format) } } } - case "array": + if schema.Pattern != "" && !validationOpts.schemaPatternValidationDisabled { + if err := schema.compilePattern(); err != nil { + return err + } + } + case TypeArray: if schema.Items == nil { - return errors.New("When schema type is 'array', schema 'items' must be non-null") + return errors.New("when schema type is 'array', schema 'items' must be non-null") } - case "object": + case TypeObject: default: - return fmt.Errorf("Unsupported 'type' value '%s'", schemaType) + return fmt.Errorf("unsupported 'type' value %q", schemaType) } if ref := schema.Items; ref != nil { @@ -546,66 +969,100 @@ func (schema *Schema) validate(c context.Context, stack []*Schema) (err error) { if v == nil { return foundUnresolvedRef(ref.Ref) } - if err = v.validate(c, stack); err != nil { - return + if err := v.validate(ctx, stack); err != nil { + return err } } - for _, ref := range schema.Properties { + properties := make([]string, 0, len(schema.Properties)) + for name := range schema.Properties { + properties = append(properties, name) + } + sort.Strings(properties) + for _, name := range properties { + ref := schema.Properties[name] v := ref.Value if v == nil { return foundUnresolvedRef(ref.Ref) } - if err = v.validate(c, stack); err != nil { - return + if err := v.validate(ctx, stack); err != nil { + return err } } - if ref := schema.AdditionalProperties; ref != nil { + if schema.AdditionalProperties.Has != nil && schema.AdditionalProperties.Schema != nil { + return errors.New("additionalProperties are set to both boolean and schema") + } + if ref := schema.AdditionalProperties.Schema; ref != nil { v := ref.Value if v == nil { return foundUnresolvedRef(ref.Ref) } - if err = v.validate(c, stack); err != nil { - return + if err := v.validate(ctx, stack); err != nil { + return err } } - return + if v := schema.ExternalDocs; v != nil { + if err := v.Validate(ctx); err != nil { + return fmt.Errorf("invalid external docs: %w", err) + } + } + + if v := schema.Default; v != nil && !validationOpts.schemaDefaultsValidationDisabled { + if err := schema.VisitJSON(v); err != nil { + return fmt.Errorf("invalid default: %w", err) + } + } + + if x := schema.Example; x != nil && !validationOpts.examplesValidationDisabled { + if err := validateExampleValue(ctx, x, schema); err != nil { + return fmt.Errorf("invalid example: %w", err) + } + } + + return validateExtensions(ctx, schema.Extensions) } func (schema *Schema) IsMatching(value interface{}) bool { - return schema.visitJSON(value, true) == nil + settings := newSchemaValidationSettings(FailFast()) + return schema.visitJSON(settings, value) == nil } func (schema *Schema) IsMatchingJSONBoolean(value bool) bool { - return schema.visitJSON(value, true) == nil + settings := newSchemaValidationSettings(FailFast()) + return schema.visitJSON(settings, value) == nil } func (schema *Schema) IsMatchingJSONNumber(value float64) bool { - return schema.visitJSON(value, true) == nil + settings := newSchemaValidationSettings(FailFast()) + return schema.visitJSON(settings, value) == nil } func (schema *Schema) IsMatchingJSONString(value string) bool { - return schema.visitJSON(value, true) == nil + settings := newSchemaValidationSettings(FailFast()) + return schema.visitJSON(settings, value) == nil } func (schema *Schema) IsMatchingJSONArray(value []interface{}) bool { - return schema.visitJSON(value, true) == nil + settings := newSchemaValidationSettings(FailFast()) + return schema.visitJSON(settings, value) == nil } func (schema *Schema) IsMatchingJSONObject(value map[string]interface{}) bool { - return schema.visitJSON(value, true) == nil + settings := newSchemaValidationSettings(FailFast()) + return schema.visitJSON(settings, value) == nil } -func (schema *Schema) VisitJSON(value interface{}) error { - return schema.visitJSON(value, false) +func (schema *Schema) VisitJSON(value interface{}, opts ...SchemaValidationOption) error { + settings := newSchemaValidationSettings(opts...) + return schema.visitJSON(settings, value) } -func (schema *Schema) visitJSON(value interface{}, fast bool) (err error) { +func (schema *Schema) visitJSON(settings *schemaValidationSettings, value interface{}) (err error) { switch value := value.(type) { case nil: - return schema.visitJSONNull(fast) + return schema.visitJSONNull(settings) case float64: if math.IsNaN(value) { return ErrSchemaInputNaN @@ -618,48 +1075,99 @@ func (schema *Schema) visitJSON(value interface{}, fast bool) (err error) { if schema.IsEmpty() { return } - if err = schema.visitSetOperations(value, fast); err != nil { + if err = schema.visitSetOperations(settings, value); err != nil { return } switch value := value.(type) { - case nil: - return schema.visitJSONNull(fast) case bool: - return schema.visitJSONBoolean(value, fast) + return schema.visitJSONBoolean(settings, value) + case json.Number: + valueFloat64, err := value.Float64() + if err != nil { + return &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "type", + Reason: "cannot convert json.Number to float64", + customizeMessageError: settings.customizeMessageError, + Origin: err, + } + } + return schema.visitJSONNumber(settings, valueFloat64) + case int: + return schema.visitJSONNumber(settings, float64(value)) + case int32: + return schema.visitJSONNumber(settings, float64(value)) + case int64: + return schema.visitJSONNumber(settings, float64(value)) case float64: - return schema.visitJSONNumber(value, fast) + return schema.visitJSONNumber(settings, value) case string: - return schema.visitJSONString(value, fast) + return schema.visitJSONString(settings, value) case []interface{}: - return schema.visitJSONArray(value, fast) + return schema.visitJSONArray(settings, value) case map[string]interface{}: - return schema.visitJSONObject(value, fast) - default: - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "type", - Reason: fmt.Sprintf("Not a JSON value: %T", value), + return schema.visitJSONObject(settings, value) + case map[interface{}]interface{}: // for YAML cf. issue #444 + values := make(map[string]interface{}, len(value)) + for key, v := range value { + if k, ok := key.(string); ok { + values[k] = v + } + } + if len(value) == len(values) { + return schema.visitJSONObject(settings, values) + } + } + + // Catch slice of non-empty interface type + if reflect.TypeOf(value).Kind() == reflect.Slice { + valueR := reflect.ValueOf(value) + newValue := make([]interface{}, 0, valueR.Len()) + for i := 0; i < valueR.Len(); i++ { + newValue = append(newValue, valueR.Index(i).Interface()) } + return schema.visitJSONArray(settings, newValue) + } + + return &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "type", + Reason: fmt.Sprintf("unhandled value of type %T", value), + customizeMessageError: settings.customizeMessageError, } } -func (schema *Schema) visitSetOperations(value interface{}, fast bool) (err error) { +func (schema *Schema) visitSetOperations(settings *schemaValidationSettings, value interface{}) (err error) { if enum := schema.Enum; len(enum) != 0 { for _, v := range enum { - if value == v { - return + switch c := value.(type) { + case json.Number: + var f float64 + if f, err = strconv.ParseFloat(c.String(), 64); err != nil { + return err + } + if v == f { + return + } + default: + if reflect.DeepEqual(v, value) { + return + } } } - if fast { + if settings.failfast { return errSchema } + allowedValues, _ := json.Marshal(enum) return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "enum", - Reason: "JSON value is not one of the allowed values", + Value: value, + Schema: schema, + SchemaField: "enum", + Reason: fmt.Sprintf("value is not one of the allowed values %s", string(allowedValues)), + customizeMessageError: settings.customizeMessageError, } } @@ -668,63 +1176,146 @@ func (schema *Schema) visitSetOperations(value interface{}, fast bool) (err erro if v == nil { return foundUnresolvedRef(ref.Ref) } - if err := v.visitJSON(value, true); err == nil { - if fast { + if err := v.visitJSON(settings, value); err == nil { + if settings.failfast { return errSchema } return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "not", + Value: value, + Schema: schema, + SchemaField: "not", + customizeMessageError: settings.customizeMessageError, } } } if v := schema.OneOf; len(v) > 0 { - ok := 0 - for _, item := range v { + var discriminatorRef string + if schema.Discriminator != nil { + pn := schema.Discriminator.PropertyName + if valuemap, okcheck := value.(map[string]interface{}); okcheck { + discriminatorVal, okcheck := valuemap[pn] + if !okcheck { + return &SchemaError{ + Schema: schema, + SchemaField: "discriminator", + Reason: fmt.Sprintf("input does not contain the discriminator property %q", pn), + } + } + + discriminatorValString, okcheck := discriminatorVal.(string) + if !okcheck { + return &SchemaError{ + Value: discriminatorVal, + Schema: schema, + SchemaField: "discriminator", + Reason: fmt.Sprintf("value of discriminator property %q is not a string", pn), + } + } + + if discriminatorRef, okcheck = schema.Discriminator.Mapping[discriminatorValString]; len(schema.Discriminator.Mapping) > 0 && !okcheck { + return &SchemaError{ + Value: discriminatorVal, + Schema: schema, + SchemaField: "discriminator", + Reason: fmt.Sprintf("discriminator property %q has invalid value", pn), + } + } + } + } + + var ( + ok = 0 + validationErrors = multiErrorForOneOf{} + matchedOneOfIndices = make([]int, 0) + tempValue = value + ) + for idx, item := range v { v := item.Value if v == nil { return foundUnresolvedRef(item.Ref) } - if err := v.visitJSON(value, true); err == nil { - ok++ + + if discriminatorRef != "" && discriminatorRef != item.Ref { + continue + } + + // make a deep copy to protect origin value from being injected default value that defined in mismatched oneOf schema + if settings.asreq || settings.asrep { + tempValue = deepcopy.Copy(value) + } + + if err := v.visitJSON(settings, tempValue); err != nil { + validationErrors = append(validationErrors, err) + continue } + + matchedOneOfIndices = append(matchedOneOfIndices, idx) + ok++ } + if ok != 1 { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "oneOf", + e := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "oneOf", + customizeMessageError: settings.customizeMessageError, + } + if ok > 1 { + e.Origin = ErrOneOfConflict + e.Reason = fmt.Sprintf(`value matches more than one schema from "oneOf" (matches schemas at indices %v)`, matchedOneOfIndices) + } else { + e.Origin = fmt.Errorf("doesn't match schema due to: %w", validationErrors) + e.Reason = `value doesn't match any schema from "oneOf"` } + + return e + } + + // run again to inject default value that defined in matched oneOf schema + if settings.asreq || settings.asrep { + _ = v[matchedOneOfIndices[0]].Value.visitJSON(settings, value) } } if v := schema.AnyOf; len(v) > 0 { - ok := false - for _, item := range v { + var ( + ok = false + matchedAnyOfIdx = 0 + tempValue = value + ) + for idx, item := range v { v := item.Value if v == nil { return foundUnresolvedRef(item.Ref) } - if err := v.visitJSON(value, true); err == nil { + // make a deep copy to protect origin value from being injected default value that defined in mismatched anyOf schema + if settings.asreq || settings.asrep { + tempValue = deepcopy.Copy(value) + } + if err := v.visitJSON(settings, tempValue); err == nil { ok = true + matchedAnyOfIdx = idx break } } if !ok { - if fast { + if settings.failfast { return errSchema } return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "anyOf", + Value: value, + Schema: schema, + SchemaField: "anyOf", + Reason: `doesn't match any schema from "anyOf"`, + customizeMessageError: settings.customizeMessageError, } } + + _ = v[matchedAnyOfIdx].Value.visitJSON(settings, value) } for _, item := range schema.AllOf { @@ -732,119 +1323,188 @@ func (schema *Schema) visitSetOperations(value interface{}, fast bool) (err erro if v == nil { return foundUnresolvedRef(item.Ref) } - if err := v.visitJSON(value, false); err != nil { - if fast { + if err := v.visitJSON(settings, value); err != nil { + if settings.failfast { return errSchema } return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "allOf", - Origin: err, + Value: value, + Schema: schema, + SchemaField: "allOf", + Reason: `doesn't match all schemas from "allOf"`, + Origin: err, + customizeMessageError: settings.customizeMessageError, } } } return } -func (schema *Schema) visitJSONNull(fast bool) (err error) { +// The value is not considered in visitJSONNull because according to the spec +// "null is not supported as a type" unless `nullable` is also set to true +// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#data-types +// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object +func (schema *Schema) visitJSONNull(settings *schemaValidationSettings) (err error) { if schema.Nullable { return } - if fast { + if settings.failfast { return errSchema } return &SchemaError{ - Value: nil, - Schema: schema, - SchemaField: "nullable", - Reason: "Value is not nullable", + Value: nil, + Schema: schema, + SchemaField: "nullable", + Reason: "Value is not nullable", + customizeMessageError: settings.customizeMessageError, } } func (schema *Schema) VisitJSONBoolean(value bool) error { - return schema.visitJSONBoolean(value, false) + settings := newSchemaValidationSettings() + return schema.visitJSONBoolean(settings, value) } -func (schema *Schema) visitJSONBoolean(value bool, fast bool) (err error) { - if schemaType := schema.Type; schemaType != "" && schemaType != "boolean" { - return schema.expectedType("boolean", fast) +func (schema *Schema) visitJSONBoolean(settings *schemaValidationSettings, value bool) (err error) { + if schemaType := schema.Type; schemaType != "" && schemaType != TypeBoolean { + return schema.expectedType(settings, value) } return } func (schema *Schema) VisitJSONNumber(value float64) error { - return schema.visitJSONNumber(value, false) + settings := newSchemaValidationSettings() + return schema.visitJSONNumber(settings, value) } -func (schema *Schema) visitJSONNumber(value float64, fast bool) (err error) { +func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value float64) error { + var me MultiError schemaType := schema.Type - if schemaType == "integer" { + if schemaType == TypeInteger { if bigFloat := big.NewFloat(value); !bigFloat.IsInt() { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "type", - Reason: "Value must be an integer", + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "type", + Reason: fmt.Sprintf("value must be an integer"), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err + } + me = append(me, err) + } + } else if schemaType != "" && schemaType != TypeNumber { + return schema.expectedType(settings, value) + } + + // formats + if schemaType == TypeInteger && schema.Format != "" { + formatMin := float64(0) + formatMax := float64(0) + switch schema.Format { + case "int32": + formatMin = formatMinInt32 + formatMax = formatMaxInt32 + case "int64": + formatMin = formatMinInt64 + formatMax = formatMaxInt64 + default: + if settings.formatValidationEnabled { + return unsupportedFormat(schema.Format) + } + } + if formatMin != 0 && formatMax != 0 && !(formatMin <= value && value <= formatMax) { + if settings.failfast { + return errSchema + } + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "format", + Reason: fmt.Sprintf("number must be an %s", schema.Format), + customizeMessageError: settings.customizeMessageError, } + if !settings.multiError { + return err + } + me = append(me, err) } - } else if schemaType != "" && schemaType != "number" { - return schema.expectedType("number, integer", fast) } // "exclusiveMinimum" if v := schema.ExclusiveMin; v && !(*schema.Min < value) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "exclusiveMinimum", - Reason: fmt.Sprintf("Number must be more than %g", *schema.Min), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "exclusiveMinimum", + Reason: fmt.Sprintf("number must be more than %g", *schema.Min), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } // "exclusiveMaximum" if v := schema.ExclusiveMax; v && !(*schema.Max > value) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "exclusiveMaximum", - Reason: fmt.Sprintf("Number must be less than %g", *schema.Max), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "exclusiveMaximum", + Reason: fmt.Sprintf("number must be less than %g", *schema.Max), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } // "minimum" if v := schema.Min; v != nil && !(*v <= value) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "minimum", - Reason: fmt.Sprintf("Number must be at least %g", *v), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "minimum", + Reason: fmt.Sprintf("number must be at least %g", *v), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } // "maximum" if v := schema.Max; v != nil && !(*v >= value) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "maximum", - Reason: fmt.Sprintf("Number must be most %g", *v), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "maximum", + Reason: fmt.Sprintf("number must be at most %g", *v), + customizeMessageError: settings.customizeMessageError, } + if !settings.multiError { + return err + } + me = append(me, err) } // "multipleOf" @@ -852,28 +1512,42 @@ func (schema *Schema) visitJSONNumber(value float64, fast bool) (err error) { // "A numeric instance is valid only if division by this keyword's // value results in an integer." if bigFloat := big.NewFloat(value / *v); !bigFloat.IsInt() { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "multipleOf", + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "multipleOf", + Reason: fmt.Sprintf("number must be a multiple of %g", *v), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } } - return + + if len(me) > 0 { + return me + } + + return nil } func (schema *Schema) VisitJSONString(value string) error { - return schema.visitJSONString(value, false) + settings := newSchemaValidationSettings() + return schema.visitJSONString(settings, value) } -func (schema *Schema) visitJSONString(value string, fast bool) (err error) { - if schemaType := schema.Type; schemaType != "" && schemaType != "string" { - return schema.expectedType("string", fast) +func (schema *Schema) visitJSONString(settings *schemaValidationSettings, value string) error { + if schemaType := schema.Type; schemaType != "" && schemaType != TypeString { + return schema.expectedType(settings, value) } + var me MultiError + // "minLength" and "maxLength" minLength := schema.MinLength maxLength := schema.MaxLength @@ -888,121 +1562,180 @@ func (schema *Schema) visitJSONString(value string, fast bool) (err error) { } } if minLength != 0 && length < int64(minLength) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "minLength", - Reason: fmt.Sprintf("Minimum string length is %d", minLength), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "minLength", + Reason: fmt.Sprintf("minimum string length is %d", minLength), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } if maxLength != nil && length > int64(*maxLength) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "maxLength", - Reason: fmt.Sprintf("Maximum string length is %d", *maxLength), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "maxLength", + Reason: fmt.Sprintf("maximum string length is %d", *maxLength), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } } - // "format" and "pattern" - cp := schema.compiledPattern - if cp == nil { - pattern := schema.Pattern - if v := schema.Pattern; len(v) > 0 { - // Pattern - re, err := regexp.Compile(v) - if err != nil { - return fmt.Errorf("Error while compiling regular expression '%s': %v", pattern, err) - } - cp = &compiledPattern{ - Regexp: re, - ErrReason: "JSON string doesn't match the regular expression '" + v + "'", + // "pattern" + if schema.Pattern != "" && schema.compiledPattern == nil && !settings.patternValidationDisabled { + var err error + if err = schema.compilePattern(); err != nil { + if !settings.multiError { + return err } - schema.compiledPattern = cp - } else if v := schema.Format; len(v) > 0 { - // No pattern, but does have a format - re := SchemaStringFormats[v] - if re != nil { - cp = &compiledPattern{ - Regexp: re, - ErrReason: "JSON string doesn't match the format '" + v + " (regular expression `" + re.String() + "`)'", + me = append(me, err) + } + } + if cp := schema.compiledPattern; cp != nil && !cp.MatchString(value) { + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "pattern", + Reason: fmt.Sprintf(`string doesn't match the regular expression "%s"`, schema.Pattern), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err + } + me = append(me, err) + } + + // "format" + var formatStrErr string + var formatErr error + if format := schema.Format; format != "" { + if f, ok := SchemaStringFormats[format]; ok { + switch { + case f.regexp != nil && f.callback == nil: + if cp := f.regexp; !cp.MatchString(value) { + formatStrErr = fmt.Sprintf(`string doesn't match the format %q (regular expression "%s")`, format, cp.String()) + } + case f.regexp == nil && f.callback != nil: + if err := f.callback(value); err != nil { + var schemaErr = &SchemaError{} + if errors.As(err, &schemaErr) { + formatStrErr = fmt.Sprintf(`string doesn't match the format %q (%s)`, format, schemaErr.Reason) + } else { + formatStrErr = fmt.Sprintf(`string doesn't match the format %q (%v)`, format, err) + } + formatErr = err } - schema.compiledPattern = cp + default: + formatStrErr = fmt.Sprintf("corrupted entry %q in SchemaStringFormats", format) } } } - if cp != nil { - if !cp.Regexp.MatchString(value) { - field := "format" - if schema.Pattern != "" { - field = "pattern" - } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: field, - Reason: cp.ErrReason, - } + if formatStrErr != "" || formatErr != nil { + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "format", + Reason: formatStrErr, + Origin: formatErr, + customizeMessageError: settings.customizeMessageError, } + if !settings.multiError { + return err + } + me = append(me, err) + } - return + + if len(me) > 0 { + return me + } + + return nil } func (schema *Schema) VisitJSONArray(value []interface{}) error { - return schema.visitJSONArray(value, false) + settings := newSchemaValidationSettings() + return schema.visitJSONArray(settings, value) } -func (schema *Schema) visitJSONArray(value []interface{}, fast bool) (err error) { - if schemaType := schema.Type; schemaType != "" && schemaType != "array" { - return schema.expectedType("array", fast) +func (schema *Schema) visitJSONArray(settings *schemaValidationSettings, value []interface{}) error { + if schemaType := schema.Type; schemaType != "" && schemaType != TypeArray { + return schema.expectedType(settings, value) } + var me MultiError + lenValue := int64(len(value)) // "minItems" if v := schema.MinItems; v != 0 && lenValue < int64(v) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "minItems", - Reason: fmt.Sprintf("Minimum number of items is %d", v), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "minItems", + Reason: fmt.Sprintf("minimum number of items is %d", v), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } // "maxItems" if v := schema.MaxItems; v != nil && lenValue > int64(*v) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "maxItems", - Reason: fmt.Sprintf("Maximum number of items is %d", *v), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "maxItems", + Reason: fmt.Sprintf("maximum number of items is %d", *v), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } // "uniqueItems" + if sliceUniqueItemsChecker == nil { + sliceUniqueItemsChecker = isSliceOfUniqueItems + } if v := schema.UniqueItems; v && !sliceUniqueItemsChecker(value) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "uniqueItems", - Reason: fmt.Sprintf("Duplicate items found"), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "uniqueItems", + Reason: "duplicate items found", + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } // "items" @@ -1012,21 +1745,65 @@ func (schema *Schema) visitJSONArray(value []interface{}, fast bool) (err error) return foundUnresolvedRef(itemSchemaRef.Ref) } for i, item := range value { - if err := itemSchema.VisitJSON(item); err != nil { - return markSchemaErrorIndex(err, i) + if err := itemSchema.visitJSON(settings, item); err != nil { + err = markSchemaErrorIndex(err, i) + if !settings.multiError { + return err + } + if itemMe, ok := err.(MultiError); ok { + me = append(me, itemMe...) + } else { + me = append(me, err) + } } } } - return + + if len(me) > 0 { + return me + } + + return nil } func (schema *Schema) VisitJSONObject(value map[string]interface{}) error { - return schema.visitJSONObject(value, false) + settings := newSchemaValidationSettings() + return schema.visitJSONObject(settings, value) } -func (schema *Schema) visitJSONObject(value map[string]interface{}, fast bool) (err error) { - if schemaType := schema.Type; schemaType != "" && schemaType != "object" { - return schema.expectedType("object", fast) +func (schema *Schema) visitJSONObject(settings *schemaValidationSettings, value map[string]interface{}) error { + if schemaType := schema.Type; schemaType != "" && schemaType != TypeObject { + return schema.expectedType(settings, value) + } + + var me MultiError + + if settings.asreq || settings.asrep { + properties := make([]string, 0, len(schema.Properties)) + for propName := range schema.Properties { + properties = append(properties, propName) + } + sort.Strings(properties) + for _, propName := range properties { + propSchema := schema.Properties[propName] + reqRO := settings.asreq && propSchema.Value.ReadOnly && !settings.readOnlyValidationDisabled + repWO := settings.asrep && propSchema.Value.WriteOnly && !settings.writeOnlyValidationDisabled + + if f := settings.defaultsSet; f != nil && value[propName] == nil { + if dflt := propSchema.Value.Default; dflt != nil && !reqRO && !repWO { + value[propName] = dflt + settings.onceSettingDefaults.Do(f) + } + } + + if value[propName] != nil { + if reqRO { + me = append(me, fmt.Errorf("readOnly property %q in request", propName)) + } else if repWO { + me = append(me, fmt.Errorf("writeOnly property %q in response", propName)) + } + } + } } // "properties" @@ -1035,36 +1812,52 @@ func (schema *Schema) visitJSONObject(value map[string]interface{}, fast bool) ( // "minProperties" if v := schema.MinProps; v != 0 && lenValue < int64(v) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "minProperties", - Reason: fmt.Sprintf("There must be at least %d properties", v), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "minProperties", + Reason: fmt.Sprintf("there must be at least %d properties", v), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } // "maxProperties" if v := schema.MaxProps; v != nil && lenValue > int64(*v) { - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "maxProperties", - Reason: fmt.Sprintf("There must be at most %d properties", *v), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "maxProperties", + Reason: fmt.Sprintf("there must be at most %d properties", *v), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } // "additionalProperties" var additionalProperties *Schema - if ref := schema.AdditionalProperties; ref != nil { + if ref := schema.AdditionalProperties.Schema; ref != nil { additionalProperties = ref.Value } - for k, v := range value { + keys := make([]string, 0, len(value)) + for k := range value { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := value[k] if properties != nil { propertyRef := properties[k] if propertyRef != nil { @@ -1072,88 +1865,165 @@ func (schema *Schema) visitJSONObject(value map[string]interface{}, fast bool) ( if p == nil { return foundUnresolvedRef(propertyRef.Ref) } - if err := p.VisitJSON(v); err != nil { - if fast { + if err := p.visitJSON(settings, v); err != nil { + if settings.failfast { return errSchema } - return markSchemaErrorKey(err, k) + err = markSchemaErrorKey(err, k) + if !settings.multiError { + return err + } + if v, ok := err.(MultiError); ok { + me = append(me, v...) + continue + } + me = append(me, err) } continue } } - allowed := schema.AdditionalPropertiesAllowed - if additionalProperties != nil || allowed == nil || (allowed != nil && *allowed) { + if allowed := schema.AdditionalProperties.Has; allowed == nil || *allowed { if additionalProperties != nil { - if err := additionalProperties.VisitJSON(v); err != nil { - if fast { + if err := additionalProperties.visitJSON(settings, v); err != nil { + if settings.failfast { return errSchema } - return markSchemaErrorKey(err, k) + err = markSchemaErrorKey(err, k) + if !settings.multiError { + return err + } + if v, ok := err.(MultiError); ok { + me = append(me, v...) + continue + } + me = append(me, err) } } continue } - if fast { + if settings.failfast { return errSchema } - return &SchemaError{ - Value: value, - Schema: schema, - SchemaField: "properties", - Reason: fmt.Sprintf("Property '%s' is unsupported", k), + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "properties", + Reason: fmt.Sprintf("property %q is unsupported", k), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err } + me = append(me, err) } + + // "required" for _, k := range schema.Required { if _, ok := value[k]; !ok { - if fast { + if s := schema.Properties[k]; s != nil && s.Value.ReadOnly && settings.asreq { + continue + } + if s := schema.Properties[k]; s != nil && s.Value.WriteOnly && settings.asrep { + continue + } + if settings.failfast { return errSchema } - return markSchemaErrorKey(&SchemaError{ - Value: value, - Schema: schema, - SchemaField: "required", - Reason: fmt.Sprintf("Property '%s' is missing", k), + err := markSchemaErrorKey(&SchemaError{ + Value: value, + Schema: schema, + SchemaField: "required", + Reason: fmt.Sprintf("property %q is missing", k), + customizeMessageError: settings.customizeMessageError, }, k) + if !settings.multiError { + return err + } + me = append(me, err) } } - return + + if len(me) > 0 { + return me + } + + return nil } -func (schema *Schema) expectedType(typ string, fast bool) error { - if fast { +func (schema *Schema) expectedType(settings *schemaValidationSettings, value interface{}) error { + if settings.failfast { return errSchema } + + a := "a" + switch schema.Type { + case TypeArray, TypeObject, TypeInteger: + a = "an" + } return &SchemaError{ - Value: typ, - Schema: schema, - SchemaField: "type", - Reason: "Field must be set to " + schema.Type + " or not be present", + Value: value, + Schema: schema, + SchemaField: "type", + Reason: fmt.Sprintf("value must be %s %s", a, schema.Type), + customizeMessageError: settings.customizeMessageError, } } +func (schema *Schema) compilePattern() (err error) { + if schema.compiledPattern, err = regexp.Compile(schema.Pattern); err != nil { + return &SchemaError{ + Schema: schema, + SchemaField: "pattern", + Origin: err, + Reason: fmt.Sprintf("cannot compile pattern %q: %v", schema.Pattern, err), + } + } + return nil +} + +// SchemaError is an error that occurs during schema validation. type SchemaError struct { - Value interface{} + // Value is the value that failed validation. + Value interface{} + // reversePath is the path to the value that failed validation. reversePath []string - Schema *Schema + // Schema is the schema that failed validation. + Schema *Schema + // SchemaField is the field of the schema that failed validation. SchemaField string - Reason string - Origin error + // Reason is a human-readable message describing the error. + // The message should never include the original value to prevent leakage of potentially sensitive inputs in error messages. + Reason string + // Origin is the original error that caused this error. + Origin error + // customizeMessageError is a function that can be used to customize the error message. + customizeMessageError func(err *SchemaError) string } +var _ interface{ Unwrap() error } = SchemaError{} + func markSchemaErrorKey(err error, key string) error { + var me multiErrorForOneOf + + if errors.As(err, &me) { + err = me.Unwrap() + } + if v, ok := err.(*SchemaError); ok { v.reversePath = append(v.reversePath, key) return v } + if v, ok := err.(MultiError); ok { + for _, e := range v { + _ = markSchemaErrorKey(e, key) + } + return v + } return err } func markSchemaErrorIndex(err error, index int) error { - if v, ok := err.(*SchemaError); ok { - v.reversePath = append(v.reversePath, strconv.FormatInt(int64(index), 10)) - return v - } - return err + return markSchemaErrorKey(err, strconv.FormatInt(int64(index), 10)) } func (err *SchemaError) JSONPointer() []string { @@ -1166,11 +2036,14 @@ func (err *SchemaError) JSONPointer() []string { } func (err *SchemaError) Error() string { - if err.Origin != nil { - return err.Origin.Error() + if err.customizeMessageError != nil { + if msg := err.customizeMessageError(err); msg != "" { + return msg + } } buf := bytes.NewBuffer(make([]byte, 0, 256)) + if len(err.reversePath) > 0 { buf.WriteString(`Error at "`) reversePath := err.reversePath @@ -1178,8 +2051,15 @@ func (err *SchemaError) Error() string { buf.WriteByte('/') buf.WriteString(reversePath[i]) } - buf.WriteString(`":`) + buf.WriteString(`": `) } + + if err.Origin != nil { + buf.WriteString(err.Origin.Error()) + + return buf.String() + } + reason := err.Reason if reason == "" { buf.WriteString(`Doesn't match schema "`) @@ -1188,6 +2068,7 @@ func (err *SchemaError) Error() string { } else { buf.WriteString(reason) } + if !SchemaErrorDetailsDisabled { buf.WriteString("\nSchema:\n ") encoder := json.NewEncoder(buf) @@ -1200,9 +2081,14 @@ func (err *SchemaError) Error() string { panic(err) } } + return buf.String() } +func (err SchemaError) Unwrap() error { + return err.Origin +} + func isSliceOfUniqueItems(xs []interface{}) bool { s := len(xs) m := make(map[string]struct{}, s) @@ -1231,5 +2117,5 @@ func RegisterArrayUniqueItemsChecker(fn SliceUniqueItemsChecker) { } func unsupportedFormat(format string) error { - return fmt.Errorf("Unsupported 'format' value '%s'", format) + return fmt.Errorf("unsupported 'format' value %q", format) } diff --git a/openapi3/schema_formats.go b/openapi3/schema_formats.go index 746e40882..ea38400c2 100644 --- a/openapi3/schema_formats.go +++ b/openapi3/schema_formats.go @@ -2,30 +2,87 @@ package openapi3 import ( "fmt" + "net" "regexp" + "strings" ) const ( // FormatOfStringForUUIDOfRFC4122 is an optional predefined format for UUID v1-v5 as specified by RFC4122 - FormatOfStringForUUIDOfRFC4122 = `^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$` + FormatOfStringForUUIDOfRFC4122 = `^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$` + + // FormatOfStringForEmail pattern catches only some suspiciously wrong-looking email addresses. + // Use DefineStringFormat(...) if you need something stricter. + FormatOfStringForEmail = `^[^@]+@[^@<>",\s]+$` ) -var SchemaStringFormats = make(map[string]*regexp.Regexp, 8) +// FormatCallback performs custom checks on exotic formats +type FormatCallback func(value string) error + +// Format represents a format validator registered by either DefineStringFormat or DefineStringFormatCallback +type Format struct { + regexp *regexp.Regexp + callback FormatCallback +} +// SchemaStringFormats allows for validating string formats +var SchemaStringFormats = make(map[string]Format, 4) + +// DefineStringFormat defines a new regexp pattern for a given format func DefineStringFormat(name string, pattern string) { re, err := regexp.Compile(pattern) if err != nil { - err := fmt.Errorf("Format '%v' has invalid pattern '%v': %v", name, pattern, err) + err := fmt.Errorf("format %q has invalid pattern %q: %w", name, pattern, err) panic(err) } - SchemaStringFormats[name] = re + SchemaStringFormats[name] = Format{regexp: re} } -func init() { - // This pattern catches only some suspiciously wrong-looking email addresses. - // Use DefineStringFormat(...) if you need something stricter. - DefineStringFormat("email", `^[^@]+@[^@<>",\s]+$`) +// DefineStringFormatCallback adds a validation function for a specific schema format entry +func DefineStringFormatCallback(name string, callback FormatCallback) { + SchemaStringFormats[name] = Format{callback: callback} +} + +func validateIP(ip string) error { + parsed := net.ParseIP(ip) + if parsed == nil { + return &SchemaError{ + Value: ip, + Reason: "Not an IP address", + } + } + return nil +} +func validateIPv4(ip string) error { + if err := validateIP(ip); err != nil { + return err + } + + if !(strings.Count(ip, ":") < 2) { + return &SchemaError{ + Value: ip, + Reason: "Not an IPv4 address (it's IPv6)", + } + } + return nil +} + +func validateIPv6(ip string) error { + if err := validateIP(ip); err != nil { + return err + } + + if !(strings.Count(ip, ":") >= 2) { + return &SchemaError{ + Value: ip, + Reason: "Not an IPv6 address (it's IPv4)", + } + } + return nil +} + +func init() { // Base64 // The pattern supports base64 and b./ase64url. Padding ('=') is supported. DefineStringFormat("byte", `(^$|^[a-zA-Z0-9+/\-_]*=*$)`) @@ -34,5 +91,16 @@ func init() { DefineStringFormat("date", `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)$`) // date-time - DefineStringFormat("date-time", `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$`) + DefineStringFormat("date-time", `^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+|-)[0-9]{2}:[0-9]{2})?$`) + +} + +// DefineIPv4Format opts in ipv4 format validation on top of OAS 3 spec +func DefineIPv4Format() { + DefineStringFormatCallback("ipv4", validateIPv4) +} + +// DefineIPv6Format opts in ipv6 format validation on top of OAS 3 spec +func DefineIPv6Format() { + DefineStringFormatCallback("ipv6", validateIPv6) } diff --git a/openapi3/schema_formats_test.go b/openapi3/schema_formats_test.go new file mode 100644 index 000000000..70092d6de --- /dev/null +++ b/openapi3/schema_formats_test.go @@ -0,0 +1,156 @@ +package openapi3 + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssue430(t *testing.T) { + schema := NewOneOfSchema( + NewStringSchema().WithFormat("ipv4"), + NewStringSchema().WithFormat("ipv6"), + ) + + delete(SchemaStringFormats, "ipv4") + delete(SchemaStringFormats, "ipv6") + + err := schema.Validate(context.Background()) + require.NoError(t, err) + + data := map[string]bool{ + "127.0.1.1": true, + + // https://stackoverflow.com/a/48519490/1418165 + + // v4 + "192.168.0.1": true, + // "192.168.0.1:80" doesn't parse per net.ParseIP() + + // v6 + "::FFFF:C0A8:1": false, + "::FFFF:C0A8:0001": false, + "0000:0000:0000:0000:0000:FFFF:C0A8:1": false, + // "::FFFF:C0A8:1%1" doesn't parse per net.ParseIP() + "::FFFF:192.168.0.1": false, + // "[::FFFF:C0A8:1]:80" doesn't parse per net.ParseIP() + // "[::FFFF:C0A8:1%1]:80" doesn't parse per net.ParseIP() + } + + for datum := range data { + err = schema.VisitJSON(datum) + require.Error(t, err, ErrOneOfConflict.Error()) + } + + DefineIPv4Format() + DefineIPv6Format() + + for datum, isV4 := range data { + err = schema.VisitJSON(datum) + require.NoError(t, err) + if isV4 { + require.Nil(t, validateIPv4(datum), "%q should be IPv4", datum) + require.NotNil(t, validateIPv6(datum), "%q should not be IPv6", datum) + } else { + require.NotNil(t, validateIPv4(datum), "%q should not be IPv4", datum) + require.Nil(t, validateIPv6(datum), "%q should be IPv6", datum) + } + } +} + +func TestFormatCallback_WrapError(t *testing.T) { + var errSomething = errors.New("something error") + + DefineStringFormatCallback("foobar", func(value string) error { + return errSomething + }) + + s := &Schema{Format: "foobar"} + err := s.VisitJSONString("blablabla") + + assert.ErrorIs(t, err, errSomething) + + delete(SchemaStringFormats, "foobar") +} + +func TestReversePathInMessageSchemaError(t *testing.T) { + DefineIPv4Format() + + SchemaErrorDetailsDisabled = true + + const spc = ` +components: + schemas: + Something: + type: object + properties: + ip: + type: string + format: ipv4 +` + l := NewLoader() + + doc, err := l.LoadFromData([]byte(spc)) + require.NoError(t, err) + + err = doc.Components.Schemas["Something"].Value.VisitJSON(map[string]interface{}{ + `ip`: `123.0.0.11111`, + }) + + require.EqualError(t, err, `Error at "/ip": Not an IP address`) + + delete(SchemaStringFormats, "ipv4") + SchemaErrorDetailsDisabled = false +} + +func TestUuidFormat(t *testing.T) { + + type testCase struct { + name string + value string + wantErr bool + } + + DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122) + testCases := []testCase{ + { + name: "invalid", + value: "foo", + wantErr: true, + }, + { + name: "uuid v1", + value: "77e66540-ca29-11ed-afa1-0242ac120002", + wantErr: false, + }, + { + name: "uuid v4", + value: "00f4d301-b9f4-4366-8907-2b5a03430aa1", + wantErr: false, + }, + { + name: "uuid nil", + value: "00000000-0000-0000-0000-000000000000", + wantErr: false, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := NewUUIDSchema().VisitJSON(tc.value) + var schemaError = &SchemaError{} + if tc.wantErr { + require.Error(t, err) + require.ErrorAs(t, err, &schemaError) + + require.NotZero(t, schemaError.Reason) + require.NotContains(t, schemaError.Reason, fmt.Sprint(tc.value)) + } else { + require.Nil(t, err) + } + }) + } +} diff --git a/openapi3/schema_issue289_test.go b/openapi3/schema_issue289_test.go new file mode 100644 index 000000000..56d1d4562 --- /dev/null +++ b/openapi3/schema_issue289_test.go @@ -0,0 +1,39 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue289(t *testing.T) { + spec := []byte(`components: + schemas: + Server: + properties: + address: + oneOf: + - $ref: "#/components/schemas/ip-address" + - $ref: "#/components/schemas/domain-name" + name: + type: string + type: object + domain-name: + maxLength: 10 + minLength: 5 + pattern: "((([a-zA-Z0-9_]([a-zA-Z0-9\\-_]){0,61})?[a-zA-Z0-9]\\.)*([a-zA-Z0-9_]([a-zA-Z0-9\\-_]){0,61})?[a-zA-Z0-9]\\.?)|\\." + type: string + ip-address: + pattern: "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$" + type: string +openapi: "3.0.1" +`) + + s, err := NewLoader().LoadFromData(spec) + require.NoError(t, err) + err = s.Components.Schemas["Server"].Value.VisitJSON(map[string]interface{}{ + "name": "kin-openapi", + "address": "127.0.0.1", + }) + require.ErrorIs(t, err, ErrOneOfConflict) +} diff --git a/openapi3/schema_issue492_test.go b/openapi3/schema_issue492_test.go new file mode 100644 index 000000000..535c82a66 --- /dev/null +++ b/openapi3/schema_issue492_test.go @@ -0,0 +1,41 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue492(t *testing.T) { + spec := []byte(`components: + schemas: + Server: + properties: + time: + $ref: "#/components/schemas/timestamp" + name: + type: string + type: object + timestamp: + type: string + format: date-time +openapi: "3.0.1" +`) + + s, err := NewLoader().LoadFromData(spec) + require.NoError(t, err) + + // verify that the expected format works + err = s.Components.Schemas["Server"].Value.VisitJSON(map[string]interface{}{ + "name": "kin-openapi", + "time": "2001-02-03T04:05:06.789Z", + }) + require.NoError(t, err) + + // verify that the issue is fixed + err = s.Components.Schemas["Server"].Value.VisitJSON(map[string]interface{}{ + "name": "kin-openapi", + "time": "2001-02-03T04:05:06:789Z", + }) + require.EqualError(t, err, "Error at \"/time\": string doesn't match the format \"date-time\" (regular expression \"^[0-9]{4}-(0[0-9]|10|11|12)-([0-2][0-9]|30|31)T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?(Z|(\\+|-)[0-9]{2}:[0-9]{2})?$\")\nSchema:\n {\n \"format\": \"date-time\",\n \"type\": \"string\"\n }\n\nValue:\n \"2001-02-03T04:05:06:789Z\"\n") +} diff --git a/openapi3/schema_oneOf_test.go b/openapi3/schema_oneOf_test.go new file mode 100644 index 000000000..8d5451950 --- /dev/null +++ b/openapi3/schema_oneOf_test.go @@ -0,0 +1,184 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var oneofSpec = []byte(`components: + schemas: + Cat: + type: object + properties: + name: + type: string + scratches: + type: boolean + $type: + type: string + enum: + - cat + required: + - name + - scratches + - $type + Dog: + type: object + properties: + name: + type: string + barks: + type: boolean + $type: + type: string + enum: + - dog + required: + - name + - barks + - $type + Animal: + type: object + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" + discriminator: + propertyName: $type + mapping: + cat: "#/components/schemas/Cat" + dog: "#/components/schemas/Dog" +`) + +var oneofNoDiscriminatorSpec = []byte(`components: + schemas: + Cat: + type: object + properties: + name: + type: string + scratches: + type: boolean + required: + - name + - scratches + Dog: + type: object + properties: + name: + type: string + barks: + type: boolean + required: + - name + - barks + Animal: + type: object + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" +`) + +func TestVisitJSON_OneOf_MissingDiscriptorProperty(t *testing.T) { + s, err := NewLoader().LoadFromData(oneofSpec) + require.NoError(t, err) + err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + "name": "snoopy", + }) + require.ErrorContains(t, err, "input does not contain the discriminator property \"$type\"\n") +} + +func TestVisitJSON_OneOf_MissingDiscriptorValue(t *testing.T) { + s, err := NewLoader().LoadFromData(oneofSpec) + require.NoError(t, err) + err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + "name": "snoopy", + "$type": "snake", + }) + require.ErrorContains(t, err, "discriminator property \"$type\" has invalid value") +} + +func TestVisitJSON_OneOf_MissingField(t *testing.T) { + s, err := NewLoader().LoadFromData(oneofSpec) + require.NoError(t, err) + err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + "name": "snoopy", + "$type": "dog", + }) + require.EqualError(t, err, "doesn't match schema due to: Error at \"/barks\": property \"barks\" is missing\nSchema:\n {\n \"properties\": {\n \"$type\": {\n \"enum\": [\n \"dog\"\n ],\n \"type\": \"string\"\n },\n \"barks\": {\n \"type\": \"boolean\"\n },\n \"name\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"barks\",\n \"$type\"\n ],\n \"type\": \"object\"\n }\n\nValue:\n {\n \"$type\": \"dog\",\n \"name\": \"snoopy\"\n }\n") +} + +func TestVisitJSON_OneOf_NoDiscriptor_MissingField(t *testing.T) { + s, err := NewLoader().LoadFromData(oneofNoDiscriminatorSpec) + require.NoError(t, err) + err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + "name": "snoopy", + }) + require.EqualError(t, err, "doesn't match schema due to: Error at \"/scratches\": property \"scratches\" is missing\nSchema:\n {\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"scratches\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"name\",\n \"scratches\"\n ],\n \"type\": \"object\"\n }\n\nValue:\n {\n \"name\": \"snoopy\"\n }\n Or Error at \"/barks\": property \"barks\" is missing\nSchema:\n {\n \"properties\": {\n \"barks\": {\n \"type\": \"boolean\"\n },\n \"name\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"barks\"\n ],\n \"type\": \"object\"\n }\n\nValue:\n {\n \"name\": \"snoopy\"\n }\n") +} + +func TestVisitJSON_OneOf_BadDescriminatorType(t *testing.T) { + s, err := NewLoader().LoadFromData(oneofSpec) + require.NoError(t, err) + err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + "name": "snoopy", + "scratches": true, + "$type": 1, + }) + require.ErrorContains(t, err, "value of discriminator property \"$type\" is not a string") + + err = s.Components.Schemas["Animal"].Value.VisitJSON(map[string]interface{}{ + "name": "snoopy", + "barks": true, + "$type": nil, + }) + require.ErrorContains(t, err, "value of discriminator property \"$type\" is not a string") +} + +func TestVisitJSON_OneOf_Path(t *testing.T) { + t.Parallel() + + loader := NewLoader() + spc := ` +components: + schemas: + Something: + type: object + properties: + first: + type: object + properties: + second: + type: object + properties: + third: + oneOf: + - title: First rule + type: string + minLength: 5 + maxLength: 5 + - title: Second rule + type: string + minLength: 10 + maxLength: 10 +`[1:] + + doc, err := loader.LoadFromData([]byte(spc)) + require.NoError(t, err) + + err = doc.Components.Schemas["Something"].Value.VisitJSON(map[string]interface{}{ + "first": map[string]interface{}{ + "second": map[string]interface{}{ + "third": "123456789", + }, + }, + }) + + assert.Contains(t, err.Error(), `Error at "/first/second/third"`) + + var sErr *SchemaError + + assert.ErrorAs(t, err, &sErr) + assert.Equal(t, []string{"first", "second", "third"}, sErr.JSONPointer()) +} diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go index e82aba26b..26669799a 100644 --- a/openapi3/schema_test.go +++ b/openapi3/schema_test.go @@ -1,27 +1,28 @@ -package openapi3_test +package openapi3 import ( "context" "encoding/base64" "encoding/json" "math" + "reflect" "strings" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) type schemaExample struct { Title string - Schema *openapi3.Schema + Schema *Schema Serialization interface{} AllValid []interface{} AllInvalid []interface{} } func TestSchemas(t *testing.T) { - openapi3.DefineStringFormat("uuid", openapi3.FormatOfStringForUUIDOfRFC4122) + DefineStringFormat("uuid", FormatOfStringForUUIDOfRFC4122) for _, example := range schemaExamples { t.Run(example.Title, testSchema(t, example)) } @@ -36,43 +37,61 @@ func testSchema(t *testing.T, example schemaExample) func(*testing.T) { jsonSchema, err := json.Marshal(schema) require.NoError(t, err) require.JSONEq(t, string(jsonSerialized), string(jsonSchema)) - var dataUnserialized openapi3.Schema + var dataUnserialized Schema err = json.Unmarshal(jsonSerialized, &dataUnserialized) require.NoError(t, err) - var dataSchema openapi3.Schema + var dataSchema Schema err = json.Unmarshal(jsonSchema, &dataSchema) require.NoError(t, err) require.Equal(t, dataUnserialized, dataSchema) } - for _, value := range example.AllValid { - err := validateSchema(t, schema, value) - require.NoError(t, err) - } - for _, value := range example.AllInvalid { - err := validateSchema(t, schema, value) - require.Error(t, err) + for validateFuncIndex, validateFunc := range validateSchemaFuncs { + for index, value := range example.AllValid { + err := validateFunc(t, schema, value) + require.NoErrorf(t, err, "ValidateFunc #%d, AllValid #%d: %#v", validateFuncIndex, index, value) + } + for index, value := range example.AllInvalid { + err := validateFunc(t, schema, value) + require.Errorf(t, err, "ValidateFunc #%d, AllInvalid #%d: %#v", validateFuncIndex, index, value) + } } // NaN and Inf aren't valid JSON but are handled - for _, value := range []interface{}{math.NaN(), math.Inf(-1), math.Inf(+1)} { + for index, value := range []interface{}{math.NaN(), math.Inf(-1), math.Inf(+1)} { err := schema.VisitJSON(value) - require.Error(t, err) + require.Errorf(t, err, "NaNAndInf #%d: %#v", index, value) } } } -func validateSchema(t *testing.T, schema *openapi3.Schema, value interface{}) error { +func validateSchemaJSON(t *testing.T, schema *Schema, value interface{}, opts ...SchemaValidationOption) error { data, err := json.Marshal(value) require.NoError(t, err) var val interface{} err = json.Unmarshal(data, &val) require.NoError(t, err) - return schema.VisitJSON(val) + return schema.VisitJSON(val, opts...) +} + +func validateSchemaYAML(t *testing.T, schema *Schema, value interface{}, opts ...SchemaValidationOption) error { + data, err := yaml.Marshal(value) + require.NoError(t, err) + var val interface{} + err = yaml.Unmarshal(data, &val) + require.NoError(t, err) + return schema.VisitJSON(val, opts...) +} + +type validateSchemaFunc func(t *testing.T, schema *Schema, value interface{}, opts ...SchemaValidationOption) error + +var validateSchemaFuncs = []validateSchemaFunc{ + validateSchemaJSON, + validateSchemaYAML, } var schemaExamples = []schemaExample{ { Title: "EMPTY SCHEMA", - Schema: &openapi3.Schema{}, + Schema: &Schema{}, Serialization: map[string]interface{}{ // This OA3 schema is exactly this draft-04 schema: // {"not": {"type": "null"}} @@ -92,7 +111,7 @@ var schemaExamples = []schemaExample{ { Title: "JUST NULLABLE", - Schema: openapi3.NewSchema().WithNullable(), + Schema: NewSchema().WithNullable(), Serialization: map[string]interface{}{ // This OA3 schema is exactly both this draft-04 schema: {} and: // {anyOf: [type:string, type:number, type:integer, type:boolean @@ -114,7 +133,7 @@ var schemaExamples = []schemaExample{ { Title: "NULLABLE BOOLEAN", - Schema: openapi3.NewBoolSchema().WithNullable(), + Schema: NewBoolSchema().WithNullable(), Serialization: map[string]interface{}{ "nullable": true, "type": "boolean", @@ -136,9 +155,9 @@ var schemaExamples = []schemaExample{ { Title: "NULLABLE ANYOF", - Schema: openapi3.NewAnyOfSchema( - openapi3.NewIntegerSchema(), - openapi3.NewFloat64Schema(), + Schema: NewAnyOfSchema( + NewIntegerSchema(), + NewFloat64Schema(), ).WithNullable(), Serialization: map[string]interface{}{ "nullable": true, @@ -162,7 +181,7 @@ var schemaExamples = []schemaExample{ { Title: "BOOLEAN", - Schema: openapi3.NewBoolSchema(), + Schema: NewBoolSchema(), Serialization: map[string]interface{}{ "type": "boolean", }, @@ -181,7 +200,7 @@ var schemaExamples = []schemaExample{ { Title: "NUMBER", - Schema: openapi3.NewFloat64Schema(). + Schema: NewFloat64Schema(). WithMin(2.5). WithMax(3.5), Serialization: map[string]interface{}{ @@ -208,7 +227,7 @@ var schemaExamples = []schemaExample{ { Title: "INTEGER", - Schema: openapi3.NewInt64Schema(). + Schema: NewInt64Schema(). WithMin(2). WithMax(5), Serialization: map[string]interface{}{ @@ -233,10 +252,59 @@ var schemaExamples = []schemaExample{ map[string]interface{}{}, }, }, - + { + Title: "INTEGER OPTIONAL INT64 FORMAT", + Schema: NewInt64Schema(), + Serialization: map[string]interface{}{ + "type": "integer", + "format": "int64", + }, + AllValid: []interface{}{ + 1, + 256, + 65536, + int64(math.MaxInt32) + 10, + int64(math.MinInt32) - 10, + }, + AllInvalid: []interface{}{ + nil, + false, + 3.5, + true, + "", + []interface{}{}, + map[string]interface{}{}, + }, + }, + { + Title: "INTEGER OPTIONAL INT32 FORMAT", + Schema: NewInt32Schema(), + Serialization: map[string]interface{}{ + "type": "integer", + "format": "int32", + }, + AllValid: []interface{}{ + 1, + 256, + 65536, + int64(math.MaxInt32), + int64(math.MaxInt32), + }, + AllInvalid: []interface{}{ + nil, + false, + 3.5, + int64(math.MaxInt32) + 10, + int64(math.MinInt32) - 10, + true, + "", + []interface{}{}, + map[string]interface{}{}, + }, + }, { Title: "STRING", - Schema: openapi3.NewStringSchema(). + Schema: NewStringSchema(). WithMinLength(2). WithMaxLength(3). WithPattern("^[abc]+$"), @@ -265,7 +333,7 @@ var schemaExamples = []schemaExample{ { Title: "STRING: optional format 'uuid'", - Schema: openapi3.NewUUIDSchema(), + Schema: NewUUIDSchema(), Serialization: map[string]interface{}{ "type": "string", "format": "uuid", @@ -274,6 +342,12 @@ var schemaExamples = []schemaExample{ "dd7d8481-81a3-407f-95f0-a2f1cb382a4b", "dcba3901-2fba-48c1-9db2-00422055804e", "ace8e3be-c254-4c10-8859-1401d9a9d52a", + "DD7D8481-81A3-407F-95F0-A2F1CB382A4B", + "DCBA3901-2FBA-48C1-9DB2-00422055804E", + "ACE8E3BE-C254-4C10-8859-1401D9A9D52A", + "dd7D8481-81A3-407f-95F0-A2F1CB382A4B", + "DCBA3901-2FBA-48C1-9db2-00422055804e", + "ACE8E3BE-c254-4C10-8859-1401D9A9D52A", }, AllInvalid: []interface{}{ nil, @@ -281,12 +355,19 @@ var schemaExamples = []schemaExample{ "4cf3i040-ea14-4daa-b0b5-ea9329473519", "aaf85740-7e27-4b4f-b4554-a03a43b1f5e3", "56f5bff4-z4b6-48e6-a10d-b6cf66a83b04", + "G39840B1-D0EF-446D-E555-48FCCA50A90A", + "4CF3I040-EA14-4DAA-B0B5-EA9329473519", + "AAF85740-7E27-4B4F-B4554-A03A43B1F5E3", + "56F5BFF4-Z4B6-48E6-A10D-B6CF66A83B04", + "4CF3I040-EA14-4Daa-B0B5-EA9329473519", + "AAf85740-7E27-4B4F-B4554-A03A43b1F5E3", + "56F5Bff4-Z4B6-48E6-a10D-B6CF66A83B04", }, }, { Title: "STRING: format 'date-time'", - Schema: openapi3.NewDateTimeSchema(), + Schema: NewDateTimeSchema(), Serialization: map[string]interface{}{ "type": "string", "format": "date-time", @@ -311,7 +392,7 @@ var schemaExamples = []schemaExample{ { Title: "STRING: format 'date-time'", - Schema: openapi3.NewBytesSchema(), + Schema: NewBytesSchema(), Serialization: map[string]interface{}{ "type": "string", "format": "byte", @@ -336,19 +417,19 @@ var schemaExamples = []schemaExample{ AllInvalid: []interface{}{ nil, " ", - "\n", + "\n\n", // a \n is ok for JSON but not for YAML decoder/encoder "%", }, }, { Title: "ARRAY", - Schema: &openapi3.Schema{ + Schema: &Schema{ Type: "array", MinItems: 2, - MaxItems: openapi3.Uint64Ptr(3), + MaxItems: Uint64Ptr(3), UniqueItems: true, - Items: openapi3.NewFloat64Schema().NewRef(), + Items: NewFloat64Schema().NewRef(), }, Serialization: map[string]interface{}{ "type": "array", @@ -383,13 +464,13 @@ var schemaExamples = []schemaExample{ }, { Title: "ARRAY : items format 'object'", - Schema: &openapi3.Schema{ + Schema: &Schema{ Type: "array", UniqueItems: true, - Items: (&openapi3.Schema{ + Items: (&Schema{ Type: "object", - Properties: map[string]*openapi3.SchemaRef{ - "key1": openapi3.NewFloat64Schema().NewRef(), + Properties: Schemas{ + "key1": NewFloat64Schema().NewRef(), }, }).NewRef(), }, @@ -440,16 +521,16 @@ var schemaExamples = []schemaExample{ { Title: "ARRAY : items format 'object' and object with a property of array type ", - Schema: &openapi3.Schema{ + Schema: &Schema{ Type: "array", UniqueItems: true, - Items: (&openapi3.Schema{ + Items: (&Schema{ Type: "object", - Properties: map[string]*openapi3.SchemaRef{ - "key1": (&openapi3.Schema{ + Properties: Schemas{ + "key1": (&Schema{ Type: "array", UniqueItems: true, - Items: openapi3.NewFloat64Schema().NewRef(), + Items: NewFloat64Schema().NewRef(), }).NewRef(), }, }).NewRef(), @@ -526,13 +607,13 @@ var schemaExamples = []schemaExample{ { Title: "ARRAY : items format 'array'", - Schema: &openapi3.Schema{ + Schema: &Schema{ Type: "array", UniqueItems: true, - Items: (&openapi3.Schema{ + Items: (&Schema{ Type: "array", UniqueItems: true, - Items: openapi3.NewFloat64Schema().NewRef(), + Items: NewFloat64Schema().NewRef(), }).NewRef(), }, Serialization: map[string]interface{}{ @@ -570,16 +651,16 @@ var schemaExamples = []schemaExample{ { Title: "ARRAY : items format 'array' and array with object type items", - Schema: &openapi3.Schema{ + Schema: &Schema{ Type: "array", UniqueItems: true, - Items: (&openapi3.Schema{ + Items: (&Schema{ Type: "array", UniqueItems: true, - Items: (&openapi3.Schema{ + Items: (&Schema{ Type: "object", - Properties: map[string]*openapi3.SchemaRef{ - "key1": openapi3.NewFloat64Schema().NewRef(), + Properties: Schemas{ + "key1": NewFloat64Schema().NewRef(), }, }).NewRef(), }).NewRef(), @@ -674,11 +755,11 @@ var schemaExamples = []schemaExample{ { Title: "OBJECT", - Schema: &openapi3.Schema{ + Schema: &Schema{ Type: "object", - MaxProps: openapi3.Uint64Ptr(2), - Properties: map[string]*openapi3.SchemaRef{ - "numberProperty": openapi3.NewFloat64Schema().NewRef(), + MaxProps: Uint64Ptr(2), + Properties: Schemas{ + "numberProperty": NewFloat64Schema().NewRef(), }, }, Serialization: map[string]interface{}{ @@ -718,13 +799,13 @@ var schemaExamples = []schemaExample{ }, }, { - Schema: &openapi3.Schema{ + Schema: &Schema{ Type: "object", - AdditionalProperties: &openapi3.SchemaRef{ - Value: &openapi3.Schema{ + AdditionalProperties: AdditionalProperties{Schema: &SchemaRef{ + Value: &Schema{ Type: "number", }, - }, + }}, }, Serialization: map[string]interface{}{ "type": "object", @@ -746,9 +827,9 @@ var schemaExamples = []schemaExample{ }, }, { - Schema: &openapi3.Schema{ - Type: "object", - AdditionalPropertiesAllowed: openapi3.BoolPtr(true), + Schema: &Schema{ + Type: "object", + AdditionalProperties: AdditionalProperties{Has: BoolPtr(true)}, }, Serialization: map[string]interface{}{ "type": "object", @@ -765,9 +846,9 @@ var schemaExamples = []schemaExample{ { Title: "NOT", - Schema: &openapi3.Schema{ - Not: &openapi3.SchemaRef{ - Value: &openapi3.Schema{ + Schema: &Schema{ + Not: &SchemaRef{ + Value: &Schema{ Enum: []interface{}{ nil, true, @@ -802,15 +883,15 @@ var schemaExamples = []schemaExample{ { Title: "ANY OF", - Schema: &openapi3.Schema{ - AnyOf: []*openapi3.SchemaRef{ + Schema: &Schema{ + AnyOf: []*SchemaRef{ { - Value: openapi3.NewFloat64Schema(). + Value: NewFloat64Schema(). WithMin(1). WithMax(2), }, { - Value: openapi3.NewFloat64Schema(). + Value: NewFloat64Schema(). WithMin(2). WithMax(3), }, @@ -843,15 +924,15 @@ var schemaExamples = []schemaExample{ { Title: "ALL OF", - Schema: &openapi3.Schema{ - AllOf: []*openapi3.SchemaRef{ + Schema: &Schema{ + AllOf: []*SchemaRef{ { - Value: openapi3.NewFloat64Schema(). + Value: NewFloat64Schema(). WithMin(1). WithMax(2), }, { - Value: openapi3.NewFloat64Schema(). + Value: NewFloat64Schema(). WithMin(2). WithMax(3), }, @@ -884,15 +965,15 @@ var schemaExamples = []schemaExample{ { Title: "ONE OF", - Schema: &openapi3.Schema{ - OneOf: []*openapi3.SchemaRef{ + Schema: &Schema{ + OneOf: []*SchemaRef{ { - Value: openapi3.NewFloat64Schema(). + Value: NewFloat64Schema(). WithMin(1). WithMax(2), }, { - Value: openapi3.NewFloat64Schema(). + Value: NewFloat64Schema(). WithMin(2). WithMax(3), }, @@ -926,7 +1007,7 @@ var schemaExamples = []schemaExample{ type schemaTypeExample struct { Title string - Schema *openapi3.Schema + Schema *Schema AllValid []string AllInvalid []string } @@ -942,12 +1023,13 @@ func testType(t *testing.T, example schemaTypeExample) func(*testing.T) { baseSchema := example.Schema for _, typ := range example.AllValid { schema := baseSchema.WithFormat(typ) - err := schema.Validate(context.TODO()) + err := schema.Validate(context.Background()) require.NoError(t, err) } for _, typ := range example.AllInvalid { schema := baseSchema.WithFormat(typ) - err := schema.Validate(context.TODO()) + ctx := WithValidationOptions(context.Background(), EnableSchemaFormatValidation()) + err := schema.Validate(ctx) require.Error(t, err) } } @@ -956,7 +1038,7 @@ func testType(t *testing.T, example schemaTypeExample) func(*testing.T) { var typeExamples = []schemaTypeExample{ { Title: "STRING", - Schema: openapi3.NewStringSchema(), + Schema: NewStringSchema(), AllValid: []string{ "", "byte", @@ -974,7 +1056,7 @@ var typeExamples = []schemaTypeExample{ { Title: "NUMBER", - Schema: openapi3.NewFloat64Schema(), + Schema: NewFloat64Schema(), AllValid: []string{ "", "float", @@ -987,7 +1069,7 @@ var typeExamples = []schemaTypeExample{ { Title: "INTEGER", - Schema: openapi3.NewIntegerSchema(), + Schema: NewIntegerSchema(), AllValid: []string{ "", "int32", @@ -1014,29 +1096,29 @@ func testSchemaError(t *testing.T, example schemaErrorExample) func(*testing.T) type schemaErrorExample struct { Title string - Error *openapi3.SchemaError + Error *SchemaError Want string } var schemaErrorExamples = []schemaErrorExample{ { Title: "SIMPLE", - Error: &openapi3.SchemaError{ + Error: &SchemaError{ Value: 1, - Schema: &openapi3.Schema{}, + Schema: &Schema{}, Reason: "SIMPLE", }, Want: "SIMPLE", }, { Title: "NEST", - Error: &openapi3.SchemaError{ + Error: &SchemaError{ Value: 1, - Schema: &openapi3.Schema{}, + Schema: &Schema{}, Reason: "PARENT", - Origin: &openapi3.SchemaError{ + Origin: &SchemaError{ Value: 1, - Schema: &openapi3.Schema{}, + Schema: &Schema{}, Reason: "NEST", }, }, @@ -1044,30 +1126,243 @@ var schemaErrorExamples = []schemaErrorExample{ }, } -func TestRegisterArrayUniqueItemsChecker(t *testing.T) { - var ( - checker = func(items []interface{}) bool { - return false - } - scheme = openapi3.Schema{ - Type: "array", - UniqueItems: true, - Items: openapi3.NewStringSchema().NewRef(), +type schemaMultiErrorExample struct { + Title string + Schema *Schema + Values []interface{} + ExpectedErrors []MultiError +} + +func TestSchemasMultiError(t *testing.T) { + for _, example := range schemaMultiErrorExamples { + t.Run(example.Title, testSchemaMultiError(t, example)) + } +} + +func testSchemaMultiError(t *testing.T, example schemaMultiErrorExample) func(*testing.T) { + return func(t *testing.T) { + schema := example.Schema + for validateFuncIndex, validateFunc := range validateSchemaFuncs { + for i, value := range example.Values { + err := validateFunc(t, schema, value, MultiErrors()) + require.Errorf(t, err, "ValidateFunc #%d, value #%d: %#", validateFuncIndex, i, value) + require.IsType(t, MultiError{}, err) + + merr, _ := err.(MultiError) + expected := example.ExpectedErrors[i] + require.True(t, len(merr) > 0) + require.Len(t, merr, len(expected)) + for _, e := range merr { + require.IsType(t, &SchemaError{}, e) + var found bool + scherr, _ := e.(*SchemaError) + for _, expectedErr := range expected { + expectedScherr, _ := expectedErr.(*SchemaError) + if reflect.DeepEqual(expectedScherr.reversePath, scherr.reversePath) && + expectedScherr.SchemaField == scherr.SchemaField { + found = true + break + } + } + require.Truef(t, found, "ValidateFunc #%d, value #%d: missing %s error on %s", validateFunc, i, scherr.SchemaField, strings.Join(scherr.JSONPointer(), ".")) + } + } } - val = []interface{}{"1", "2", "3"} - err error - ) + } +} - // Fist checked by predefined function - err = scheme.VisitJSON(val) +var schemaMultiErrorExamples = []schemaMultiErrorExample{ + { + Title: "STRING", + Schema: NewStringSchema(). + WithMinLength(2). + WithMaxLength(3). + WithPattern("^[abc]+$"), + Values: []interface{}{ + "f", + "foobar", + }, + ExpectedErrors: []MultiError{ + {&SchemaError{SchemaField: "minLength"}, &SchemaError{SchemaField: "pattern"}}, + {&SchemaError{SchemaField: "maxLength"}, &SchemaError{SchemaField: "pattern"}}, + }, + }, + { + Title: "NUMBER", + Schema: NewIntegerSchema(). + WithMin(1). + WithMax(10), + Values: []interface{}{ + 0.5, + 10.1, + }, + ExpectedErrors: []MultiError{ + {&SchemaError{SchemaField: "type"}, &SchemaError{SchemaField: "minimum"}}, + {&SchemaError{SchemaField: "type"}, &SchemaError{SchemaField: "maximum"}}, + }, + }, + { + Title: "ARRAY: simple", + Schema: NewArraySchema(). + WithMinItems(2). + WithMaxItems(2). + WithItems(NewStringSchema(). + WithPattern("^[abc]+$")), + Values: []interface{}{ + []interface{}{"foo"}, + []interface{}{"foo", "bar", "fizz"}, + }, + ExpectedErrors: []MultiError{ + { + &SchemaError{SchemaField: "minItems"}, + &SchemaError{SchemaField: "pattern", reversePath: []string{"0"}}, + }, + { + &SchemaError{SchemaField: "maxItems"}, + &SchemaError{SchemaField: "pattern", reversePath: []string{"0"}}, + &SchemaError{SchemaField: "pattern", reversePath: []string{"1"}}, + &SchemaError{SchemaField: "pattern", reversePath: []string{"2"}}, + }, + }, + }, + { + Title: "ARRAY: object", + Schema: NewArraySchema(). + WithItems(NewObjectSchema(). + WithProperties(map[string]*Schema{ + "key1": NewStringSchema(), + "key2": NewIntegerSchema(), + }), + ), + Values: []interface{}{ + []interface{}{ + map[string]interface{}{ + "key1": 100, // not a string + "key2": "not an integer", + }, + }, + }, + ExpectedErrors: []MultiError{ + { + &SchemaError{SchemaField: "type", reversePath: []string{"key1", "0"}}, + &SchemaError{SchemaField: "type", reversePath: []string{"key2", "0"}}, + }, + }, + }, + { + Title: "OBJECT", + Schema: NewObjectSchema(). + WithProperties(map[string]*Schema{ + "key1": NewStringSchema(), + "key2": NewIntegerSchema(), + "key3": NewArraySchema(). + WithItems(NewStringSchema(). + WithPattern("^[abc]+$")), + }), + Values: []interface{}{ + map[string]interface{}{ + "key1": 100, // not a string + "key2": "not an integer", + "key3": []interface{}{"abc", "def"}, + }, + }, + ExpectedErrors: []MultiError{ + { + &SchemaError{SchemaField: "type", reversePath: []string{"key1"}}, + &SchemaError{SchemaField: "type", reversePath: []string{"key2"}}, + &SchemaError{SchemaField: "pattern", reversePath: []string{"1", "key3"}}, + }, + }, + }, +} + +func TestIssue283(t *testing.T) { + const api = ` +openapi: "3.0.1" +components: + schemas: + Test: + properties: + name: + type: string + ownerName: + not: + type: boolean + type: object +` + data := map[string]interface{}{ + "name": "kin-openapi", + "ownerName": true, + } + s, err := NewLoader().LoadFromData([]byte(api)) + require.NoError(t, err) + require.NotNil(t, s) + err = s.Components.Schemas["Test"].Value.VisitJSON(data) + require.NotNil(t, err) + require.NotEqual(t, errSchema, err) + require.Contains(t, err.Error(), `Error at "/ownerName": Doesn't match schema "not"`) +} + +func TestValidationFailsOnInvalidPattern(t *testing.T) { + schema := Schema{ + Pattern: "[", + Type: "string", + } + + err := schema.Validate(context.Background()) + require.Error(t, err) +} + +func TestIssue646(t *testing.T) { + data := []byte(` +enum: +- 42 +- [] +- [a] +- {} +- {b: c} +`[1:]) + + var schema Schema + err := yaml.Unmarshal(data, &schema) + require.NoError(t, err) + + err = schema.Validate(context.Background()) require.NoError(t, err) - // Register a function will always return false when check if a - // slice has unique items, then use a slice indeed has unique - // items to verify that check unique items will failed. - openapi3.RegisterArrayUniqueItemsChecker(checker) + err = schema.VisitJSON(42) + require.NoError(t, err) - err = scheme.VisitJSON(val) + err = schema.VisitJSON(1337) require.Error(t, err) - require.True(t, strings.HasPrefix(err.Error(), "Duplicate items found")) + + err = schema.VisitJSON([]interface{}{}) + require.NoError(t, err) + + err = schema.VisitJSON([]interface{}{"a"}) + require.NoError(t, err) + + err = schema.VisitJSON([]interface{}{"b"}) + require.Error(t, err) + + err = schema.VisitJSON(map[string]interface{}{}) + require.NoError(t, err) + + err = schema.VisitJSON(map[string]interface{}{"b": "c"}) + require.NoError(t, err) + + err = schema.VisitJSON(map[string]interface{}{"d": "e"}) + require.Error(t, err) +} + +func TestIssue751(t *testing.T) { + schema := &Schema{ + Type: "array", + UniqueItems: true, + Items: NewStringSchema().NewRef(), + } + validData := []string{"foo", "bar"} + invalidData := []string{"foo", "foo"} + require.NoError(t, schema.VisitJSON(validData)) + require.ErrorContains(t, schema.VisitJSON(invalidData), "duplicate items found") } diff --git a/openapi3/schema_validation_settings.go b/openapi3/schema_validation_settings.go new file mode 100644 index 000000000..17aad2fa7 --- /dev/null +++ b/openapi3/schema_validation_settings.go @@ -0,0 +1,79 @@ +package openapi3 + +import ( + "sync" +) + +// SchemaValidationOption describes options a user has when validating request / response bodies. +type SchemaValidationOption func(*schemaValidationSettings) + +type schemaValidationSettings struct { + failfast bool + multiError bool + asreq, asrep bool // exclusive (XOR) fields + formatValidationEnabled bool + patternValidationDisabled bool + readOnlyValidationDisabled bool + writeOnlyValidationDisabled bool + + onceSettingDefaults sync.Once + defaultsSet func() + + customizeMessageError func(err *SchemaError) string +} + +// FailFast returns schema validation errors quicker. +func FailFast() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.failfast = true } +} + +func MultiErrors() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.multiError = true } +} + +func VisitAsRequest() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.asreq, s.asrep = true, false } +} + +func VisitAsResponse() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.asreq, s.asrep = false, true } +} + +// EnableFormatValidation setting makes Validate not return an error when validating documents that mention schema formats that are not defined by the OpenAPIv3 specification. +func EnableFormatValidation() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.formatValidationEnabled = true } +} + +// DisablePatternValidation setting makes Validate not return an error when validating patterns that are not supported by the Go regexp engine. +func DisablePatternValidation() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.patternValidationDisabled = true } +} + +// DisableReadOnlyValidation setting makes Validate not return an error when validating properties marked as read-only +func DisableReadOnlyValidation() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.readOnlyValidationDisabled = true } +} + +// DisableWriteOnlyValidation setting makes Validate not return an error when validating properties marked as write-only +func DisableWriteOnlyValidation() SchemaValidationOption { + return func(s *schemaValidationSettings) { s.writeOnlyValidationDisabled = true } +} + +// DefaultsSet executes the given callback (once) IFF schema validation set default values. +func DefaultsSet(f func()) SchemaValidationOption { + return func(s *schemaValidationSettings) { s.defaultsSet = f } +} + +// SetSchemaErrorMessageCustomizer allows to override the schema error message. +// If the passed function returns an empty string, it returns to the previous Error() implementation. +func SetSchemaErrorMessageCustomizer(f func(err *SchemaError) string) SchemaValidationOption { + return func(s *schemaValidationSettings) { s.customizeMessageError = f } +} + +func newSchemaValidationSettings(opts ...SchemaValidationOption) *schemaValidationSettings { + settings := &schemaValidationSettings{} + for _, opt := range opts { + opt(settings) + } + return settings +} diff --git a/openapi3/schema_validation_settings_test.go b/openapi3/schema_validation_settings_test.go new file mode 100644 index 000000000..db52d3bdf --- /dev/null +++ b/openapi3/schema_validation_settings_test.go @@ -0,0 +1,36 @@ +package openapi3_test + +import ( + "fmt" + + "github.com/getkin/kin-openapi/openapi3" +) + +func ExampleSetSchemaErrorMessageCustomizer() { + loader := openapi3.NewLoader() + spc := ` +components: + schemas: + Something: + type: object + properties: + field: + title: Some field + type: string +`[1:] + + doc, err := loader.LoadFromData([]byte(spc)) + if err != nil { + panic(err) + } + + opt := openapi3.SetSchemaErrorMessageCustomizer(func(err *openapi3.SchemaError) string { + return fmt.Sprintf(`field "%s" should be string`, err.Schema.Title) + }) + + err = doc.Components.Schemas["Something"].Value.Properties["field"].Value.VisitJSON(123, opt) + + fmt.Println(err.Error()) + + // Output: field "Some field" should be string +} diff --git a/openapi3/security_requirements.go b/openapi3/security_requirements.go index 1d2c745f7..87891c954 100644 --- a/openapi3/security_requirements.go +++ b/openapi3/security_requirements.go @@ -15,15 +15,20 @@ func (srs *SecurityRequirements) With(securityRequirement SecurityRequirement) * return srs } -func (srs SecurityRequirements) Validate(c context.Context) error { - for _, item := range srs { - if err := item.Validate(c); err != nil { +// Validate returns an error if SecurityRequirements does not comply with the OpenAPI spec. +func (srs SecurityRequirements) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + for _, security := range srs { + if err := security.Validate(ctx); err != nil { return err } } return nil } +// SecurityRequirement is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-requirement-object type SecurityRequirement map[string][]string func NewSecurityRequirement() SecurityRequirement { @@ -38,6 +43,9 @@ func (security SecurityRequirement) Authenticate(provider string, scopes ...stri return security } -func (security SecurityRequirement) Validate(c context.Context) error { +// Validate returns an error if SecurityRequirement does not comply with the OpenAPI spec. +func (security *SecurityRequirement) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + return nil } diff --git a/openapi3/security_scheme.go b/openapi3/security_scheme.go index 0e991fb67..76cc21f37 100644 --- a/openapi3/security_scheme.go +++ b/openapi3/security_scheme.go @@ -2,22 +2,44 @@ package openapi3 import ( "context" + "encoding/json" "errors" "fmt" + "net/url" - "github.com/getkin/kin-openapi/jsoninfo" + "github.com/go-openapi/jsonpointer" ) +type SecuritySchemes map[string]*SecuritySchemeRef + +// JSONLookup implements github.com/go-openapi/jsonpointer#JSONPointable +func (s SecuritySchemes) JSONLookup(token string) (interface{}, error) { + ref, ok := s[token] + if ref == nil || ok == false { + return nil, fmt.Errorf("object has no field %q", token) + } + + if ref.Ref != "" { + return &Ref{Ref: ref.Ref}, nil + } + return ref.Value, nil +} + +var _ jsonpointer.JSONPointable = (*SecuritySchemes)(nil) + +// SecurityScheme is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-scheme-object type SecurityScheme struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` - Type string `json:"type,omitempty" yaml:"type,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` - In string `json:"in,omitempty" yaml:"in,omitempty"` - Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` - BearerFormat string `json:"bearerFormat,omitempty" yaml:"bearerFormat,omitempty"` - Flows *OAuthFlows `json:"flows,omitempty" yaml:"flows,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + BearerFormat string `json:"bearerFormat,omitempty" yaml:"bearerFormat,omitempty"` + Flows *OAuthFlows `json:"flows,omitempty" yaml:"flows,omitempty"` + OpenIdConnectUrl string `json:"openIdConnectUrl,omitempty" yaml:"openIdConnectUrl,omitempty"` } func NewSecurityScheme() *SecurityScheme { @@ -32,6 +54,13 @@ func NewCSRFSecurityScheme() *SecurityScheme { } } +func NewOIDCSecurityScheme(oidcUrl string) *SecurityScheme { + return &SecurityScheme{ + Type: "openIdConnect", + OpenIdConnectUrl: oidcUrl, + } +} + func NewJWTSecurityScheme() *SecurityScheme { return &SecurityScheme{ Type: "http", @@ -40,12 +69,57 @@ func NewJWTSecurityScheme() *SecurityScheme { } } -func (ss *SecurityScheme) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(ss) +// MarshalJSON returns the JSON encoding of SecurityScheme. +func (ss SecurityScheme) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 8+len(ss.Extensions)) + for k, v := range ss.Extensions { + m[k] = v + } + if x := ss.Type; x != "" { + m["type"] = x + } + if x := ss.Description; x != "" { + m["description"] = x + } + if x := ss.Name; x != "" { + m["name"] = x + } + if x := ss.In; x != "" { + m["in"] = x + } + if x := ss.Scheme; x != "" { + m["scheme"] = x + } + if x := ss.BearerFormat; x != "" { + m["bearerFormat"] = x + } + if x := ss.Flows; x != nil { + m["flows"] = x + } + if x := ss.OpenIdConnectUrl; x != "" { + m["openIdConnectUrl"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets SecurityScheme to a copy of data. func (ss *SecurityScheme) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, ss) + type SecuritySchemeBis SecurityScheme + var x SecuritySchemeBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "type") + delete(x.Extensions, "description") + delete(x.Extensions, "name") + delete(x.Extensions, "in") + delete(x.Extensions, "scheme") + delete(x.Extensions, "bearerFormat") + delete(x.Extensions, "flows") + delete(x.Extensions, "openIdConnectUrl") + *ss = SecurityScheme(x) + return nil } func (ss *SecurityScheme) WithType(value string) *SecurityScheme { @@ -78,7 +152,10 @@ func (ss *SecurityScheme) WithBearerFormat(value string) *SecurityScheme { return ss } -func (ss *SecurityScheme) Validate(c context.Context) error { +// Validate returns an error if SecurityScheme does not comply with the OpenAPI spec. +func (ss *SecurityScheme) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + hasIn := false hasBearerFormat := false hasFlow := false @@ -90,16 +167,18 @@ func (ss *SecurityScheme) Validate(c context.Context) error { switch scheme { case "bearer": hasBearerFormat = true - case "basic": + case "basic", "negotiate", "digest": default: - return fmt.Errorf("Security scheme of type 'http' has invalid 'scheme' value '%s'", scheme) + return fmt.Errorf("security scheme of type 'http' has invalid 'scheme' value %q", scheme) } case "oauth2": hasFlow = true case "openIdConnect": - return fmt.Errorf("Support for security schemes with type '%v' has not been implemented", ss.Type) + if ss.OpenIdConnectUrl == "" { + return fmt.Errorf("no OIDC URL found for openIdConnect security scheme %q", ss.Name) + } default: - return fmt.Errorf("Security scheme 'type' can't be '%v'", ss.Type) + return fmt.Errorf("security scheme 'type' can't be %q", ss.Type) } // Validate "in" and "name" @@ -107,40 +186,44 @@ func (ss *SecurityScheme) Validate(c context.Context) error { switch ss.In { case "query", "header", "cookie": default: - return fmt.Errorf("Security scheme of type 'apiKey' should have 'in'. It can be 'query', 'header' or 'cookie', not '%s'", ss.In) + return fmt.Errorf("security scheme of type 'apiKey' should have 'in'. It can be 'query', 'header' or 'cookie', not %q", ss.In) } if ss.Name == "" { - return errors.New("Security scheme of type 'apiKey' should have 'name'") + return errors.New("security scheme of type 'apiKey' should have 'name'") } } else if len(ss.In) > 0 { - return fmt.Errorf("Security scheme of type '%s' can't have 'in'", ss.Type) + return fmt.Errorf("security scheme of type %q can't have 'in'", ss.Type) } else if len(ss.Name) > 0 { - return errors.New("Security scheme of type 'apiKey' can't have 'name'") + return fmt.Errorf("security scheme of type %q can't have 'name'", ss.Type) } // Validate "format" // "bearerFormat" is an arbitrary string so we only check if the scheme supports it if !hasBearerFormat && len(ss.BearerFormat) > 0 { - return fmt.Errorf("Security scheme of type '%v' can't have 'bearerFormat'", ss.Type) + return fmt.Errorf("security scheme of type %q can't have 'bearerFormat'", ss.Type) } // Validate "flow" if hasFlow { flow := ss.Flows if flow == nil { - return fmt.Errorf("Security scheme of type '%v' should have 'flows'", ss.Type) + return fmt.Errorf("security scheme of type %q should have 'flows'", ss.Type) } - if err := flow.Validate(c); err != nil { - return fmt.Errorf("Security scheme 'flow' is invalid: %v", err) + if err := flow.Validate(ctx); err != nil { + return fmt.Errorf("security scheme 'flow' is invalid: %w", err) } } else if ss.Flows != nil { - return fmt.Errorf("Security scheme of type '%s' can't have 'flows'", ss.Type) + return fmt.Errorf("security scheme of type %q can't have 'flows'", ss.Type) } - return nil + + return validateExtensions(ctx, ss.Extensions) } +// OAuthFlows is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flows-object type OAuthFlows struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` + Implicit *OAuthFlow `json:"implicit,omitempty" yaml:"implicit,omitempty"` Password *OAuthFlow `json:"password,omitempty" yaml:"password,omitempty"` ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty" yaml:"clientCredentials,omitempty"` @@ -156,59 +239,174 @@ const ( oAuthFlowAuthorizationCode ) -func (flows *OAuthFlows) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(flows) +// MarshalJSON returns the JSON encoding of OAuthFlows. +func (flows OAuthFlows) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 4+len(flows.Extensions)) + for k, v := range flows.Extensions { + m[k] = v + } + if x := flows.Implicit; x != nil { + m["implicit"] = x + } + if x := flows.Password; x != nil { + m["password"] = x + } + if x := flows.ClientCredentials; x != nil { + m["clientCredentials"] = x + } + if x := flows.AuthorizationCode; x != nil { + m["authorizationCode"] = x + } + return json.Marshal(m) } +// UnmarshalJSON sets OAuthFlows to a copy of data. func (flows *OAuthFlows) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, flows) + type OAuthFlowsBis OAuthFlows + var x OAuthFlowsBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "implicit") + delete(x.Extensions, "password") + delete(x.Extensions, "clientCredentials") + delete(x.Extensions, "authorizationCode") + *flows = OAuthFlows(x) + return nil } -func (flows *OAuthFlows) Validate(c context.Context) error { +// Validate returns an error if OAuthFlows does not comply with the OpenAPI spec. +func (flows *OAuthFlows) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + if v := flows.Implicit; v != nil { - return v.Validate(c, oAuthFlowTypeImplicit) + if err := v.validate(ctx, oAuthFlowTypeImplicit, opts...); err != nil { + return fmt.Errorf("the OAuth flow 'implicit' is invalid: %w", err) + } } + if v := flows.Password; v != nil { - return v.Validate(c, oAuthFlowTypePassword) + if err := v.validate(ctx, oAuthFlowTypePassword, opts...); err != nil { + return fmt.Errorf("the OAuth flow 'password' is invalid: %w", err) + } } + if v := flows.ClientCredentials; v != nil { - return v.Validate(c, oAuthFlowTypeClientCredentials) + if err := v.validate(ctx, oAuthFlowTypeClientCredentials, opts...); err != nil { + return fmt.Errorf("the OAuth flow 'clientCredentials' is invalid: %w", err) + } } + if v := flows.AuthorizationCode; v != nil { - return v.Validate(c, oAuthFlowAuthorizationCode) + if err := v.validate(ctx, oAuthFlowAuthorizationCode, opts...); err != nil { + return fmt.Errorf("the OAuth flow 'authorizationCode' is invalid: %w", err) + } } - return errors.New("No OAuth flow is defined") + + return validateExtensions(ctx, flows.Extensions) } +// OAuthFlow is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flow-object type OAuthFlow struct { - ExtensionProps + Extensions map[string]interface{} `json:"-" yaml:"-"` + AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"` TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"` - Scopes map[string]string `json:"scopes" yaml:"scopes"` + Scopes map[string]string `json:"scopes" yaml:"scopes"` // required } -func (flow *OAuthFlow) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(flow) +// MarshalJSON returns the JSON encoding of OAuthFlow. +func (flow OAuthFlow) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 4+len(flow.Extensions)) + for k, v := range flow.Extensions { + m[k] = v + } + if x := flow.AuthorizationURL; x != "" { + m["authorizationUrl"] = x + } + if x := flow.TokenURL; x != "" { + m["tokenUrl"] = x + } + if x := flow.RefreshURL; x != "" { + m["refreshUrl"] = x + } + m["scopes"] = flow.Scopes + return json.Marshal(m) } +// UnmarshalJSON sets OAuthFlow to a copy of data. func (flow *OAuthFlow) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, flow) + type OAuthFlowBis OAuthFlow + var x OAuthFlowBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "authorizationUrl") + delete(x.Extensions, "tokenUrl") + delete(x.Extensions, "refreshUrl") + delete(x.Extensions, "scopes") + *flow = OAuthFlow(x) + return nil +} + +// Validate returns an error if OAuthFlows does not comply with the OpenAPI spec. +func (flow *OAuthFlow) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if v := flow.RefreshURL; v != "" { + if _, err := url.Parse(v); err != nil { + return fmt.Errorf("field 'refreshUrl' is invalid: %w", err) + } + } + + if flow.Scopes == nil { + return errors.New("field 'scopes' is missing") + } + + return validateExtensions(ctx, flow.Extensions) } -func (flow *OAuthFlow) Validate(c context.Context, typ oAuthFlowType) error { - if typ == oAuthFlowAuthorizationCode || typ == oAuthFlowTypeImplicit { - if v := flow.AuthorizationURL; v == "" { - return errors.New("An OAuth flow is missing 'authorizationUrl in authorizationCode or implicit '") +func (flow *OAuthFlow) validate(ctx context.Context, typ oAuthFlowType, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + typeIn := func(types ...oAuthFlowType) bool { + for _, ty := range types { + if ty == typ { + return true + } } + return false } - if typ != oAuthFlowTypeImplicit { - if v := flow.TokenURL; v == "" { - return errors.New("An OAuth flow is missing 'tokenUrl in not implicit'") + + if in := typeIn(oAuthFlowTypeImplicit, oAuthFlowAuthorizationCode); true { + switch { + case flow.AuthorizationURL == "" && in: + return errors.New("field 'authorizationUrl' is empty or missing") + case flow.AuthorizationURL != "" && !in: + return errors.New("field 'authorizationUrl' should not be set") + case flow.AuthorizationURL != "": + if _, err := url.Parse(flow.AuthorizationURL); err != nil { + return fmt.Errorf("field 'authorizationUrl' is invalid: %w", err) + } } } - if v := flow.Scopes; v == nil { - return errors.New("An OAuth flow is missing 'scopes'") + + if in := typeIn(oAuthFlowTypePassword, oAuthFlowTypeClientCredentials, oAuthFlowAuthorizationCode); true { + switch { + case flow.TokenURL == "" && in: + return errors.New("field 'tokenUrl' is empty or missing") + case flow.TokenURL != "" && !in: + return errors.New("field 'tokenUrl' should not be set") + case flow.TokenURL != "": + if _, err := url.Parse(flow.TokenURL); err != nil { + return fmt.Errorf("field 'tokenUrl' is invalid: %w", err) + } + } } - return nil + + return flow.Validate(ctx, opts...) } diff --git a/openapi3/security_scheme_test.go b/openapi3/security_scheme_test.go index 7f013be4c..790414ca2 100644 --- a/openapi3/security_scheme_test.go +++ b/openapi3/security_scheme_test.go @@ -1,10 +1,9 @@ -package openapi3_test +package openapi3 import ( "context" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) @@ -16,75 +15,84 @@ type securitySchemeExample struct { func TestSecuritySchemaExample(t *testing.T) { for _, example := range securitySchemeExamples { - t.Run(example.title, testSecuritySchemaExample(t, example)) - } -} - -func testSecuritySchemaExample(t *testing.T, e securitySchemeExample) func(*testing.T) { - return func(t *testing.T) { - var err error - ss := &openapi3.SecurityScheme{} - err = ss.UnmarshalJSON(e.raw) - require.NoError(t, err) - err = ss.Validate(context.TODO()) - if e.valid { + t.Run(example.title, func(t *testing.T) { + ss := &SecurityScheme{} + err := ss.UnmarshalJSON(example.raw) require.NoError(t, err) - } else { - require.Error(t, err) - } + + err = ss.Validate(context.Background()) + if example.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) } } -// from https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#fixed-fields-23 +// from https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#fixed-fields-23 var securitySchemeExamples = []securitySchemeExample{ { title: "Basic Authentication Sample", - raw: []byte(` -{ + raw: []byte(`{ "type": "http", "scheme": "basic" -} -`), +}`), valid: true, }, + + { + title: "Negotiate Authentication Sample", + raw: []byte(`{ + "type": "http", + "scheme": "negotiate" +}`), + valid: true, + }, + + { + title: "Unknown http Authentication Sample", + raw: []byte(`{ + "type": "http", + "scheme": "notvalid" +}`), + valid: false, + }, + { title: "API Key Sample", - raw: []byte(` -{ + raw: []byte(`{ "type": "apiKey", "name": "api_key", "in": "header" -} -`), +}`), valid: true, }, + { title: "apiKey with bearerFormat", - raw: []byte(` -{ + raw: []byte(`{ "type": "apiKey", - "in": "header", - "name": "X-API-KEY", + "in": "header", + "name": "X-API-KEY", "bearerFormat": "Arbitrary text" -} -`), +}`), valid: false, }, + { title: "Bearer Sample with arbitrary format", - raw: []byte(` -{ + raw: []byte(`{ "type": "http", "scheme": "bearer", "bearerFormat": "Arbitrary text" -} -`), +}`), valid: true, }, + { title: "Implicit OAuth2 Sample", - raw: []byte(` -{ + raw: []byte(`{ "type": "oauth2", "flows": { "implicit": { @@ -95,14 +103,13 @@ var securitySchemeExamples = []securitySchemeExample{ } } } -} -`), +}`), valid: true, }, + { title: "OAuth Flow Object Sample", - raw: []byte(` -{ + raw: []byte(`{ "type": "oauth2", "flows": { "implicit": { @@ -121,14 +128,13 @@ var securitySchemeExamples = []securitySchemeExample{ } } } -} -`), +}`), valid: true, }, + { title: "OAuth Flow Object clientCredentials/password", - raw: []byte(` -{ + raw: []byte(`{ "type": "oauth2", "flows": { "clientCredentials": { @@ -144,59 +150,71 @@ var securitySchemeExamples = []securitySchemeExample{ } } } -} -`), +}`), valid: true, }, + { title: "Invalid Basic", - raw: []byte(` -{ + raw: []byte(`{ "type": "https", "scheme": "basic" -} -`), +}`), valid: false, }, + { title: "Apikey Cookie", - raw: []byte(` -{ + raw: []byte(`{ "type": "apiKey", "in": "cookie", "name": "somecookie" -} -`), +}`), valid: true, }, { title: "OAuth Flow Object with no scopes", - raw: []byte(` -{ + raw: []byte(`{ "type": "oauth2", "flows": { "password": { "tokenUrl": "https://example.com/api/oauth/token" } } -} -`), +}`), valid: false, }, + { title: "OAuth Flow Object with empty scopes", - raw: []byte(` -{ + raw: []byte(`{ "type": "oauth2", "flows": { "password": { - "tokenUrl": "https://example.com/api/oauth/token", - "scopes": {} + "tokenUrl": "https://example.com/api/oauth/token", + "scopes": {} } } -} -`), +}`), + valid: true, + }, + + { + title: "OIDC Type With URL", + raw: []byte(`{ + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" +}`), valid: true, }, + + { + title: "OIDC Type Without URL", + raw: []byte(`{ + "type": "openIdConnect", + "openIdConnectUrl": "" +}`), + valid: false, + }, } diff --git a/openapi3/server.go b/openapi3/server.go index 4392b09af..9fc99f90c 100644 --- a/openapi3/server.go +++ b/openapi3/server.go @@ -2,24 +2,38 @@ package openapi3 import ( "context" + "encoding/json" "errors" + "fmt" "math" "net/url" + "sort" "strings" ) -// Servers is specified by OpenAPI/Swagger standard version 3.0. +// Servers is specified by OpenAPI/Swagger standard version 3. type Servers []*Server -func (servers Servers) Validate(c context.Context) error { +// Validate returns an error if Servers does not comply with the OpenAPI spec. +func (servers Servers) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + for _, v := range servers { - if err := v.Validate(c); err != nil { + if err := v.Validate(ctx); err != nil { return err } } return nil } +// BasePath returns the base path of the first server in the list, or /. +func (servers Servers) BasePath() (string, error) { + for _, server := range servers { + return server.BasePath() + } + return "/", nil +} + func (servers Servers) MatchURL(parsedURL *url.URL) (*Server, []string, string) { rawURL := parsedURL.String() if i := strings.IndexByte(rawURL, '?'); i >= 0 { @@ -34,13 +48,71 @@ func (servers Servers) MatchURL(parsedURL *url.URL) (*Server, []string, string) return nil, nil, "" } -// Server is specified by OpenAPI/Swagger standard version 3.0. +// Server is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-object type Server struct { - URL string `json:"url" yaml:"url"` + Extensions map[string]interface{} `json:"-" yaml:"-"` + + URL string `json:"url" yaml:"url"` // Required Description string `json:"description,omitempty" yaml:"description,omitempty"` Variables map[string]*ServerVariable `json:"variables,omitempty" yaml:"variables,omitempty"` } +// BasePath returns the base path extracted from the default values of variables, if any. +// Assumes a valid struct (per Validate()). +func (server *Server) BasePath() (string, error) { + if server == nil { + return "/", nil + } + + uri := server.URL + for name, svar := range server.Variables { + uri = strings.ReplaceAll(uri, "{"+name+"}", svar.Default) + } + + u, err := url.ParseRequestURI(uri) + if err != nil { + return "", err + } + + if bp := u.Path; bp != "" { + return bp, nil + } + + return "/", nil +} + +// MarshalJSON returns the JSON encoding of Server. +func (server Server) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 3+len(server.Extensions)) + for k, v := range server.Extensions { + m[k] = v + } + m["url"] = server.URL + if x := server.Description; x != "" { + m["description"] = x + } + if x := server.Variables; len(x) != 0 { + m["variables"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets Server to a copy of data. +func (server *Server) UnmarshalJSON(data []byte) error { + type ServerBis Server + var x ServerBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "url") + delete(x.Extensions, "description") + delete(x.Extensions, "variables") + *server = Server(x) + return nil +} + func (server Server) ParameterNames() ([]string, error) { pattern := server.URL var params []string @@ -52,7 +124,7 @@ func (server Server) ParameterNames() ([]string, error) { pattern = pattern[i+1:] i = strings.IndexByte(pattern, '}') if i < 0 { - return nil, errors.New("Missing '}'") + return nil, errors.New("missing '}'") } params = append(params, strings.TrimSpace(pattern[:i])) pattern = pattern[i+1:] @@ -112,37 +184,95 @@ func (server Server) MatchRawURL(input string) ([]string, string, bool) { return params, input, true } -func (server *Server) Validate(c context.Context) (err error) { +// Validate returns an error if Server does not comply with the OpenAPI spec. +func (server *Server) Validate(ctx context.Context, opts ...ValidationOption) (err error) { + ctx = WithValidationOptions(ctx, opts...) + if server.URL == "" { - return errors.New("Variable 'URL' must be a non-empty JSON string") + return errors.New("value of url must be a non-empty string") + } + + opening, closing := strings.Count(server.URL, "{"), strings.Count(server.URL, "}") + if opening != closing { + return errors.New("server URL has mismatched { and }") + } + + if opening != len(server.Variables) { + return errors.New("server has undeclared variables") + } + + variables := make([]string, 0, len(server.Variables)) + for name := range server.Variables { + variables = append(variables, name) } - for _, v := range server.Variables { - if err = v.Validate(c); err != nil { + sort.Strings(variables) + for _, name := range variables { + v := server.Variables[name] + if !strings.Contains(server.URL, "{"+name+"}") { + return errors.New("server has undeclared variables") + } + if err = v.Validate(ctx); err != nil { return } } - return + + return validateExtensions(ctx, server.Extensions) } -// ServerVariable is specified by OpenAPI/Swagger standard version 3.0. +// ServerVariable is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-variable-object type ServerVariable struct { - Enum []interface{} `json:"enum,omitempty" yaml:"enum,omitempty"` - Default interface{} `json:"default,omitempty" yaml:"default,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Enum []string `json:"enum,omitempty" yaml:"enum,omitempty"` + Default string `json:"default,omitempty" yaml:"default,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` } -func (serverVariable *ServerVariable) Validate(c context.Context) error { - switch serverVariable.Default.(type) { - case float64, string: - default: - return errors.New("Variable 'default' must be either JSON number or JSON string") - } - for _, item := range serverVariable.Enum { - switch item.(type) { - case float64, string: - default: - return errors.New("Every variable 'enum' item must be number of string") - } +// MarshalJSON returns the JSON encoding of ServerVariable. +func (serverVariable ServerVariable) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 4+len(serverVariable.Extensions)) + for k, v := range serverVariable.Extensions { + m[k] = v + } + if x := serverVariable.Enum; len(x) != 0 { + m["enum"] = x } + if x := serverVariable.Default; x != "" { + m["default"] = x + } + if x := serverVariable.Description; x != "" { + m["description"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets ServerVariable to a copy of data. +func (serverVariable *ServerVariable) UnmarshalJSON(data []byte) error { + type ServerVariableBis ServerVariable + var x ServerVariableBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "enum") + delete(x.Extensions, "default") + delete(x.Extensions, "description") + *serverVariable = ServerVariable(x) return nil } + +// Validate returns an error if ServerVariable does not comply with the OpenAPI spec. +func (serverVariable *ServerVariable) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if serverVariable.Default == "" { + data, err := serverVariable.MarshalJSON() + if err != nil { + return err + } + return fmt.Errorf("field default is required in %s", data) + } + + return validateExtensions(ctx, serverVariable.Extensions) +} diff --git a/openapi3/server_test.go b/openapi3/server_test.go index b877a3546..c59b86e56 100644 --- a/openapi3/server_test.go +++ b/openapi3/server_test.go @@ -1,16 +1,15 @@ -package openapi3_test +package openapi3 import ( "context" "errors" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) func TestServerParamNames(t *testing.T) { - server := &openapi3.Server{ + server := &Server{ URL: "http://{x}.{y}.example.com", } values, err := server.ParameterNames() @@ -19,7 +18,7 @@ func TestServerParamNames(t *testing.T) { } func TestServerParamValuesWithPath(t *testing.T) { - server := &openapi3.Server{ + server := &Server{ URL: "http://{arg0}.{arg1}.example.com/a/{arg3}-version/{arg4}c{arg5}", } for input, expected := range map[string]*serverMatch{ @@ -41,7 +40,7 @@ func TestServerParamValuesWithPath(t *testing.T) { } func TestServerParamValuesNoPath(t *testing.T) { - server := &openapi3.Server{ + server := &Server{ URL: "https://{arg0}.{arg1}.example.com/", } for input, expected := range map[string]*serverMatch{ @@ -51,26 +50,26 @@ func TestServerParamValuesNoPath(t *testing.T) { } } -func validServer() *openapi3.Server { - return &openapi3.Server{ +func validServer() *Server { + return &Server{ URL: "http://my.cool.website", } } -func invalidServer() *openapi3.Server { - return &openapi3.Server{} +func invalidServer() *Server { + return &Server{} } func TestServerValidation(t *testing.T) { tests := []struct { name string - input *openapi3.Server + input *Server expectedError error }{ { "when no URL is provided", invalidServer(), - errors.New("Variable 'URL' must be a non-empty JSON string"), + errors.New("value of url must be a non-empty string"), }, { "when a URL is provided", @@ -89,7 +88,7 @@ func TestServerValidation(t *testing.T) { } } -func testServerParamValues(t *testing.T, server *openapi3.Server, input string, expected *serverMatch) func(*testing.T) { +func testServerParamValues(t *testing.T, server *Server, input string, expected *serverMatch) func(*testing.T) { return func(t *testing.T) { args, remaining, ok := server.MatchRawURL(input) if expected == nil { @@ -117,3 +116,88 @@ func newServerMatch(remaining string, args ...string) *serverMatch { Args: args, } } + +func TestServersBasePath(t *testing.T) { + for _, testcase := range []struct { + title string + servers Servers + expected string + }{ + { + title: "empty servers", + servers: nil, + expected: "/", + }, + { + title: "URL set, missing trailing slash", + servers: Servers{&Server{URL: "https://example.com"}}, + expected: "/", + }, + { + title: "URL set, with trailing slash", + servers: Servers{&Server{URL: "https://example.com/"}}, + expected: "/", + }, + { + title: "URL set", + servers: Servers{&Server{URL: "https://example.com/b/l/a"}}, + expected: "/b/l/a", + }, + { + title: "URL set with variables", + servers: Servers{&Server{ + URL: "{scheme}://example.com/b/l/a", + Variables: map[string]*ServerVariable{ + "scheme": { + Enum: []string{"http", "https"}, + Default: "https", + }, + }, + }}, + expected: "/b/l/a", + }, + { + title: "URL set with variables in path", + servers: Servers{&Server{ + URL: "http://example.com/b/{var1}/a", + Variables: map[string]*ServerVariable{ + "var1": { + Default: "lllll", + }, + }, + }}, + expected: "/b/lllll/a", + }, + { + title: "URLs set with variables in path", + servers: Servers{ + &Server{ + URL: "http://example.com/b/{var2}/a", + Variables: map[string]*ServerVariable{ + "var2": { + Default: "LLLLL", + }, + }, + }, + &Server{ + URL: "https://example.com/b/{var1}/a", + Variables: map[string]*ServerVariable{ + "var1": { + Default: "lllll", + }, + }, + }, + }, + expected: "/b/LLLLL/a", + }, + } { + t.Run(testcase.title, func(t *testing.T) { + err := testcase.servers.Validate(context.Background()) + require.NoError(t, err) + + got, err := testcase.servers.BasePath() + require.NoError(t, err) + require.Exactly(t, testcase.expected, got) + }) + } +} diff --git a/openapi3/swagger.go b/openapi3/swagger.go deleted file mode 100644 index 6ca8f1e02..000000000 --- a/openapi3/swagger.go +++ /dev/null @@ -1,81 +0,0 @@ -package openapi3 - -import ( - "context" - "errors" - "fmt" - - "github.com/getkin/kin-openapi/jsoninfo" -) - -type Swagger struct { - ExtensionProps - OpenAPI string `json:"openapi" yaml:"openapi"` // Required - Info *Info `json:"info" yaml:"info"` // Required - Servers Servers `json:"servers,omitempty" yaml:"servers,omitempty"` - Paths Paths `json:"paths" yaml:"paths"` // Required - Components Components `json:"components,omitempty" yaml:"components,omitempty"` - Tags Tags `json:"tags,omitempty" yaml:"tags,omitempty"` - Security SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"` - ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` -} - -func (swagger *Swagger) MarshalJSON() ([]byte, error) { - return jsoninfo.MarshalStrictStruct(swagger) -} - -func (swagger *Swagger) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, swagger) -} - -func (swagger *Swagger) AddOperation(path string, method string, operation *Operation) { - paths := swagger.Paths - if paths == nil { - paths = make(Paths) - swagger.Paths = paths - } - pathItem := paths[path] - if pathItem == nil { - pathItem = &PathItem{} - paths[path] = pathItem - } - pathItem.SetOperation(method, operation) -} - -func (swagger *Swagger) AddServer(server *Server) { - swagger.Servers = append(swagger.Servers, server) -} - -func (swagger *Swagger) Validate(c context.Context) error { - if swagger.OpenAPI == "" { - return errors.New("Variable 'openapi' must be a non-empty JSON string") - } - if err := swagger.Components.Validate(c); err != nil { - return fmt.Errorf("Error when validating Components: %s", err.Error()) - } - if v := swagger.Security; v != nil { - if err := v.Validate(c); err != nil { - return fmt.Errorf("Error when validating Security: %s", err.Error()) - } - } - if v := swagger.Servers; v != nil { - if err := v.Validate(c); err != nil { - return fmt.Errorf("Error when validating Servers: %s", err.Error()) - } - } - if v := swagger.Paths; v != nil { - if err := v.Validate(c); err != nil { - return fmt.Errorf("Error when validating Paths: %s", err.Error()) - } - } else { - return errors.New("Variable 'paths' must be a JSON object") - } - if v := swagger.Info; v != nil { - if err := v.Validate(c); err != nil { - return fmt.Errorf("Error when validating Info: %s", err.Error()) - } - } else { - return errors.New("Variable 'info' must be a JSON object") - } - return nil -} diff --git a/openapi3/swagger_loader.go b/openapi3/swagger_loader.go deleted file mode 100644 index 3925e0e6c..000000000 --- a/openapi3/swagger_loader.go +++ /dev/null @@ -1,875 +0,0 @@ -package openapi3 - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "path" - "reflect" - "strconv" - "strings" - - "github.com/ghodss/yaml" -) - -func foundUnresolvedRef(ref string) error { - return fmt.Errorf("Found unresolved ref: '%s'", ref) -} - -func failedToResolveRefFragment(value string) error { - return fmt.Errorf("Failed to resolve fragment in URI: '%s'", value) -} - -func failedToResolveRefFragmentPart(value string, what string) error { - return fmt.Errorf("Failed to resolve '%s' in fragment in URI: '%s'", what, value) -} - -type SwaggerLoader struct { - IsExternalRefsAllowed bool - Context context.Context - LoadSwaggerFromURIFunc func(loader *SwaggerLoader, url *url.URL) (*Swagger, error) - visited map[interface{}]struct{} - visitedFiles map[string]struct{} -} - -func NewSwaggerLoader() *SwaggerLoader { - return &SwaggerLoader{} -} - -func (swaggerLoader *SwaggerLoader) reset() { - swaggerLoader.visitedFiles = make(map[string]struct{}) -} - -func (swaggerLoader *SwaggerLoader) LoadSwaggerFromURI(location *url.URL) (*Swagger, error) { - swaggerLoader.reset() - return swaggerLoader.loadSwaggerFromURIInternal(location) -} - -func (swaggerLoader *SwaggerLoader) loadSwaggerFromURIInternal(location *url.URL) (*Swagger, error) { - f := swaggerLoader.LoadSwaggerFromURIFunc - if f != nil { - return f(swaggerLoader, location) - } - data, err := readURL(location) - if err != nil { - return nil, err - } - return swaggerLoader.loadSwaggerFromDataWithPathInternal(data, location) -} - -// loadSingleElementFromURI read the data from ref and unmarshal to JSON to the -// passed element. -func (swaggerLoader *SwaggerLoader) loadSingleElementFromURI(ref string, rootPath *url.URL, element json.Unmarshaler) error { - if !swaggerLoader.IsExternalRefsAllowed { - return fmt.Errorf("encountered non-allowed external reference: '%s'", ref) - } - - parsedURL, err := url.Parse(ref) - if err != nil { - return err - } - - if parsedURL.Fragment != "" { - return errors.New("references to files which contain more than one element definition are not supported") - } - - resolvedPath, err := resolvePath(rootPath, parsedURL) - if err != nil { - return fmt.Errorf("could not resolve path: %v", err) - } - - data, err := readURL(resolvedPath) - if err != nil { - return err - } - if err := yaml.Unmarshal(data, element); err != nil { - return err - } - - return nil -} - -func readURL(location *url.URL) ([]byte, error) { - if location.Scheme != "" && location.Host != "" { - resp, err := http.Get(location.String()) - if err != nil { - return nil, err - } - data, err := ioutil.ReadAll(resp.Body) - defer resp.Body.Close() - if err != nil { - return nil, err - } - return data, nil - } - if location.Scheme != "" || location.Host != "" || location.RawQuery != "" { - return nil, fmt.Errorf("Unsupported URI: '%s'", location.String()) - } - data, err := ioutil.ReadFile(location.Path) - if err != nil { - return nil, err - } - return data, nil -} - -func (swaggerLoader *SwaggerLoader) LoadSwaggerFromFile(path string) (*Swagger, error) { - swaggerLoader.reset() - return swaggerLoader.loadSwaggerFromFileInternal(path) -} - -func (swaggerLoader *SwaggerLoader) loadSwaggerFromFileInternal(path string) (*Swagger, error) { - f := swaggerLoader.LoadSwaggerFromURIFunc - if f != nil { - return f(swaggerLoader, &url.URL{ - Path: path, - }) - } - data, err := ioutil.ReadFile(path) - if err != nil { - return nil, err - } - return swaggerLoader.loadSwaggerFromDataWithPathInternal(data, &url.URL{ - Path: path, - }) -} - -func (swaggerLoader *SwaggerLoader) LoadSwaggerFromData(data []byte) (*Swagger, error) { - swaggerLoader.reset() - return swaggerLoader.loadSwaggerFromDataInternal(data) -} - -func (swaggerLoader *SwaggerLoader) loadSwaggerFromDataInternal(data []byte) (*Swagger, error) { - swagger := &Swagger{} - if err := yaml.Unmarshal(data, swagger); err != nil { - return nil, err - } - return swagger, swaggerLoader.ResolveRefsIn(swagger, nil) -} - -// LoadSwaggerFromDataWithPath takes the OpenApi spec data in bytes and a path where the resolver can find referred -// elements and returns a *Swagger with all resolved data or an error if unable to load data or resolve refs. -func (swaggerLoader *SwaggerLoader) LoadSwaggerFromDataWithPath(data []byte, path *url.URL) (*Swagger, error) { - swaggerLoader.reset() - return swaggerLoader.loadSwaggerFromDataWithPathInternal(data, path) -} - -func (swaggerLoader *SwaggerLoader) loadSwaggerFromDataWithPathInternal(data []byte, path *url.URL) (*Swagger, error) { - swagger := &Swagger{} - if err := yaml.Unmarshal(data, swagger); err != nil { - return nil, err - } - return swagger, swaggerLoader.ResolveRefsIn(swagger, path) -} - -func (swaggerLoader *SwaggerLoader) ResolveRefsIn(swagger *Swagger, path *url.URL) (err error) { - swaggerLoader.visited = make(map[interface{}]struct{}) - if swaggerLoader.visitedFiles == nil { - swaggerLoader.visitedFiles = make(map[string]struct{}) - } - - // Visit all components - components := swagger.Components - for _, component := range components.Headers { - if err = swaggerLoader.resolveHeaderRef(swagger, component, path); err != nil { - return - } - } - for _, component := range components.Parameters { - if err = swaggerLoader.resolveParameterRef(swagger, component, path); err != nil { - return - } - } - for _, component := range components.RequestBodies { - if err = swaggerLoader.resolveRequestBodyRef(swagger, component, path); err != nil { - return - } - } - for _, component := range components.Responses { - if err = swaggerLoader.resolveResponseRef(swagger, component, path); err != nil { - return - } - } - for _, component := range components.Schemas { - if err = swaggerLoader.resolveSchemaRef(swagger, component, path); err != nil { - return - } - } - for _, component := range components.SecuritySchemes { - if err = swaggerLoader.resolveSecuritySchemeRef(swagger, component, path); err != nil { - return - } - } - for _, component := range components.Examples { - if err = swaggerLoader.resolveExampleRef(swagger, component, path); err != nil { - return - } - } - - // Visit all operations - for entrypoint, pathItem := range swagger.Paths { - if pathItem == nil { - continue - } - if err = swaggerLoader.resolvePathItemRef(swagger, entrypoint, pathItem, path); err != nil { - return - } - } - - return -} - -func copyURL(basePath *url.URL) (*url.URL, error) { - return url.Parse(basePath.String()) -} - -func join(basePath *url.URL, relativePath *url.URL) (*url.URL, error) { - if basePath == nil { - return relativePath, nil - } - newPath, err := copyURL(basePath) - if err != nil { - return nil, fmt.Errorf("Can't copy path: '%s'", basePath.String()) - } - newPath.Path = path.Join(path.Dir(newPath.Path), relativePath.Path) - return newPath, nil -} - -func resolvePath(basePath *url.URL, componentPath *url.URL) (*url.URL, error) { - if componentPath.Scheme == "" && componentPath.Host == "" { - return join(basePath, componentPath) - } - return componentPath, nil -} - -func isSingleRefElement(ref string) bool { - return !strings.Contains(ref, "#") -} - -func (swaggerLoader *SwaggerLoader) resolveComponent(swagger *Swagger, ref string, path *url.URL) ( - cursor interface{}, - componentPath *url.URL, - err error, -) { - if swagger, ref, componentPath, err = swaggerLoader.resolveRefSwagger(swagger, ref, path); err != nil { - return nil, nil, err - } - - parsedURL, err := url.Parse(ref) - if err != nil { - return nil, nil, fmt.Errorf("Can't parse reference: '%s': %v", ref, parsedURL) - } - fragment := parsedURL.Fragment - if !strings.HasPrefix(fragment, "/") { - err := fmt.Errorf("expected fragment prefix '#/' in URI '%s'", ref) - return nil, nil, err - } - - cursor = swagger - for _, pathPart := range strings.Split(fragment[1:], "/") { - - pathPart = strings.Replace(pathPart, "~1", "/", -1) - pathPart = strings.Replace(pathPart, "~0", "~", -1) - - if cursor, err = drillIntoSwaggerField(cursor, pathPart); err != nil { - return nil, nil, fmt.Errorf("Failed to resolve '%s' in fragment in URI: '%s': %v", ref, pathPart, err.Error()) - } - if cursor == nil { - return nil, nil, failedToResolveRefFragmentPart(ref, pathPart) - } - } - - return cursor, componentPath, nil -} - -func drillIntoSwaggerField(cursor interface{}, fieldName string) (interface{}, error) { - switch val := reflect.Indirect(reflect.ValueOf(cursor)); val.Kind() { - case reflect.Map: - elementValue := val.MapIndex(reflect.ValueOf(fieldName)) - if !elementValue.IsValid() { - return nil, fmt.Errorf("Map key not found: %v", fieldName) - } - return elementValue.Interface(), nil - - case reflect.Slice: - i, err := strconv.ParseUint(fieldName, 10, 32) - if err != nil { - return nil, err - } - index := int(i) - if index >= val.Len() { - return nil, errors.New("slice index out of bounds") - } - return val.Index(index).Interface(), nil - - case reflect.Struct: - for i := 0; i < val.NumField(); i++ { - field := val.Type().Field(i) - tagValue := field.Tag.Get("yaml") - yamlKey := strings.Split(tagValue, ",")[0] - if yamlKey == fieldName { - return val.Field(i).Interface(), nil - } - } - // if cursor if a "ref wrapper" struct (e.g. RequestBodyRef), try digging into its Value field - _, ok := val.Type().FieldByName("Value") - if ok { - return drillIntoSwaggerField(val.FieldByName("Value").Interface(), fieldName) // recurse into .Value - } - // give up - return nil, fmt.Errorf("Struct field not found: %v", fieldName) - - default: - return nil, errors.New("not a map, slice nor struct") - } -} - -func (swaggerLoader *SwaggerLoader) resolveRefSwagger(swagger *Swagger, ref string, path *url.URL) (*Swagger, string, *url.URL, error) { - componentPath := path - if !strings.HasPrefix(ref, "#") { - if !swaggerLoader.IsExternalRefsAllowed { - return nil, "", nil, fmt.Errorf("Encountered non-allowed external reference: '%s'", ref) - } - parsedURL, err := url.Parse(ref) - if err != nil { - return nil, "", nil, fmt.Errorf("Can't parse reference: '%s': %v", ref, parsedURL) - } - fragment := parsedURL.Fragment - parsedURL.Fragment = "" - - resolvedPath, err := resolvePath(path, parsedURL) - if err != nil { - return nil, "", nil, fmt.Errorf("Error while resolving path: %v", err) - } - - if swagger, err = swaggerLoader.loadSwaggerFromURIInternal(resolvedPath); err != nil { - return nil, "", nil, fmt.Errorf("Error while resolving reference '%s': %v", ref, err) - } - ref = fmt.Sprintf("#%s", fragment) - componentPath = resolvedPath - } - return swagger, ref, componentPath, nil -} - -func (swaggerLoader *SwaggerLoader) resolveHeaderRef(swagger *Swagger, component *HeaderRef, path *url.URL) error { - // Prevent infinite recursion - visited := swaggerLoader.visited - if _, isVisited := visited[component]; isVisited { - return nil - } - visited[component] = struct{}{} - - // Resolve ref - const prefix = "#/components/headers/" - if ref := component.Ref; len(ref) > 0 { - if isSingleRefElement(ref) { - var header Header - if err := swaggerLoader.loadSingleElementFromURI(ref, path, &header); err != nil { - return err - } - - component.Value = &header - } else { - untypedResolved, componentPath, err := swaggerLoader.resolveComponent(swagger, ref, path) - if err != nil { - return err - } - resolved, ok := untypedResolved.(*HeaderRef) - if !ok { - return failedToResolveRefFragment(ref) - } - if err := swaggerLoader.resolveHeaderRef(swagger, resolved, componentPath); err != nil { - return err - } - component.Value = resolved.Value - } - } - value := component.Value - if value == nil { - return nil - } - if schema := value.Schema; schema != nil { - if err := swaggerLoader.resolveSchemaRef(swagger, schema, path); err != nil { - return err - } - } - return nil -} - -func (swaggerLoader *SwaggerLoader) resolveParameterRef(swagger *Swagger, component *ParameterRef, documentPath *url.URL) error { - // Prevent infinite recursion - visited := swaggerLoader.visited - if _, isVisited := visited[component]; isVisited { - return nil - } - visited[component] = struct{}{} - - // Resolve ref - const prefix = "#/components/parameters/" - ref := component.Ref - if len(ref) > 0 { - if isSingleRefElement(ref) { - var param Parameter - if err := swaggerLoader.loadSingleElementFromURI(ref, documentPath, ¶m); err != nil { - return err - } - component.Value = ¶m - } else { - untypedResolved, componentPath, err := swaggerLoader.resolveComponent(swagger, ref, documentPath) - if err != nil { - return err - } - resolved, ok := untypedResolved.(*ParameterRef) - if !ok { - return failedToResolveRefFragment(ref) - } - if err := swaggerLoader.resolveParameterRef(swagger, resolved, componentPath); err != nil { - return err - } - component.Value = resolved.Value - } - } - value := component.Value - if value == nil { - return nil - } - - refDocumentPath, err := referencedDocumentPath(documentPath, ref) - if err != nil { - return err - } - - if value.Content != nil && value.Schema != nil { - return errors.New("Cannot contain both schema and content in a parameter") - } - for _, contentType := range value.Content { - if schema := contentType.Schema; schema != nil { - if err := swaggerLoader.resolveSchemaRef(swagger, schema, refDocumentPath); err != nil { - return err - } - } - } - if schema := value.Schema; schema != nil { - if err := swaggerLoader.resolveSchemaRef(swagger, schema, refDocumentPath); err != nil { - return err - } - } - return nil -} - -func (swaggerLoader *SwaggerLoader) resolveRequestBodyRef(swagger *Swagger, component *RequestBodyRef, path *url.URL) error { - // Prevent infinite recursion - visited := swaggerLoader.visited - if _, isVisited := visited[component]; isVisited { - return nil - } - visited[component] = struct{}{} - - // Resolve ref - const prefix = "#/components/requestBodies/" - if ref := component.Ref; len(ref) > 0 { - if isSingleRefElement(ref) { - var requestBody RequestBody - if err := swaggerLoader.loadSingleElementFromURI(ref, path, &requestBody); err != nil { - return err - } - - component.Value = &requestBody - } else { - untypedResolved, componentPath, err := swaggerLoader.resolveComponent(swagger, ref, path) - if err != nil { - return err - } - resolved, ok := untypedResolved.(*RequestBodyRef) - if !ok { - return failedToResolveRefFragment(ref) - } - if err = swaggerLoader.resolveRequestBodyRef(swagger, resolved, componentPath); err != nil { - return err - } - component.Value = resolved.Value - } - } - value := component.Value - if value == nil { - return nil - } - for _, contentType := range value.Content { - for name, example := range contentType.Examples { - if err := swaggerLoader.resolveExampleRef(swagger, example, path); err != nil { - return err - } - contentType.Examples[name] = example - } - if schema := contentType.Schema; schema != nil { - if err := swaggerLoader.resolveSchemaRef(swagger, schema, path); err != nil { - return err - } - } - } - return nil -} - -func (swaggerLoader *SwaggerLoader) resolveResponseRef(swagger *Swagger, component *ResponseRef, documentPath *url.URL) error { - // Prevent infinite recursion - visited := swaggerLoader.visited - if _, isVisited := visited[component]; isVisited { - return nil - } - visited[component] = struct{}{} - - // Resolve ref - ref := component.Ref - const prefix = "#/components/responses/" - if len(ref) > 0 { - - if isSingleRefElement(ref) { - var resp Response - if err := swaggerLoader.loadSingleElementFromURI(ref, documentPath, &resp); err != nil { - return err - } - - component.Value = &resp - } else { - untypedResolved, componentPath, err := swaggerLoader.resolveComponent(swagger, ref, documentPath) - if err != nil { - return err - } - resolved, ok := untypedResolved.(*ResponseRef) - if !ok { - return failedToResolveRefFragment(ref) - } - if err := swaggerLoader.resolveResponseRef(swagger, resolved, componentPath); err != nil { - return err - } - component.Value = resolved.Value - } - } - refDocumentPath, err := referencedDocumentPath(documentPath, ref) - if err != nil { - return err - } - - value := component.Value - if value == nil { - return nil - } - for _, header := range value.Headers { - if err := swaggerLoader.resolveHeaderRef(swagger, header, refDocumentPath); err != nil { - return err - } - } - for _, contentType := range value.Content { - if contentType == nil { - continue - } - for name, example := range contentType.Examples { - if err := swaggerLoader.resolveExampleRef(swagger, example, refDocumentPath); err != nil { - return err - } - contentType.Examples[name] = example - } - if schema := contentType.Schema; schema != nil { - if err := swaggerLoader.resolveSchemaRef(swagger, schema, refDocumentPath); err != nil { - return err - } - contentType.Schema = schema - } - } - for _, link := range value.Links { - if err := swaggerLoader.resolveLinkRef(swagger, link, refDocumentPath); err != nil { - return err - } - } - return nil -} - -func (swaggerLoader *SwaggerLoader) resolveSchemaRef(swagger *Swagger, component *SchemaRef, documentPath *url.URL) error { - // Prevent infinite recursion - visited := swaggerLoader.visited - if _, isVisited := visited[component]; isVisited { - return nil - } - visited[component] = struct{}{} - - // Resolve ref - const prefix = "#/components/schemas/" - ref := component.Ref - if len(ref) > 0 { - if isSingleRefElement(ref) { - var schema Schema - if err := swaggerLoader.loadSingleElementFromURI(ref, documentPath, &schema); err != nil { - return err - } - component.Value = &schema - } else { - untypedResolved, componentPath, err := swaggerLoader.resolveComponent(swagger, ref, documentPath) - if err != nil { - return err - } - - resolved, ok := untypedResolved.(*SchemaRef) - if !ok { - return failedToResolveRefFragment(ref) - } - if err := swaggerLoader.resolveSchemaRef(swagger, resolved, componentPath); err != nil { - return err - } - component.Value = resolved.Value - } - } - - refDocumentPath, err := referencedDocumentPath(documentPath, ref) - if err != nil { - return err - } - - value := component.Value - if value == nil { - return nil - } - - // ResolveRefs referred schemas - if v := value.Items; v != nil { - if err := swaggerLoader.resolveSchemaRef(swagger, v, refDocumentPath); err != nil { - return err - } - } - for _, v := range value.Properties { - if err := swaggerLoader.resolveSchemaRef(swagger, v, refDocumentPath); err != nil { - return err - } - } - if v := value.AdditionalProperties; v != nil { - if err := swaggerLoader.resolveSchemaRef(swagger, v, refDocumentPath); err != nil { - return err - } - } - if v := value.Not; v != nil { - if err := swaggerLoader.resolveSchemaRef(swagger, v, refDocumentPath); err != nil { - return err - } - } - for _, v := range value.AllOf { - if err := swaggerLoader.resolveSchemaRef(swagger, v, refDocumentPath); err != nil { - return err - } - } - for _, v := range value.AnyOf { - if err := swaggerLoader.resolveSchemaRef(swagger, v, refDocumentPath); err != nil { - return err - } - } - for _, v := range value.OneOf { - if err := swaggerLoader.resolveSchemaRef(swagger, v, refDocumentPath); err != nil { - return err - } - } - - return nil -} - -func (swaggerLoader *SwaggerLoader) resolveSecuritySchemeRef(swagger *Swagger, component *SecuritySchemeRef, path *url.URL) error { - // Prevent infinite recursion - visited := swaggerLoader.visited - if _, isVisited := visited[component]; isVisited { - return nil - } - visited[component] = struct{}{} - - // Resolve ref - const prefix = "#/components/securitySchemes/" - if ref := component.Ref; len(ref) > 0 { - if isSingleRefElement(ref) { - var scheme SecurityScheme - if err := swaggerLoader.loadSingleElementFromURI(ref, path, &scheme); err != nil { - return err - } - - component.Value = &scheme - } else { - untypedResolved, componentPath, err := swaggerLoader.resolveComponent(swagger, ref, path) - if err != nil { - return err - } - resolved, ok := untypedResolved.(*SecuritySchemeRef) - if !ok { - return failedToResolveRefFragment(ref) - } - if err := swaggerLoader.resolveSecuritySchemeRef(swagger, resolved, componentPath); err != nil { - return err - } - component.Value = resolved.Value - } - } - return nil -} - -func (swaggerLoader *SwaggerLoader) resolveExampleRef(swagger *Swagger, component *ExampleRef, path *url.URL) error { - // Prevent infinite recursion - visited := swaggerLoader.visited - if _, isVisited := visited[component]; isVisited { - return nil - } - visited[component] = struct{}{} - - const prefix = "#/components/examples/" - if ref := component.Ref; len(ref) > 0 { - if isSingleRefElement(ref) { - var example Example - if err := swaggerLoader.loadSingleElementFromURI(ref, path, &example); err != nil { - return err - } - - component.Value = &example - } else { - untypedResolved, componentPath, err := swaggerLoader.resolveComponent(swagger, ref, path) - if err != nil { - return err - } - resolved, ok := untypedResolved.(*ExampleRef) - if !ok { - return failedToResolveRefFragment(ref) - } - if err := swaggerLoader.resolveExampleRef(swagger, resolved, componentPath); err != nil { - return err - } - component.Value = resolved.Value - } - } - return nil -} - -func (swaggerLoader *SwaggerLoader) resolveLinkRef(swagger *Swagger, component *LinkRef, path *url.URL) error { - // Prevent infinite recursion - visited := swaggerLoader.visited - if _, isVisited := visited[component]; isVisited { - return nil - } - visited[component] = struct{}{} - - const prefix = "#/components/links/" - if ref := component.Ref; len(ref) > 0 { - if isSingleRefElement(ref) { - var link Link - if err := swaggerLoader.loadSingleElementFromURI(ref, path, &link); err != nil { - return err - } - - component.Value = &link - } else { - untypedResolved, componentPath, err := swaggerLoader.resolveComponent(swagger, ref, path) - if err != nil { - return err - } - resolved, ok := untypedResolved.(*LinkRef) - if !ok { - return failedToResolveRefFragment(ref) - } - if err := swaggerLoader.resolveLinkRef(swagger, resolved, componentPath); err != nil { - return err - } - component.Value = resolved.Value - } - } - return nil -} - -func (swaggerLoader *SwaggerLoader) resolvePathItemRef(swagger *Swagger, entrypoint string, pathItem *PathItem, documentPath *url.URL) (err error) { - // Prevent infinite recursion - visited := swaggerLoader.visitedFiles - key := "_" - if documentPath != nil { - key = documentPath.EscapedPath() - } - key += entrypoint - if _, isVisited := visited[key]; isVisited { - return nil - } - visited[key] = struct{}{} - - ref := pathItem.Ref - if ref != "" { - if isSingleRefElement(ref) { - var p PathItem - if err := swaggerLoader.loadSingleElementFromURI(ref, documentPath, &p); err != nil { - return err - } - *pathItem = p - } else { - if swagger, ref, documentPath, err = swaggerLoader.resolveRefSwagger(swagger, ref, documentPath); err != nil { - return - } - - prefix := "#/paths/" - if !strings.HasPrefix(ref, prefix) { - err = fmt.Errorf("expected prefix '%s' in URI '%s'", prefix, ref) - return - } - id := unescapeRefString(ref[len(prefix):]) - - definitions := swagger.Paths - if definitions == nil { - return failedToResolveRefFragmentPart(ref, "paths") - } - resolved := definitions[id] - if resolved == nil { - return failedToResolveRefFragmentPart(ref, id) - } - - *pathItem = *resolved - } - } - - refDocumentPath, err := referencedDocumentPath(documentPath, ref) - if err != nil { - return err - } - - for _, parameter := range pathItem.Parameters { - if err = swaggerLoader.resolveParameterRef(swagger, parameter, refDocumentPath); err != nil { - return - } - } - for _, operation := range pathItem.Operations() { - for _, parameter := range operation.Parameters { - if err = swaggerLoader.resolveParameterRef(swagger, parameter, refDocumentPath); err != nil { - return - } - } - if requestBody := operation.RequestBody; requestBody != nil { - if err = swaggerLoader.resolveRequestBodyRef(swagger, requestBody, refDocumentPath); err != nil { - return - } - } - for _, response := range operation.Responses { - if err = swaggerLoader.resolveResponseRef(swagger, response, refDocumentPath); err != nil { - return - } - } - } - - return nil -} - -func unescapeRefString(ref string) string { - return strings.Replace(strings.Replace(ref, "~1", "/", -1), "~0", "~", -1) -} - -func referencedDocumentPath(documentPath *url.URL, ref string) (*url.URL, error) { - newDocumentPath := documentPath - if documentPath != nil { - refDirectory, err := url.Parse(path.Dir(ref)) - if err != nil { - return nil, err - } - joinedDirectory := path.Join(path.Dir(documentPath.String()), refDirectory.String()) - if newDocumentPath, err = url.Parse(joinedDirectory + "/"); err != nil { - return nil, err - } - } - return newDocumentPath, nil -} diff --git a/openapi3/tag.go b/openapi3/tag.go index d5de72d59..93009a13c 100644 --- a/openapi3/tag.go +++ b/openapi3/tag.go @@ -1,5 +1,11 @@ package openapi3 +import ( + "context" + "encoding/json" + "fmt" +) + // Tags is specified by OpenAPI/Swagger 3.0 standard. type Tags []*Tag @@ -12,9 +18,70 @@ func (tags Tags) Get(name string) *Tag { return nil } +// Validate returns an error if Tags does not comply with the OpenAPI spec. +func (tags Tags) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + for _, v := range tags { + if err := v.Validate(ctx); err != nil { + return err + } + } + return nil +} + // Tag is specified by OpenAPI/Swagger 3.0 standard. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#tag-object type Tag struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` } + +// MarshalJSON returns the JSON encoding of Tag. +func (t Tag) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 3+len(t.Extensions)) + for k, v := range t.Extensions { + m[k] = v + } + if x := t.Name; x != "" { + m["name"] = x + } + if x := t.Description; x != "" { + m["description"] = x + } + if x := t.ExternalDocs; x != nil { + m["externalDocs"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets Tag to a copy of data. +func (t *Tag) UnmarshalJSON(data []byte) error { + type TagBis Tag + var x TagBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "name") + delete(x.Extensions, "description") + delete(x.Extensions, "externalDocs") + *t = Tag(x) + return nil +} + +// Validate returns an error if Tag does not comply with the OpenAPI spec. +func (t *Tag) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + if v := t.ExternalDocs; v != nil { + if err := v.Validate(ctx); err != nil { + return fmt.Errorf("invalid external docs: %w", err) + } + } + + return validateExtensions(ctx, t.Extensions) +} diff --git a/openapi3/testdata/303bis/common/properties.yaml b/openapi3/testdata/303bis/common/properties.yaml new file mode 100644 index 000000000..e5b6cdb46 --- /dev/null +++ b/openapi3/testdata/303bis/common/properties.yaml @@ -0,0 +1,16 @@ +timestamp: + type: string + description: Date and time in ISO 8601 format. + example: "2020-04-09T18:14:30Z" + readOnly: true + nullable: true + +timestamps: + type: object + properties: + created_at: + $ref: "#/timestamp" + deleted_at: + $ref: "#/timestamp" + updated_at: + $ref: "#/timestamp" diff --git a/openapi3/testdata/303bis/service.yaml b/openapi3/testdata/303bis/service.yaml new file mode 100644 index 000000000..39dd06639 --- /dev/null +++ b/openapi3/testdata/303bis/service.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.0 +info: + title: 'some service spec' + version: 1.2.3 + +paths: + /service: + get: + tags: + - services/service + summary: List services + description: List services. + operationId: list-services + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/model_service" + +components: + schemas: + model_service: + allOf: + - $ref: "common/properties.yaml#/timestamps" diff --git a/openapi3/testdata/Test_param_override.yml b/openapi3/testdata/Test_param_override.yml new file mode 100644 index 000000000..d6982414a --- /dev/null +++ b/openapi3/testdata/Test_param_override.yml @@ -0,0 +1,40 @@ +openapi: 3.0.0 +info: + title: customer + version: '1.0' +servers: + - url: 'httpbin.kwaf-demo.test' +paths: + '/customers/{customer_id}': + parameters: + - schema: + type: integer + name: customer_id + in: path + required: true + get: + parameters: + - schema: + type: integer + maximum: 100 + name: customer_id + in: path + required: true + summary: customer + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + customer_id: + type: integer + customer_name: + type: string + operationId: get-customers-customer_id + description: Retrieve a specific customer by ID +components: + schemas: {} diff --git a/openapi3/testdata/callback-transactioned.yml b/openapi3/testdata/callback-transactioned.yml new file mode 100644 index 000000000..2d58b394b --- /dev/null +++ b/openapi3/testdata/callback-transactioned.yml @@ -0,0 +1,10 @@ +post: + requestBody: + description: Callback payload + content: + 'application/json': + schema: + $ref: 'callbacks.yml#/components/schemas/SomePayload' + responses: + '200': + description: callback successfully processed diff --git a/openapi3/testdata/callbacks.yml b/openapi3/testdata/callbacks.yml new file mode 100644 index 000000000..4ad3f7d73 --- /dev/null +++ b/openapi3/testdata/callbacks.yml @@ -0,0 +1,71 @@ +openapi: 3.1.0 +info: + title: Callback refd + version: 1.2.3 +paths: + /trans: + post: + description: '' + requestBody: + description: '' + content: + 'application/json': + schema: + properties: + id: {type: string} + email: {format: email} + responses: + '201': + description: subscription successfully created + content: + application/json: + schema: + type: object + callbacks: + transactionCallback: + 'http://notificationServer.com?transactionId={$request.body#/id}&email={$request.body#/email}': + $ref: callback-transactioned.yml + + /other: + post: + description: '' + parameters: + - name: queryUrl + in: query + required: true + description: | + bla + bla + bla + schema: + type: string + format: uri + example: https://example.com + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + callbacks: + myEvent: + $ref: '#/components/callbacks/MyCallbackEvent' + +components: + schemas: + SomePayload: {type: object} + SomeOtherPayload: {type: boolean} + callbacks: + MyCallbackEvent: + '{$request.query.queryUrl}': + post: + requestBody: + description: Callback payload + content: + 'application/json': + schema: + $ref: '#/components/schemas/SomeOtherPayload' + responses: + '200': + description: callback successfully processed diff --git a/openapi3/testdata/callbacks.yml.internalized.yml b/openapi3/testdata/callbacks.yml.internalized.yml new file mode 100644 index 000000000..866cb5ca4 --- /dev/null +++ b/openapi3/testdata/callbacks.yml.internalized.yml @@ -0,0 +1,131 @@ +{ + "components": { + "callbacks": { + "MyCallbackEvent": { + "{$request.query.queryUrl}": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SomeOtherPayload" + } + } + }, + "description": "Callback payload" + }, + "responses": { + "200": { + "description": "callback successfully processed" + } + } + } + } + } + }, + "schemas": { + "SomeOtherPayload": { + "type": "boolean" + }, + "SomePayload": { + "type": "object" + } + } + }, + "info": { + "title": "Callback refd", + "version": "1.2.3" + }, + "openapi": "3.1.0", + "paths": { + "/other": { + "post": { + "callbacks": { + "myEvent": { + "$ref": "#/components/callbacks/MyCallbackEvent" + } + }, + "parameters": [ + { + "description": "bla\nbla\nbla\n", + "in": "query", + "name": "queryUrl", + "required": true, + "schema": { + "example": "https://example.com", + "format": "uri", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "" + } + } + } + }, + "/trans": { + "post": { + "callbacks": { + "transactionCallback": { + "http://notificationServer.com?transactionId={$request.body#/id}&email={$request.body#/email}": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SomePayload" + } + } + }, + "description": "Callback payload" + }, + "responses": { + "200": { + "description": "callback successfully processed" + } + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "format": "email" + }, + "id": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "subscription successfully created" + } + } + } + } + } +} diff --git a/openapi3/testdata/circularRef/base.yml b/openapi3/testdata/circularRef/base.yml new file mode 100644 index 000000000..ff8240eb0 --- /dev/null +++ b/openapi3/testdata/circularRef/base.yml @@ -0,0 +1,16 @@ +openapi: "3.0.3" +info: + title: Recursive cyclic refs example + version: "1.0" +components: + schemas: + Foo: + properties: + foo2: + $ref: "other.yml#/components/schemas/Foo2" + bar: + $ref: "#/components/schemas/Bar" + Bar: + properties: + foo: + $ref: "#/components/schemas/Foo" diff --git a/openapi3/testdata/circularRef/other.yml b/openapi3/testdata/circularRef/other.yml new file mode 100644 index 000000000..29b72d98c --- /dev/null +++ b/openapi3/testdata/circularRef/other.yml @@ -0,0 +1,10 @@ +openapi: "3.0.3" +info: + title: Recursive cyclic refs example + version: "1.0" +components: + schemas: + Foo2: + properties: + id: + type: string diff --git a/openapi3/testdata/ext.json b/openapi3/testdata/ext.json new file mode 100644 index 000000000..df227e62e --- /dev/null +++ b/openapi3/testdata/ext.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "a": { + "type": "string" + }, + "b": { + "type": "object", + "description": "I use a local reference.", + "properties": { + "name": { + "$ref": "#/definitions/a" + } + } + } + } +} diff --git a/openapi3/testdata/issue235.spec0-typo.yml b/openapi3/testdata/issue235.spec0-typo.yml new file mode 100644 index 000000000..543600620 --- /dev/null +++ b/openapi3/testdata/issue235.spec0-typo.yml @@ -0,0 +1,24 @@ +openapi: 3.0.0 +info: + title: 'OAI Specification in YAML' + version: 0.0.1 +paths: + /test: + get: + responses: + "200": + $ref: '#/components/responses/GetTestOK' +components: + responses: + GetTestOK: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectA' + schemas: + ObjectA: + type: object + properties: + object_b: + $ref: 'issue235.spec0-typo.yml#/components/schemas/ObjectD' diff --git a/openapi3/testdata/issue235.spec0.yml b/openapi3/testdata/issue235.spec0.yml new file mode 100644 index 000000000..d9236aaec --- /dev/null +++ b/openapi3/testdata/issue235.spec0.yml @@ -0,0 +1,24 @@ +openapi: 3.0.0 +info: + title: 'OAI Specification in YAML' + version: 0.0.1 +paths: + /test: + get: + responses: + "200": + $ref: '#/components/responses/GetTestOK' +components: + responses: + GetTestOK: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectA' + schemas: + ObjectA: + type: object + properties: + object_b: + $ref: 'issue235.spec1.yml#/components/schemas/ObjectD' diff --git a/openapi3/testdata/issue235.spec1.yml b/openapi3/testdata/issue235.spec1.yml new file mode 100644 index 000000000..a1bc67906 --- /dev/null +++ b/openapi3/testdata/issue235.spec1.yml @@ -0,0 +1,12 @@ +components: + schemas: + ObjectD: + type: object + properties: + result: + $ref: '#/components/schemas/ObjectE' + + ObjectE: + properties: + name: + $ref: issue235.spec2.yml#/components/schemas/ObjectX diff --git a/openapi3/testdata/issue235.spec2.yml b/openapi3/testdata/issue235.spec2.yml new file mode 100644 index 000000000..b0bcb0fa2 --- /dev/null +++ b/openapi3/testdata/issue235.spec2.yml @@ -0,0 +1,7 @@ +components: + schemas: + ObjectX: + type: object + properties: + name: + type: string diff --git a/openapi3/testdata/issue241.yml b/openapi3/testdata/issue241.yml new file mode 100644 index 000000000..07609c1d8 --- /dev/null +++ b/openapi3/testdata/issue241.yml @@ -0,0 +1,15 @@ +openapi: 3.0.3 +components: + schemas: + FooBar: + type: object + properties: + type_url: + type: string + value: + type: string + format: byte +info: + title: sample + version: version not set +paths: {} diff --git a/openapi3/testdata/issue409.yml b/openapi3/testdata/issue409.yml new file mode 100644 index 000000000..88394904e --- /dev/null +++ b/openapi3/testdata/issue409.yml @@ -0,0 +1,21 @@ +openapi: 3.0.3 +info: + description: Contains Patterns that can't be compiled by the go regexp engine + title: Issue 409 + version: 0.0.1 +paths: + /v1/apis/{apiID}: + get: + description: Get a list of all Apis and there versions for a given workspace + operationId: getApisV1 + parameters: + - description: The ID of the API + in: path + name: apiID + required: true + schema: + type: string + pattern: ^[a-zA-Z0-9]{0,4096}$ + responses: + "200": + description: OK diff --git a/openapi3/testdata/issue570.json b/openapi3/testdata/issue570.json new file mode 100644 index 000000000..ed3a7509b --- /dev/null +++ b/openapi3/testdata/issue570.json @@ -0,0 +1,155 @@ +{ + "swagger": "2.0", + "info": { + "version": "internal", + "title": "Rubrik INTERNAL REST API", + "description": "Copyright © 2017-2021 Rubrik Inc.\n\n# Introduction\n\nThis is the INTERNAL REST API for Rubrik. We don't guarantee support or backward compatibility. Use at your own risk.\n\n# Changelog\n\n Revisions are listed with the most recent revision first.\n ### Changes to Internal API in Rubrik version 6.0\n ## Breaking changes:\n * Renamed field `node` to `nodeId` for object `NetworkInterface` used by\n `GET /cluster/{id}/network_interface`.\n * Removed `compliance24HourStatus` in `DataSourceTableRequest` for\n `POST /report/data_source/table`.\n Use `complianceStatus`, `awaitingFirstFull`, and `snapshotRange`\n as replacements.\n * Changed the sort_by attribute of `GET /vcd/vapp` to use\n `VcdVappObjectSortAttribute`.\n This attribute no longer uses the `VappCount` or `ConnectionStatus`\n parameters from the previously used `VcdHierarchyObjectSortAttribute`.\n\n ## Feature additions/improvements:\n * Added the `GET /sla_domain/{id}/protected_objects` endpoint to return\n objects explicitly protected by the SLA Domain with direct assignments.\n * Added new field `nodeName` for object `NetworkInterface` used by\n `GET /cluster/{id}/network_interface`.\n * Added the `POST /cluster/{id}/remove_nodes` endpoint to trigger a bulk\n node removal job.\n * Added new optional field `numChannels` to `ExportOracleDbConfig` object\n specifying the number of channels used during Oracle clone or same-host\n recovery.\n * Added new optional fields `forceFull` to the object\n `HypervVirtualMachineSummary` used by `GET /hyperv/vm`. This field is also\n used in `HypervVirtualMachineDetail` used by `GET /hyperv/vm/{id}` and\n `PATCH /hyperv/vm/{id}`.\n * Added the `GET /cluster/{id}/bootstrap_config` endpoint to enable Rubrik CDM\n to retrieve Rubrik cluster configuration information for the cluster nodes.\n * Added new optional field clusterUuid to the ClusterConfig object used\n by `POST /cluster/{id}/bootstrap` and `POST /cluster/{id}/setupnetwork`.\n * Added new optional fields `dataGuardGroupId` and `dataGuardGroupName` to\n the object `OracleHierarchyObjectSummary` used by\n `GET /oracle/hierarchy/{id}`, `GET /oracle/hierarchy/{id}/children`, and\n `GET /oracle/hierarchy/{id}/descendants`.\n * Added new optional fields `dataGuardGroupId` and `dataGuardGroupName` to\n the object `OracleDbSummary` used by `GET /oracle/db`.\n * Added new optional fields `dataGuardGroupId` and `dataGuardGroupName` to\n the object `OracleDbDetail` used by `GET /oracle/db/{id}` and\n `PATCH /oracle/db/{id}`.\n * Added a new optional field `immutabilityLockSummary` to the object\n `ArchivalLocationSummary` returned by GET `/archive/location` and\n GET `/organization/{id}/archive/location`\n * Added new optional fields `dbUniqueName` and `databaseRole` to the object\n `OracleHierarchyObjectSummary` used by `GET /oracle/hierarchy/{id}`,\n `GET /oracle/hierarchy/{id}/children`, and\n `GET /oracle/hierarchy/{id}/descendants`.\n * Added new required fields `dbUniqueName` and `databaseRole` to the object\n `OracleDbSummary` used by `GET /oracle/db`.\n * Added a new required field `databaseRole` to the object `OracleDbDetail`\n used by `GET /oracle/db/{id}` and `PATCH /oracle/db/{id}`.\n * Added a new optional field `subnet` to `ManagedVolumeUpdate`, used by \n `PATCH /managed_volume/{id}` for updating the subnet to which the node IPs\n will belong during an SLA MV backup.\n * Added new optional field `numChannels` to `RecoverOracleDbConfig`\n and `MountOracleDbConfig` objects specifying the number of channels used\n during Oracle recovery.\n * Added a new optional field `immutabilityLockSummary` to the object\n `ObjectStoreLocationSummary` and `ObjectStoreUpdateDefinition` used by\n `GET/POST /archive/object_store` and `GET/POST /archive/object_store/{id}`\n * Added a new optional field `errorMessage` to `SupportTunnelInfo` object \n used by `GET /node/{id}/support_tunnel` and\n `PATCH /node/{id}/support_tunnel`.\n * Added new optional field `cloudStorageLocation` to the `ClusterConfig`\n object used by `POST /cluster/{id}/bootstrap`.\n * Added new enum `Disabled` to `DataLocationOwnershipStatus`\n used by `ArchivalLocationSummary`\n * Added a new optional field `installTarball` to the `ClusterConfig`\n object used by `POST /cluster/{id}/bootstrap`.\n * Added a new optional field `clusterInstall` to the `ClusterConfigStatus`\n object used by `GET /cluster/{id}/bootstrap`.\n * Added the `GET /cluster/{id}/install` endpoint to return the current\n status of Rubrik CDM install on a cluster.\n * Added the `POST /cluster/{id}/install` endpoint to allow Rubrik CDM \n install on cluster nodes which are not bootstrapped.\n * Added the `GET /cluster/{id}/packages` endpoint to return the list of\n Rubrik CDM packages available for installation.\n * Updated `request_id` parameter in the `GET /cluster/{id}/bootstrap` \n endpoint, as not required.\n * Updated `request_id` parameter in the `GET /cluster/{id}/install` \n endpoint, as not required.\n * Updated `BootstrappableNodeInfo` returned by `GET /cluster/{id}/discover`\n endpoint to include the `version` field, to indicate the\n Rubrik CDM software version.\n * Added a new optional field `isSetupNetworkOnly` to the `ClusterConfig`\n object used by `POST /cluster/{id}/setupnetwork`.\n * Added the `POST /cluster/{id}/setupnetwork` endpoint to enable Rubrik CDM\n to perform network setup on nodes that are not bootstrapped.\n * Added the `GET /cluster/{id}/setupnetwork` endpoint to return the current\n status of setup network command on node or nodes.\n * Added a new optional field `hostname` to the `NodeStatus` object used by\n `GET /cluster/{id}/node`, `GET /node`, `GET /node/stats`, `GET /node/{id}`,\n and `GET /node/{id}/stats`.\n * Added new optional fields `usedFastVhdx` and `fileSizeInBytes` to the\n `HypervVirtualMachineSnapshotSummary` returned by the API\n `GET /hyperv/vm/{id}/snapshot`.\n * Added the `GET /archive/location/request/{id}` endpoint to query the status\n of asynchronous archival location requests.\n\n ## Deprecation:\n * Deprecated the following Oracle endpoints\n * `GET /oracle/db`\n * `GET /oracle/db/{id}`\n * `PATCH /oracle/db/{id}`\n * Deprecated the following vcd hierarchy endpoints. \n * `GET /vcd/hierarchy/{id}`\n * `GET /vcd/hierarchy/{id}/children`\n * `GET /vcd/hierarchy/{id}/descendants`\n * Deprecated the following vcd cluster endpoints.\n * `GET /vcd/cluster`\n * `POST /vcd/cluster`\n * `GET /vcd/cluster/{id}/vimserver`\n * `POST /vcd/cluster/{id}/refresh`\n * `GET /vcd/cluster/{id}`\n * `PATCH /vcd/cluster/{id}`\n * `DELETE /vcd/cluster/{id}`\n * `GET /vcd/cluster/request/{id}`\n * Deprecated the following vcd vapp endpoints.\n * `GET /vcd/vapp`\n * `GET /vcd/vapp/{id}`\n * `PATCH /vcd/vapp/{id}`\n * `GET /vcd/vapp/{id}/snapshot`\n * `POST /vcd/vapp/{id}/snapshot`\n * `DELETE /vcd/vapp/{id}/snapshot`\n * `GET/vcd/vapp/snapshot/{id}`\n * `DELETE /vcd/vapp/snapshot/{id}`\n * `GET /vcd/vapp`\n * `GET /vcd/vapp/{id}/missed_snapshot`\n * `GET /vcd/vapp/snapshot/{snapshot_id}/export/options`\n * `POST /vcd/vapp/snapshot/{snapshot_id}/export`\n * `POST /vcd/vapp/snapshot/{snapshot_id}/instant_recover`\n * `GET /vcd/vapp/snapshot/{snapshot_id}/instant_recover/options`\n * `GET /vcd/vapp/request/{id}`\n * `GET /vcd/vapp/{id}/search`\n * `POST /vcd/vapp/snapshot/{id}/download`\n\n ### Changes to Internal API in Rubrik version 5.3.2\n ## Deprecation:\n * Deprecated `compliance24HourStatus` in `DataSourceTableRequest` for\n `POST /report/data_source/table`.\n Use `complianceStatus`, `awaitingFirstFull`, and `snapshotRange`\n as replacements.\n\n ### Changes to Internal API in Rubrik version 5.3.1\n ## Breaking changes:\n * Added new required field `isPwdEncryptionSupported` to\n the API response `PlatformInfo` for password-based encryption at rest\n in the API `GET /cluster/{id}/platforminfo`.\n\n ## Feature additions/improvements:\n * Added new field `hostsInfo` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}/children`.\n * Added new field `hostsInfo` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}/descendants`.\n * Added new field `hostsInfo` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}`.\n * Added `shouldKeepConvertedDisksOnFailure` as an optional field in\n CreateCloudInstanceRequest definition used in the on-demand API\n conversion API `/cloud_on/aws/instance` and `/cloud_on/azure/instance`.\n This will enable converted disks to be kept on failure for CloudOn\n conversion.\n * Added the `hostsInfo` field to the OracleDbDetail that the\n `GET /oracle/db/{id}` and `PATCH /oracle/db/{id}` endpoints return.\n * Added new optional field `isOnNetAppSnapMirrorDestVolume` to\n HostShareParameters to support backup of NetApp SnapMirror\n destination volume.\n * Added new optional fields `encryptionPassword` and\n `newEncryptionPassword` to the KeyRotationOptions to support\n key rotation for password-based encryption at rest in\n internal API `POST /cluster/{id}/security/key_rotation`.\n * Added `Index` to `ReportableTaskType`.\n * Added new optional field `totpStatus` in `UserDetail` for\n showing the TOTP status of the user with the endpoint\n `GET /internal/user/{id}`\n * Added new optional field `isTotpEnforced` in `UserDefinition` for\n configuring the TOTP enforcement for the user with the endpoint\n `POST /internal/user`\n * Added new optional field `isTotpEnforced` in `UserUpdateInfo` for\n configuring the TOTP enforcement for the user with the endpoint\n `PATCH /internal/user/{id}`\n * Added a new field `HypervVirtualDiskInfo` to HypervVirtualMachineDetail \n used by `GET /hyperv/vm/{id}`.\n * Added a new field `virtualDiskIdsExcludedFromSnapshot` to \n HypervVirtualMachineUpdate used by `PATCH /hyperv/vm/{id}`.\n\n ### Changes to Internal API in Rubrik version 5.3.0\n ## Deprecation:\n * Deprecated `GET /authorization/role/admin`,\n `GET /authorization/role/compliance_officer`,\n `GET /authorization/role/end_user`,\n `GET /authorization/role/infra_admin`,\n `GET /authorization/role/managed_volume_admin`,\n `GET /authorization/role/managed_volume_user`,\n `GET /authorization/role/org_admin`,\n `GET /authorization/role/organization`,\n `GET /authorization/role/read_only_admin` endpoints. Use the new\n v1 endpoints for role management.\n * Deprecated `SnapshotCloudStorageTier` enum value Cold. It will be left,\n but will be mapped internally to the new value, AzureArchive, which is\n recommended as a replacement.\n * Deprecated the `GET /snapshot/{id}/storage/stats` endpoint. Use the v1\n version when possible.\n * Deprecated `POST /hierarchy/bulk_sla_conflicts`. It is migrated to\n v1 and using that is recommended.\n * Deprecated `GET /mssql/availability_group`,\n `GET /mssql/availability_group/{id}`,\n `PATCH /mssql/availability_group/{id}`, `PATCH /mssql/db/bulk`,\n `POST /mssql/db/bulk/snapshot`, `GET /mssql/db/bulk/snapshot/{id}`,\n `GET /mssql/db/count`, `DELETE /mssql/db/{id}/recoverable_range/download`,\n `GET /mssql/db/{id}/compatible_instance`, `GET /mssql/instance/count`,\n `GET /mssql/db/{id}/restore_estimate`, `GET /mssql/db/{id}/restore_files`,\n `GET /mssql/db/{id}/snappable_id`, `GET /mssql/db/defaults`,\n `PATCH /mssql/db/defaults` and `GET /mssql/db/recoverable_range/download/{id}`\n endpoints. Use the v1 version when possible.\n ## Breaking changes:\n * Added new Boolean field `isLinkLocalIpv4Mode` to `AddNodesConfig` and\n `ReplaceNodeConfig`.\n * Changed the type for ReplicationSnapshotLag, which is used by /report/{id} GET\n and PATCH endpoints from integer to string.\n * Added new required field `objectStore` to DataSourceDownloadConfig used by\n `POST /report/data_source/download`.\n * Removed the `storageClass` field from the DataSourceDownloadConfig object used\n by the `POST /report/data_source/download` endpoint. The value was not used.\n * Removed endpoint `GET /mfa/rsa/server` and moved it to v1.\n * Removed endpoint `POST /mfa/rsa/server` and moved it to v1.\n * Removed endpoint `GET /mfa/rsa/server/{id}` and moved it to v1.\n * Removed endpoint `PATCH /mfa/rsa/server/{id}` and moved it to v1.\n * Removed endpoint `DELETE /mfa/rsa/server/{id}` and moved it to v1.\n * Removed endpoint `PUT /cluster/{id}/security/web_signed_cert`\n and moved it to v1.\n * Removed endpoint `DELETE /cluster/{id}/security/web_signed_cert`\n and moved it to v1\n * Removed endpoint `PUT /cluster/{id}/security/kmip/client` and added it\n to v1.\n * Removed endpoint `GET /cluster/{id}/security/kmip/client` and added it\n to v1.\n * Removed endpoint `GET /cluster/{id}/security/kmip/server` and added it\n to v1.\n * Removed endpoint `PUT /cluster/{id}/security/kmip/server` and added it\n to v1.\n * Removed endpoint `DELETE /cluster/{id}/security/kmip/server` and added\n it to v1.\n * Removed endpoint `POST /replication/global_pause`. To toggle replication\n pause between enabled and disabled, use\n `POST /v1/replication/location_pause/disable` and\n `POST /v1/replication/location_pause/enable` instead.\n * Removed `GET /replication/global_pause`. To retrieve replication pause\n status, use `GET /internal/replication/source` and\n `GET /internal/replication/source/{id}` instead.\n * Removed `GET /node_management/{id}/fetch_package` since it was never used.\n * Removed `GET /node_management/{id}/upgrade` since it was never used.\n * Removed `POST /node_management/{id}/fetch_package` since it was never used.\n * Removed `POST /node_management/{id}/upgrade` since it was never used.\n\n ## Feature additions/improvements:\n * Added new optional field `pubKey` to the GlobalManagerConnectionUpdate\n object and the GlobalManagerConnectionInfo object used by\n `GET /cluster/{id}/global_manager` and `PUT /cluster/{id}/global_manager`.\n * Added a new optional field `storageClass` to the `ArchivalLocationSummary`\n type.\n * Added optional field `StartMethod` to the following components: \n ChartSummary, TableSummary, ReportTableRequest, FilterSummary and\n RequestFilters.\n * Added new enum field `StackedReplicationComplianceCountByStatus` to the\n measure property in ChartSummary.\n * Added new enum fields `ReplicationInComplianceCount`,\n `ReplicationNonComplianceCount` to the following properties:\n measure property in ChartSummary, column property in TableSummary,\n and sortBy property in ReportTableRequest.\n * Added the endpoint `GET /vmware/config/datastore_freespace_threshold` to\n query the VMware datastore freespace threshold config.\n * Added the endpoint `PATCH /vmware/config/set_datastore_freespace_threshold`\n to update the VMware datastore freespace threshold config.\n * Added two new optional query parameters `offset` and `limit` to\n `GET /organization`.\n * Added two new optional query parameters `offset` and `limit` to\n `GET /user/{id}/organization`.\n * Modified `SnapshotCloudStorageTier`, enum adding values AzureArchive, Glacier,\n and GlacierDeepArchive.\n * Added the `lastValidationResult` field to the OracleDbDetail that the\n `GET /oracle/db/{id}` and `PATCH /oracle/db/{id}` endpoints return.\n * Added `isValid` field to the OracleDbSnapshotSummary of\n OracleRecoverableRange that the `GET /oracle/db/\n {id}/recoverable_range` endpoint returns.\n * Added the `isRemoteGlobalBlackoutActive` field to the\n ReplicationSourceSummary object that the\n `GET /organization/{id}/replication/source` endpoint returns.\n * Added the `isRemoteGlobalBlackoutActive` field to the\n ReplicationSourceSummary object that the\n `GET /replication/source/{id}` endpoint returns.\n * Added the `isRemoteGlobalBlackoutActive` field to the\n ReplicationSourceSummary object that the\n `GET /replication/source` endpoint returns.\n * Added the `isReplicationTargetPauseEnabled` field to the\n ReplicationSourceSummary object that the\n `GET /organization/{id}/replication/source` endpoint returns.\n * Added the `isReplicationTargetPauseEnabled` field to the\n ReplicationSourceSummary object that the\n `GET /replication/source/{id}` endpoint returns.\n * Added the `isReplicationTargetPauseEnabled` field to the\n ReplicationSourceSummary object that the\n `GET /replication/source` endpoint returns.\n * Added new optional field `cloudRehydrationSpeed` to the\n ObjectStoreLocationSummary, ObjectStoreUpdateDefinition,\n PolarisAwsArchivalLocationSpec, and PolarisAzureArchivalLocationSpec\n objects to specify the rehydration speed to use when performing cloud\n rehydration on objects tiered cold storage.\n * Added new optional field earliestTimestamp to the `POST\n /polaris/export_info` endpoint to enable incremental MDS synchronization.\n * Added new values `RetentionSlaDomainName` , `ObjectType`, `SnapshotCount`,\n `AutoSnapshotCount` and `ManualSnapshotCount` to\n `UnmanagedObjectSortAttribute` field of the `GET /unmanaged_object` endpont.\n * Added new optional field `endpoint` to the ObjectStorageDetail\n object used by several Polaris APIs.\n * Added new optional field `accessKey` to the ObjectStorageConfig\n object used by several Polaris APIs.\n * Added new optional field `endpoint` to DataSourceDownloadConfig used by\n `POST /report/data_source/download`.\n * Added new field `slaClientConfig` to the `ManagedVolumeUpdate`\n object used by the `PATCH /managed_volume/{id}` endpoint to enable\n edits to the configuration of SLA Managed Volumes.\n * Added new field `shouldSkipPrechecks` to DecommissionNodesConfig used by\n `POST /cluster/{id}/decommission_nodes`.\n * Added new query parameter `managed_volume_type` to allow filtering\n managed volumes based on their type using the `GET /managed_volume`\n endpoint.\n * Added new query parameter `managed_volume_type` to allow filtering\n managed volume exports based on their source managed volume type\n using the `GET /managed_volume/snapshot/export` endpoint.\n * Added the new fields `mvType` and `slaClientConfig` to the\n `ManagedVolumeConfig` object. These fields are used with the\n `POST /managed_volume` endpoint to manage SLA Managed Volumes.\n * Added the new fields `mvType` and `slaManagedVolumeDetails` to the\n `ManagedVolumeSummary` object returned by the `GET /managed_volume`,\n `POST /managed_volume`, `GET /managed_volume/{id}` and\n `POST /managed_volume/{id}` endpoints.\n * Added new field `mvType` to the `ManagedVolumeSnapshotExportSummary`\n object returned by the `GET /managed_volume/snapshot/export` and\n `GET /managed_volume/snapshot/export/{id}` endpoints.\n * Added optional field `hostMountPoint` in the `ManagedVolumeChannelConfig`.\n `ManagedVolumeChannelConfig` is returned as part of\n `ManagedVolumeSnapshotExportSummary`, which is returned\n by the `GET /managed_volume/snapshot/export` and\n `GET /managed_volume/snapshot/export/{id}` endpoints.\n * Added `POST /managed_volume/{id}/snapshot` method to take an on\n demand snapshot for SLA Managed Volumes.\n * Added new field `isPrimary` to OracleDbSummary returned by\n `GET /oracle/db`.\n * Added new field `isPrimary` to OracleDbDetail returned by\n `GET /oracle/db/{id}` and `PATCH /oracle/db/{id}`.\n * Added new field `isOracleHost` to HostDetail\n returned by `GET /host/{id}`.\n * Added optional isShareAutoDiscoveryAndAdditionEnabled in the\n NasBaseConfig and NasConfig.\n NasBaseConfig is returned as part of HostSummary, which is returned by the\n `Get /host/envoy` and `Get /host` endpoints. NasConfig is used by\n HostRegister and HostUpdate. The HostRegister field is used by the\n `Post /host/bulk` endpoint and the HostUpdate is field used by the\n `PATCH /host/bulk` endpoint.\n * Added new endpoint `POST /managed_volume/{id}/resize` to resize managed\n volume to a larger size.\n * Added ReplicationComplianceStatus as an optional field to the TableSummary\n which is used by /report/{id} GET and PATCH endpoints and to RequestFilters\n which is used by /report/data_source/table.\n * Added `PATCH /cluster/{id}/trial_edge` endpoint to extend the trial period.\n * Added new optional fields `extensionsLeft` and `daysLeft` to\n EdgeTrialStatus returned by `GET /cluster/{id}/trial_edge` and\n `PATCH /cluster/{id}/trial_edge`.\n * Added new endpoint `POST /managed_volume/snapshot/{id}/restore` to export a\n managed volume snapshot and mount it on a host.\n * Added new endpoints `PATCH /config/{component}/reset` to allow configs to\n be reset to DEFAULT state.\n * Added a new field `logRetentionTimeInHours` to the `MssqlDbDefaults`\n object returned by the `GET /mssql/db/defaults` and\n `PATCH /mssql/db/defaults` endpoints.\n * Added new optional field `logRetentionTimeInHours` to `MssqlDbDefaultsUpdate`\n object which is used by `PATCH /mssql/db/defaults`.\n * Added new optional field `unreadable` to `BrowseResponse` and\n `SnapshotSearchResponse`, which are used by `GET /browse` and\n `GET /search/snapshot_search` respectively.\n * Added MissedReplicationSnapshots as an optional field to the TableSummary\n which is used by /report/{id} GET and PATCH endpoints.\n * Added new optional field `pitRecoveryInfo` to `ChildSnappableFailoverInfo`\n object which is used by `PUT /polaris/failover/target/{id}/start`\n * Added ReplicationDataLag as an optional field to the TableSummary\n which is used by /report/{id} GET and PATCH endpoints.\n * Added UnreplicatedSnapshots as an optional field to the TableSummary\n which is used by /report/{id} GET and PATCH endpoints.\n * Added the field `networkAdapterType` to `VappVmNetworkConnection`.\n `VappVmNetworkConnection` is returned by the\n `GET /vcd/vapp/snapshot/{snapshot_id}/instant_recover/options` and\n `GET /vcd/vapp/snapshot/{snapshot_id}/export/options` endpoints and is\n used by the `POST /vcd/vapp/snapshot/{snapshot_id}/export` and\n `POST /vcd/vapp/snapshot/{snapshot_id}/instant_recover` endpoints.\n Also added `VcdVmSnapshotDetail`, which is returned by the\n `GET /vcd/vapp/snapshot/{id}` endpoint.\n * Added new endpoint `GET /report/template` to return details\n of a report template.\n * Added new endpoint `POST /report/{id}/send_email` to send an email of the report.\n ## Breaking changes:\n * Made field `restoreScriptSmbPath` optional in `VolumeGroupMountSummary`.\n Endpoints `/volume_group/snapshot/mount` and\n `/volume_group/snapshot/mount/{id}` are affected by this change.\n * Moved endpoints `GET /volume_group`, `GET /volume_group/{id}`,\n `PATCH /volume_group/{id}`, `GET /volume_group/{id}/snapshot`,\n `POST /volume_group/{id}/snapshot`, `GET /volume_group/snapshot/{id}`,\n `GET /volume_group/snapshot/mount`, and\n `GET /volume_group/snapshot/mount/{id}` from internal to v1.\n * Moved endpoint `GET /host/{id}/volume` from internal to v1.\n\n ### Changes to Internal API in Rubrik version 5.2.2\n ## Feature Additions/improvements:\n * Added new field `exposeAllLogs` to ExportOracleTablespaceConfig\n used by `POST /oracle/db/{id}/export/tablespace`.\n\n ### Changes to Internal API in Rubrik version 5.2.1\n ## Feature Additions/improvements:\n * Added new field `shouldBlockOnNegativeFailureTolerance` to\n DecommissionNodesConfig used by `POST /cluster/{id}/decommission_nodes`.\n\n ### Changes to Internal API in Rubrik version 5.2.0\n ## Deprecation:\n * Deprecating `GET /replication/global_pause`. Use\n `GET /internal/replication/source` and\n `GET /internal/replication/source/{id}` to retrieve replication\n pause status in CDM v5.3.\n * Deprecating `POST /replication/global_pause`. Use\n `POST /v1/replication/location_pause/disable` and\n `POST /v1/replication/location_pause/enable` to toggle replication\n pause in CDM v5.3.\n * Deprecating `slaId` field returned by `GET /vcd/vapp/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /vcd/vapp/snapshot/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /oracle/db/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /oracle/db/\n {id}/recoverable_range`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /oracle/db/snapshot/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /hyperv/vm/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /hyperv/vm/snapshot/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /volume_group/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /volume_group/snapshot/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n * Deprecating `slaId` field returned by `GET /storage/array_volume_group\n/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /vcd/vapp/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /host_fileset/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /host_fileset/share/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /app_blueprint/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /app_blueprint/snapshot/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /managed_volume/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `POST /managed_volume/{id\n}/end_snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /managed_volume/snapshot/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /aws/ec2_instance/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /aws/ec2_instance/snapshot/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /nutanix/vm/{id}/snapshot`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /nutanix/vm/snapshot/{id}`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Deprecating `slaId` field returned by `GET /fileset/bulk`.\n See **snapshotRetentionInfo** to track retention for\n snapshots.\n* Added a new field `pendingSlaDomain` to `VirtualMachineDetail`\n object referred by `VappVmDetail` returned by\n `GET /vcd/vapp/{id}` and `PATCH /vcd/vapp/{id}`\n * Deprecated `POST /internal/vmware/vcenter/{id}/refresh_vm` endpoint. Use\n `POST /v1/vmware/vcenter/{id}/refresh_vm` instead to refresh a\n virtual machine by MOID.\n\n ## Breaking changes:\n* Rename the field configuredSlaDomainId in the OracleUpdate object to\n configuredSlaDomainIdDeprecated and modify the behavior so\n configuredSlaDomainIdDeprecated is only used to determine log backup\n frequency and not to set retention time.\n* Removed `GET /event/count_by_status` endpoint and it will be\n replaced by `GET /job_monitoring/summary_by_job_state`.\n* Removed `GET /event/count_by_job_type` endpoint and it will be\n replaced by `GET /job_monitoring/summary_by_job_type`.\n* Removed `GET /event_series` endpoint and it will be replaced by\n `GET /job_monitoring`.\n* Refactor `PUT /cluster/{id}/security/web_signed_cert` to accept\n certificate_id instead of X.509 certificate text. Also removed\n the `POST /cluster/{id}/security/web_csr` endpoint.\n * Refactor `GET /rsa-server`, `POST /rsa-server`, `GET /rsa-server/{id}`,\n and `PATCH /rsa-server/{id}` to take in a certificate ID instead of\n a certificate.\n * Changed definition of CloudInstanceUpdate by updating the enums ON/OFF\n to POWERSTATUS_ON/POWERSTATUS_OFF\n * Removed `GET /event_series/{status}/csv_link` endpoint to download CSV\n with job monitoring information. It has been replaced by the\n `GET /job_monitoring//csv_download_link` v1 endpoint.\n * Removed GET `/report/summary/physical_storage_time_series`. Use\n GET `/stats/total_physical_storage/time_series` instead.\n * Removed GET `/report/summary/average_local_growth_per_day`. Use\n GET `/stats/average_storage_growth_per_day` instead.\n * Removed POST `/job/instances/`. Use GET `/job/{job_id}/instances` instead.\n * Removed the POST `/cluster/{id}/reset` endpoint.\n * Removed GET `/user`. Use the internal POST `/principal_search`\n or the v1 GET `/principal` instead for querying any principals,\n including users.\n\n ## Feature additions/improvements:\n * Added the `GET /replication/global_pause` endpoint to return the current\n status of global replication pause. Added the `POST /replication/global_pause`.\n endpoint to toggle the replication target global pause jobs status. When\n global replication pause is enabled, all replication jobs on the local\n cluster are paused. When disabling global replication pause, optional\n parameter `shouldOnlyReplicateNewSnapshots` can be set to `true` to only\n replicate snapshots taken after disabling the pause. These endpoints must\n be used at the target cluster.\n * Added new field `parentSnapshotId` to AppBlueprintSnapshotSummary returned\n by `GET /app_blueprint/{id}/snapshot`.\n * Added new field `parentSnapshotId` to AppBlueprintSnapshotDetail returned\n by `GET /app_blueprint/snapshot/{id}`.\n * Added new field `parentSnapshotId` to AwsEc2InstanceSummary returned by\n `GET /aws/ec2_instance`.\n * Added new field `parentSnapshotId` to AwsEc2InstanceDetail returned by\n `GET /aws/ec2_instance/{id}`.\n * Added new field `parentSnapshotId` to AwsEc2InstanceDetail returned by\n `PATCH /aws/ec2_instance/{id}`.\n * Added new field `parentSnapshotId` to HypervVirtualMachineSnapshotSummary\n returned by `GET /hyperv/vm/{id}/snapshot`.\n * Added new field `parentSnapshotId` to HypervVirtualMachineSnapshotDetail\n returned by `GET /hyperv/vm/snapshot/{id}`.\n * Added new field `parentSnapshotId` to ManagedVolumeSnapshotSummary\n returned by `GET /managed_volume/{id}/snapshot`.\n * Added new field `parentSnapshotId` to ManagedVolumeSnapshotSummary\n returned by `POST /managed_volume/{id}/end_snapshot`.\n * Added new field `parentSnapshotId` to ManagedVolumeSnapshotDetail returned\n by `GET /managed_volume/snapshot/{id}`.\n * Added new field `parentSnapshotId` to NutanixVmSnapshotSummary returned by\n `GET /nutanix/vm/{id}/snapshot`.\n * Added new field `parentSnapshotId` to NutanixVmSnapshotDetail returned by\n `GET /nutanix/vm/snapshot/{id}`.\n * Added new field `parentSnapshotId` to OracleDbSnapshotSummary returned by\n `GET /oracle/db/{id}/snapshot`.\n * Added new field `parentSnapshotId` to OracleDbSnapshotDetail returned by\n `GET /oracle/db/snapshot/{id}`.\n * Added new field `parentSnapshotId` to StorageArrayVolumeGroupSnapshotSummary\n returned by `GET /storage/array_volume_group/{id}/snapshot`.\n * Added new field `parentSnapshotId` to StorageArrayVolumeGroupSnapshotDetail\n returned by `GET /storage/array_volume_group/snapshot/{id}`.\n * Added new field `parentSnapshotId` to VcdVappSnapshotSummary returned by\n `GET /vcd/vapp/{id}/snapshot`.\n * Added new field `parentSnapshotId` to VcdVappSnapshotDetail returned by\n `GET /vcd/vapp/snapshot/{id}`.\n * Added new field `parentSnapshotId` to VolumeGroupSnapshotSummary returned by\n `GET /volume_group/{id}/snapshot`.\n * Added new field `parentSnapshotId` to VolumeGroupSnapshotDetail returned by\n `GET /volume_group/snapshot/{id}`.\n * Added new field `retentionSlaDomanId` to MssqlAvailabilityGroupSummary\n returned by `GET /mssql/availability_group`.\n * Added new field `retentionSlaDomanId` to MssqlAvailabilityGroupDetail\n returned by `GET /mssql/availability_group/{id}`.\n * Added new field `retentionSlaDomanId` to MssqlAvailabilityGroupDetail\n returned by `PATCH /mssql/availability_group/{id}`.\n * Added new field `retentionSlaDomainId` to UnmanagedObjectSummary\n returned by `GET /unmanaged_object`.\n * Added new field `retentionSlaDomainId` to ManagedVolumeSummary\n returned by `GET /managed_volume`.\n * Added new field `retentionSlaDomainId` to AppBlueprintDetail\n returned by `GET /app_blueprint/{id}`.\n * Added new field `retentionSlaDomainId` to AppBlueprintDetail\n returned by `PATCH /polaris/app_blueprint/{id}`.\n * Added new field `retentionSlaDomainId` to AppBlueprintDetail\n returned by `POST /polaris/app_blueprint`.\n * Added new field `retentionSlaDomainId` to AppBlueprintExportSnapshotJobConfig\n returned by `POST /polaris/app_blueprint/snapshot/{id}/export`.\n * Added new field `retentionSlaDomainId` to AppBlueprintInstantRecoveryJobConfig\n returned by `POST /polaris/app_blueprint/snapshot/{id}/instant_recover`.\n * Added new field `retentionSlaDomainId` to AppBlueprintMountSnapshotJobConfig\n returned by `POST /polaris/app_blueprint/snapshot/{id}/mount`.\n * Added new field `retentionSlaDomainId` to AppBlueprintSummary\n returned by `GET /app_blueprint`.\n * Added new field `retentionSlaDomainId` to AwsEc2InstanceDetail\n returned by `GET /aws/ec2_instance/{id}`.\n * Added new field `retentionSlaDomainId` to AwsEc2InstanceDetail\n returned by `PATCH /aws/ec2_instance/{id}`.\n * Added new field `retentionSlaDomainId` to AwsEc2InstanceSummary\n returned by `GET /aws/ec2_instance`.\n * Added new field `retentionSlaDomainId` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}/children`.\n * Added new field `retentionSlaDomainId` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}/descendants`.\n * Added new field `retentionSlaDomainId` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}`.\n * Added new field `retentionSlaDomainId` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}/children`.\n * Added new field `retentionSlaDomainId` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}/descendants`.\n * Added new field `retentionSlaDomainId` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}`.\n * Added new field `retentionSlaDomainId` to HypervHierarchyObjectSummary\n returned by `GET /organization/{id}/hyperv`.\n * Added new field `retentionSlaDomainId` to HypervVirtualMachineDetail\n returned by `GET /hyperv/vm/{id}`.\n * Added new field `retentionSlaDomainId` to HypervVirtualMachineDetail\n returned by `PATCH /hyperv/vm/{id}`.\n * Added new field `retentionSlaDomainId` to HypervVirtualMachineSummary\n returned by `GET /hyperv/vm`.\n * Added new field `retentionSlaDomainId` to ManagedHierarchyObjectSummary\n returned by `GET /hierarchy/{id}`.\n * Added new field `retentionSlaDomainId` to ManagedHierarchyObjectSummary\n returned by `GET /hierarchy/{id}/sla_conflicts`.\n * Added new field `retentionSlaDomainId` to ManagedVolumeSummary\n returned by `GET /managed_volume/{id}`.\n * Added new field `retentionSlaDomainId` to ManagedVolumeSummary\n returned by `GET /organization/{id}/managed_volume`.\n * Added new field `retentionSlaDomainId` to ManagedVolumeSummary\n returned by `PATCH /managed_volume/{id}`.\n * Added new field `retentionSlaDomainId` to ManagedVolumeSummary\n returned by `POST /managed_volume`.\n * Added new field `retentionSlaDomainId` to MountDetail\n returned by `GET /vmware/vm/snapshot/mount/{id}`.\n * Added new field `retentionSlaDomainId` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}/children`.\n * Added new field `retentionSlaDomainId` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}/descendants`.\n * Added new field `retentionSlaDomainId` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}`.\n * Added new field `retentionSlaDomainId` to NutanixHierarchyObjectSummary\n returned by `GET /organization/{id}/nutanix`.\n * Added new field `retentionSlaDomainId` to OracleDbDetail\n returned by `GET /oracle/db/{id}`.\n * Added new field `retentionSlaDomainId` to OracleDbDetail\n returned by `PATCH /oracle/db/{id}`.\n * Added new field `retentionSlaDomainId` to OracleDbSummary\n returned by `GET /oracle/db`.\n * Added new field `retentionSlaDomainId` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}/children`.\n * Added new field `retentionSlaDomainId` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}/descendants`.\n * Added new field `retentionSlaDomainId` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}`.\n * Added new field `retentionSlaDomainId` to OracleHierarchyObjectSummary\n returned by `GET /organization/{id}/oracle`.\n * Added new field `retentionSlaDomainId` to SlaConflictsSummary\n returned by `POST /hierarchy/bulk_sla_conflicts`.\n * Added new field `retentionSlaDomainId` to SnappableRecoverySpecDetails\n returned by `POST /polaris/failover/recovery_spec/upsert`.\n * Added new field `retentionSlaDomainId` to SnappableRecoverySpec\n returned by `POST /polaris/failover/recovery_spec/upsert`.\n * Added new field `retentionSlaDomainId` to Snappable\n returned by `POST /polaris/failover/recovery_spec/upsert`.\n * Added new field `retentionSlaDomainId` to Snappable\n returned by `POST /stats/snappable_storage`.\n * Added new field `retentionSlaDomainId` to StorageArrayHierarchyObjectSummary\n returned by `GET /organization/{id}/storage/array`.\n * Added new field `retentionSlaDomainId` to StorageArrayHierarchyObjectSummary\n returned by `GET /storage/array/hierarchy/{id}/children`.\n * Added new field `retentionSlaDomainId` to StorageArrayHierarchyObjectSummary\n returned by `GET /storage/array/hierarchy/{id}/descendants`.\n * Added new field `retentionSlaDomainId` to StorageArrayHierarchyObjectSummary\n returned by `GET /storage/array/hierarchy/{id}`.\n * Added new field `retentionSlaDomainId` to StorageArrayVolumeGroupDetail\n returned by `GET /storage/array_volume_group/{id}`.\n * Added new field `retentionSlaDomainId` to StorageArrayVolumeGroupDetail\n returned by `PATCH /storage/array_volume_group/{id}`.\n * Added new field `retentionSlaDomainId` to StorageArrayVolumeGroupDetail\n returned by `POST /storage/array_volume_group`.\n * Added new field `retentionSlaDomainId` to StorageArrayVolumeGroupSummary\n returned by `GET /storage/array_volume_group`.\n * Added new field `retentionSlaDomainId` to TriggerFailoverOnTargetDefinition\n returned by `PUT /polaris/failover/target/{id}/resume`.\n * Added new field `retentionSlaDomainId` to TriggerFailoverOnTargetDefinition\n returned by `PUT /polaris/failover/target/{id}/start`.\n * Added new field `retentionSlaDomainId` to UpsertSnappableRecoverySpecResponse\n returned by `POST /polaris/failover/recovery_spec/upsert`.\n * Added new field `retentionSlaDomainId` to VcdHierarchyObjectSummary\n returned by `GET /organization/{id}/vcd`.\n * Added new field `retentionSlaDomainId` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}/children`.\n * Added new field `retentionSlaDomainId` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}/descendants`.\n * Added new field `retentionSlaDomainId` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}`.\n * Added new field `retentionSlaDomainId` to VcdVappDetail\n returned by `GET /vcd/vapp/{id}`.\n * Added new field `retentionSlaDomainId` to VcdVappDetail\n returned by `PATCH /vcd/vapp/{id}`.\n * Added new field `retentionSlaDomainId` to VcdVappSnapshotDetail\n returned by `GET /vcd/vapp/snapshot/{id}`.\n * Added new field `retentionSlaDomainId` to VolumeGroupDetail\n returned by `GET /volume_group/{id}`.\n * Added new field `retentionSlaDomainId` to VolumeGroupDetail\n returned by `PATCH /volume_group/{id}`.\n * Added new field `retentionSlaDomainId` to VolumeGroupSummary\n returned by `GET /volume_group`.\n * Added new field `retentionSlaDomainId` to AwsHierarchyObjectSummary\n returned by `GET /organization/{id}/aws`.\n * Added new field `retentionSlaDomainId` to VmwareVmMountSummary\n returned by `GET /vmware/vm/snapshot/mount`.\n * Added new field `retentionSlaDomainId` to VcdVappSummary\n returned by `GET /vcd/vapp`.\n * Added `isReplicationTargetPauseEnabled` to ReplicationTargetSummary\n returned by `GET /replication/target`.\n * Added `isReplicationTargetPauseEnabled` to ReplicationTargetSummary\n returned by `POST /replication/target`.\n * Added `isReplicationTargetPauseEnabled` to ReplicationTargetSummary\n returned by `GET /replication/target/{id}`.\n * Added `isReplicationTargetPauseEnabled` to ReplicationTargetSummary\n returned by `GET /replication/target/{id}`.\n * Added `isReplicationTargetPauseEnabled` to ReplicationTargetSummary\n returned by `PATCH /replication/target/{id}`.\n * Added `isReplicationTargetPauseEnabled` to ReplicationTargetSummary\n returned by `GET /organization/{id}/replication/target`.\n * Added new field `hasSnapshotsWithPolicy` to UnmanagedObjectSummary returned\n by GET `/unmanaged_object`\n * Added new field `slaLastUpdateTime` to AppBlueprintDetail\n returned by POST `/polaris/app_blueprint`.\n * Added new field `slaLastUpdateTime` to AppBlueprintDetail\n returned by `GET /app_blueprint/{id}`.\n * Added new field `slaLastUpdateTime` to AppBlueprintDetail\n returned by `PATCH /polaris/app_blueprint/{id}`.\n * Added new field `slaLastUpdateTime` to AppBlueprintExportSnapshotJobConfig\n returned by POST `/polaris/app_blueprint/snapshot/{id}/export`.\n * Added new field `slaLastUpdateTime` to AppBlueprintInstantRecoveryJobConfig\n returned by POST `/polaris/app_blueprint/snapshot/{id}/instant_recover`.\n * Added new field `slaLastUpdateTime` to AppBlueprintMountSnapshotJobConfig\n returned by POST `/polaris/app_blueprint/snapshot/{id}/mount`.\n * Added new field `slaLastUpdateTime` to AppBlueprintSummary\n returned by `GET /app_blueprint`.\n * Added new field `slaLastUpdateTime` to AwsAccountDetail\n returned by `PATCH /aws/account/dca/{id}`.\n * Added new field `slaLastUpdateTime` to AwsAccountDetail\n returned by `GET /aws/account/{id}`.\n * Added new field `slaLastUpdateTime` to AwsAccountDetail\n returned by `PATCH /aws/account/{id}`.\n * Added new field `slaLastUpdateTime` to AwsEc2InstanceDetail\n returned by `GET /aws/ec2_instance/{id}`.\n * Added new field `slaLastUpdateTime` to AwsEc2InstanceDetail\n returned by `PATCH /aws/ec2_instance/{id}`.\n * Added new field `slaLastUpdateTime` to FilesetDetail\n returned by POST `/fileset/bulk`.\n * Added new field `slaLastUpdateTime` to AwsEc2InstanceSummary\n returned by `GET /aws/ec2_instance`.\n * Added new field `slaLastUpdateTime` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}`.\n * Added new field `slaLastUpdateTime` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}/children`.\n * Added new field `slaLastUpdateTime` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}/descendants`.\n * Added new field `slaLastUpdateTime` to AwsHierarchyObjectSummary\n returned by `GET /organization/{id}/aws`.\n * Added new field `slaLastUpdateTime` to DataCenterDetail\n returned by `GET /vmware/data_center/{id}`.\n * Added new field `slaLastUpdateTime` to DataCenterSummary\n returned by `GET /vmware/data_center`.\n * Added new field `slaLastUpdateTime` to DataStoreDetail\n returned by `GET /vmware/datastore/{id}`.\n * Added new field `slaLastUpdateTime` to FolderDetail\n returned by `GET /folder/host/{datacenter_id}`.\n * Added new field `slaLastUpdateTime` to FolderDetail\n returned by `GET /folder/vm/{datacenter_id}`.\n * Added new field `slaLastUpdateTime` to FolderDetail\n returned by `GET /folder/{id}`.\n * Added new field `slaLastUpdateTime` to HostFilesetDetail\n returned by `GET /host_fileset/{id}`.\n * Added new field `slaLastUpdateTime` to HostFilesetShareDetail\n returned by `GET /host_fileset/share/{id}`.\n * Added new field `slaLastUpdateTime` to HostFilesetShareSummary\n returned by `GET /host_fileset/share`.\n * Added new field `slaLastUpdateTime` to HostFilesetSummary\n returned by `GET /host_fileset`.\n * Added new field `slaLastUpdateTime` to HypervClusterDetail\n returned by `GET /hyperv/cluster/{id}`.\n * Added new field `slaLastUpdateTime` to HypervClusterDetail\n returned by `PATCH /hyperv/cluster/{id}`.\n * Added new field `slaLastUpdateTime` to HypervClusterSummary\n returned by `GET /hyperv/cluster`.\n * Added new field `slaLastUpdateTime` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}`.\n * Added new field `slaLastUpdateTime` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}/children`.\n * Added new field `slaLastUpdateTime` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}/descendants`.\n * Added new field `slaLastUpdateTime` to HypervHierarchyObjectSummary\n returned by `GET /organization/{id}/hyperv`.\n * Added new field `slaLastUpdateTime` to HypervHostDetail\n returned by `GET /hyperv/host/{id}`.\n * Added new field `slaLastUpdateTime` to HypervHostDetail\n returned by `PATCH /hyperv/host/{id}`.\n * Added new field `slaLastUpdateTime` to HypervHostSummary\n returned by `GET /hyperv/host`.\n * Added new field `slaLastUpdateTime` to HypervScvmmDetail\n returned by `GET /hyperv/scvmm/{id}`.\n * Added new field `slaLastUpdateTime` to HypervScvmmDetail\n returned by `PATCH /hyperv/scvmm/{id}`.\n * Added new field `slaLastUpdateTime` to HypervScvmmSummary\n returned by `GET /hyperv/scvmm`.\n * Added new field `slaLastUpdateTime` to HypervVirtualMachineDetail\n returned by `GET /hyperv/vm/{id}`.\n * Added new field `slaLastUpdateTime` to HypervVirtualMachineDetail\n returned by `PATCH /hyperv/vm/{id}`.\n * Added new field `slaLastUpdateTime` to HypervVirtualMachineSummary\n returned by `GET /hyperv/vm`.\n * Added new field `slaLastUpdateTime` to ManagedHierarchyObjectSummary\n returned by `GET /hierarchy/{id}`.\n * Added new field `slaLastUpdateTime` to ManagedHierarchyObjectSummary\n returned by `GET /hierarchy/{id}/sla_conflicts`.\n * Added new field `slaLastUpdateTime` to ManagedVolumeSummary\n returned by `GET /managed_volume`.\n * Added new field `slaLastUpdateTime` to ManagedVolumeSummary\n returned by POST `/managed_volume`.\n * Added new field `slaLastUpdateTime` to ManagedVolumeSummary\n returned by `GET /managed_volume/{id}`.\n * Added new field `slaLastUpdateTime` to ManagedVolumeSummary\n returned by `PATCH /managed_volume/{id}`.\n * Added new field `slaLastUpdateTime` to ManagedVolumeSummary\n returned by `GET /organization/{id}/managed_volume`.\n * Added new field `slaLastUpdateTime` to MountDetail\n returned by `GET /vmware/vm/snapshot/mount/{id}`.\n * Added new field `slaLastUpdateTime` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}`.\n * Added new field `slaLastUpdateTime` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}/children`.\n * Added new field `slaLastUpdateTime` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}/descendants`.\n * Added new field `slaLastUpdateTime` to NutanixHierarchyObjectSummary\n returned by `GET /organization/{id}/nutanix`.\n * Added new field `slaLastUpdateTime` to OracleDbDetail\n returned by `GET /oracle/db/{id}`.\n * Added new field `slaLastUpdateTime` to OracleDbDetail\n returned by `PATCH /oracle/db/{id}`.\n * Added new field `slaLastUpdateTime` to OracleDbSummary\n returned by `GET /oracle/db`.\n * Added new field `slaLastUpdateTime` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}`.\n * Added new field `slaLastUpdateTime` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}/children`.\n * Added new field `slaLastUpdateTime` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}/descendants`.\n * Added new field `slaLastUpdateTime` to OracleHierarchyObjectSummary\n returned by `GET /organization/{id}/oracle`.\n * Added new field `slaLastUpdateTime` to OracleHostDetail\n returned by `GET /oracle/host/{id}`.\n * Added new field `slaLastUpdateTime` to OracleHostDetail\n returned by `PATCH /oracle/host/{id}`.\n * Added new field `slaLastUpdateTime` to OracleHostSummary\n returned by `GET /oracle/host`.\n * Added new field `slaLastUpdateTime` to OracleRacDetail\n returned by `GET /oracle/rac/{id}`.\n * Added new field `slaLastUpdateTime` to OracleRacDetail\n returned by `PATCH /oracle/rac/{id}`.\n * Added new field `slaLastUpdateTime` to OracleRacSummary\n returned by `GET /oracle/rac`.\n * Added new field `slaLastUpdateTime` to Snappable\n returned by POST `/polaris/failover/recovery_spec/upsert`.\n * Added new field `slaLastUpdateTime` to SnappableRecoverySpec\n returned by POST `/polaris/failover/recovery_spec/upsert`.\n * Added new field `slaLastUpdateTime` to SnappableRecoverySpecDetails\n returned by POST `/polaris/failover/recovery_spec/upsert`.\n * Added new field `slaLastUpdateTime` to StorageArrayHierarchyObjectSummary\n returned by `GET /organization/{id}/storage/array`.\n * Added new field `slaLastUpdateTime` to StorageArrayHierarchyObjectSummary\n returned by `GET /storage/array/hierarchy/{id}`.\n * Added new field `slaLastUpdateTime` to StorageArrayHierarchyObjectSummary\n returned by `GET /storage/array/hierarchy/{id}/children`.\n * Added new field `slaLastUpdateTime` to StorageArrayHierarchyObjectSummary\n returned by `GET /storage/array/hierarchy/{id}/descendants`.\n * Added new field `slaLastUpdateTime` to StorageArrayVolumeGroupDetail\n returned by POST `/storage/array_volume_group`.\n * Added new field `slaLastUpdateTime` to StorageArrayVolumeGroupDetail\n returned by `GET /storage/array_volume_group/{id}`.\n * Added new field `slaLastUpdateTime` to StorageArrayVolumeGroupDetail\n returned by `PATCH /storage/array_volume_group/{id}`.\n * Added new field `slaLastUpdateTime` to StorageArrayVolumeGroupSummary\n returned by `GET /storage/array_volume_group`.\n * Added new field `slaLastUpdateTime` to VcdClusterDetail\n returned by `GET /vcd/cluster/{id}`.\n * Added new field `slaLastUpdateTime` to VcdClusterDetail\n returned by `PATCH /vcd/cluster/{id}`.\n * Added new field `slaLastUpdateTime` to VcdClusterSummary\n returned by `GET /vcd/cluster`.\n * Added new field `slaLastUpdateTime` to VcdHierarchyObjectSummary\n returned by `GET /organization/{id}/vcd`.\n * Added new field `slaLastUpdateTime` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}`.\n * Added new field `slaLastUpdateTime` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}/children`.\n * Added new field `slaLastUpdateTime` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}/descendants`.\n * Added new field `slaLastUpdateTime` to VcdVappDetail\n returned by `GET /vcd/vapp/{id}`.\n * Added new field `slaLastUpdateTime` to VcdVappDetail\n returned by `PATCH /vcd/vapp/{id}`.\n * Added new field `slaLastUpdateTime` to VcdVappSnapshotDetail\n returned by `GET /vcd/vapp/snapshot/{id}`.\n * Added new field `slaLastUpdateTime` to VcdVappSummary\n returned by `GET /vcd/vapp`.\n * Added new field `slaLastUpdateTime` to VmwareVmMountSummary\n returned by `GET /vmware/vm/snapshot/mount`.\n * Added new field `slaLastUpdateTime` to VolumeGroupDetail\n returned by `GET /volume_group/{id}`.\n * Added new field `slaLastUpdateTime` to VolumeGroupDetail\n returned by `PATCH /volume_group/{id}`.\n * Added new field `slaLastUpdateTime` to VolumeGroupSummary\n returned by `GET /volume_group`.\n * Added new field `slaLastUpdateTime` to VsphereCategory\n returned by `GET /vmware/vcenter/{id}/tag_category`.\n * Added new field `slaLastUpdateTime` to VsphereCategory\n returned by `GET /vmware/vcenter/tag_category/{tag_category_id}`.\n * Added new field `slaLastUpdateTime` to VsphereTag\n returned by `GET /vmware/vcenter/{id}/tag`.\n * Added new field `slaLastUpdateTime` to VsphereTag\n returned by `GET /vmware/vcenter/tag/{tag_id}`.\n * Added new Field `configuredSlaDomainType` to AppBlueprintDetail returned by\n `POST /polaris/app_blueprint`.\n * Added new Field `configuredSlaDomainType` to AppBlueprintDetail returned by\n `GET /app_blueprint/{id}`.\n * Added new Field `configuredSlaDomainType` to AppBlueprintDetail returned by\n `PATCH /polaris/app_blueprint/{id}`.\n * Added new Field `configuredSlaDomainType` to\n AppBlueprintExportSnapshotJobConfig returned by\n `POST /polaris/app_blueprint/snapshot/{id}/export`.\n * Added new Field `configuredSlaDomainType` to\n AppBlueprintInstantRecoveryJobConfig returned by\n `POST /polaris/app_blueprint/snapshot/{id}/instant_recover`.\n * Added new Field `configuredSlaDomainType` to\n AppBlueprintMountSnapshotJobConfig returned by\n `POST /polaris/app_blueprint/snapshot/{id}/mount`.\n * Added new Field `configuredSlaDomainType` to AppBlueprintSummary returned by\n `GET /app_blueprint`.\n * Added new Field `configuredSlaDomainType` to AwsAccountDetail returned by\n `PATCH /aws/account/dca/{id}`.\n * Added new Field `configuredSlaDomainType` to AwsAccountDetail returned by\n `GET /aws/account/{id}`.\n * Added new Field `configuredSlaDomainType` to AwsAccountDetail returned by\n `PATCH /aws/account/{id}`.\n * Added new Field `configuredSlaDomainType` to AwsEc2InstanceDetail returned\n by `GET /aws/ec2_instance/{id}`.\n * Added new Field `configuredSlaDomainType` to AwsEc2InstanceDetail returned\n by `PATCH /aws/ec2_instance/{id}`.\n * Added new Field `configuredSlaDomainType` to AwsEc2InstanceSummary returned\n by `GET /aws/ec2_instance`.\n * Added new Field `configuredSlaDomainType` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}`.\n * Added new Field `configuredSlaDomainType` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}/children`.\n * Added new Field `configuredSlaDomainType` to AwsHierarchyObjectSummary\n returned by `GET /aws/hierarchy/{id}/descendants`.\n * Added new Field `configuredSlaDomainType` to AwsHierarchyObjectSummary\n returned by `GET /organization/{id}/aws`.\n * Added new Field `configuredSlaDomainType` to DataCenterDetail returned by\n `GET /vmware/data_center/{id}`.\n * Added new Field `configuredSlaDomainType` to DataCenterSummary returned by\n `GET /vmware/data_center`.\n * Added new Field `configuredSlaDomainType` to DataStoreDetail returned by\n `GET /vmware/datastore/{id}`.\n * Added new Field `configuredSlaDomainType` to FilesetDetail returned by\n `POST /fileset/bulk`.\n * Added new Field `configuredSlaDomainType` to FolderDetail returned by\n `GET /folder/host/{datacenter_id}`.\n * Added new Field `configuredSlaDomainType` to FolderDetail returned by\n `GET /folder/vm/{datacenter_id}`.\n * Added new Field `configuredSlaDomainType` to FolderDetail returned by\n `GET /folder/{id}`.\n * Added new Field `configuredSlaDomainType` to HostFilesetDetail returned by\n `GET /host_fileset/{id}`.\n * Added new Field `configuredSlaDomainType` to HostFilesetShareDetail returned\n by `GET /host_fileset/share/{id}`.\n * Added new Field `configuredSlaDomainType` to HostFilesetShareSummary\n returned by `GET /host_fileset/share`.\n * Added new Field `configuredSlaDomainType` to HostFilesetSummary returned by\n `GET /host_fileset`.\n * Added new Field `configuredSlaDomainType` to HypervClusterDetail returned by\n `GET /hyperv/cluster/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervClusterDetail returned by\n `PATCH /hyperv/cluster/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervClusterSummary returned\n by `GET /hyperv/cluster`.\n * Added new Field `configuredSlaDomainType` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}/children`.\n * Added new Field `configuredSlaDomainType` to HypervHierarchyObjectSummary\n returned by `GET /hyperv/hierarchy/{id}/descendants`.\n * Added new Field `configuredSlaDomainType` to HypervHierarchyObjectSummary\n returned by `GET /organization/{id}/hyperv`.\n * Added new Field `configuredSlaDomainType` to HypervHostDetail returned by\n `GET /hyperv/host/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervHostDetail returned by\n `PATCH /hyperv/host/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervHostSummary returned by\n `GET /hyperv/host`.\n * Added new Field `configuredSlaDomainType` to HypervScvmmDetail returned by\n `GET /hyperv/scvmm/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervScvmmDetail returned by\n `PATCH /hyperv/scvmm/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervScvmmSummary returned by\n `GET /hyperv/scvmm`.\n * Added new Field `configuredSlaDomainType` to HypervVirtualMachineDetail\n returned by `GET /hyperv/vm/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervVirtualMachineDetail\n returned by `PATCH /hyperv/vm/{id}`.\n * Added new Field `configuredSlaDomainType` to HypervVirtualMachineSummary\n returned by `GET /hyperv/vm`.\n * Added new Field `configuredSlaDomainType` to ManagedHierarchyObjectSummary\n returned by `GET /hierarchy/{id}`.\n * Added new Field `configuredSlaDomainType` to ManagedHierarchyObjectSummary\n returned by `GET /hierarchy/{id}/sla_conflicts`.\n * Added new Field `configuredSlaDomainType` to ManagedVolumeSummary returned\n by `GET /managed_volume`.\n * Added new Field `configuredSlaDomainType` to ManagedVolumeSummary returned\n by `POST /managed_volume`.\n * Added new Field `configuredSlaDomainType` to ManagedVolumeSummary returned\n by `GET /managed_volume/{id}`.\n * Added new Field `configuredSlaDomainType` to ManagedVolumeSummary returned\n by `PATCH /managed_volume/{id}`.\n * Added new Field `configuredSlaDomainType` to ManagedVolumeSummary returned\n by `GET /organization/{id}/managed_volume`.\n * Added new Field `configuredSlaDomainType` to MountDetail returned by\n `GET /vmware/vm/snapshot/mount/{id}`.\n * Added new Field `configuredSlaDomainType` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}`.\n * Added new Field `configuredSlaDomainType` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}/children`.\n * Added new Field `configuredSlaDomainType` to NutanixHierarchyObjectSummary\n returned by `GET /nutanix/hierarchy/{id}/descendants`.\n * Added new Field `configuredSlaDomainType` to NutanixHierarchyObjectSummary\n returned by `GET /organization/{id}/nutanix`.\n * Added new Field `configuredSlaDomainType` to OracleDbDetail returned by\n `GET /oracle/db/{id}`.\n * Added new Field `configuredSlaDomainType` to OracleDbDetail returned by\n `PATCH /oracle/db/{id}`.\n * Added new Field `configuredSlaDomainType` to OracleDbSummary returned by\n `GET /oracle/db`.\n * Added new Field `configuredSlaDomainType` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}`.\n * Added new Field `configuredSlaDomainType` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}/children`.\n * Added new Field `configuredSlaDomainType` to OracleHierarchyObjectSummary\n returned by `GET /oracle/hierarchy/{id}/descendants`.\n * Added new Field `configuredSlaDomainType` to OracleHierarchyObjectSummary\n returned by `GET /organization/{id}/oracle`.\n * Added new Field `configuredSlaDomainType` to OracleHostDetail returned by\n `GET /oracle/host/{id}`.\n * Added new Field `configuredSlaDomainType` to OracleHostDetail returned by\n `PATCH /oracle/host/{id}`.\n * Added new Field `configuredSlaDomainType` to OracleHostSummary returned by\n `GET /oracle/host`.\n * Added new Field `configuredSlaDomainType` to OracleRacDetail returned by\n `GET /oracle/rac/{id}`.\n * Added new Field `configuredSlaDomainType` to OracleRacDetail returned by\n `PATCH /oracle/rac/{id}`.\n * Added new Field `configuredSlaDomainType` to OracleRacSummary returned by\n `GET /oracle/rac`.\n * Added new Field `configuredSlaDomainType` to SlaConflictsSummary returned by\n `POST /hierarchy/bulk_sla_conflicts`.\n * Added new Field `configuredSlaDomainType` to Snappable returned by\n `POST /polaris/failover/recovery_spec/upsert`.\n * Added new Field `configuredSlaDomainType` to SnappableRecoverySpec returned\n by `POST /polaris/failover/recovery_spec/upsert`.\n * Added new Field `configuredSlaDomainType` to SnappableRecoverySpecDetails\n returned by `POST /polaris/failover/recovery_spec/upsert`.\n * Added new Field `configuredSlaDomainType` to\n StorageArrayHierarchyObjectSummary returned by\n `GET /organization/{id}/storage/array`.\n * Added new Field `configuredSlaDomainType` to\n StorageArrayHierarchyObjectSummary returned by\n `GET /storage/array/hierarchy/{id}`.\n * Added new Field `configuredSlaDomainType` to\n StorageArrayHierarchyObjectSummary returned by\n `GET /storage/array/hierarchy/{id}/children`.\n * Added new Field `configuredSlaDomainType` to\n StorageArrayHierarchyObjectSummary returned by\n `GET /storage/array/hierarchy/{id}/descendants`.\n * Added new Field `configuredSlaDomainType` to StorageArrayVolumeGroupDetail\n returned by `POST /storage/array_volume_group`.\n * Added new Field `configuredSlaDomainType` to StorageArrayVolumeGroupDetail\n returned by `GET /storage/array_volume_group/{id}`.\n * Added new Field `configuredSlaDomainType` to StorageArrayVolumeGroupDetail\n returned by `PATCH /storage/array_volume_group/{id}`.\n * Added new Field `configuredSlaDomainType` to StorageArrayVolumeGroupSummary\n returned by `GET /storage/array_volume_group`.\n * Added new Field `configuredSlaDomainType` to\n TriggerFailoverOnTargetDefinition returned by\n `PUT /polaris/failover/target/{id}/start`.\n * Added new Field `configuredSlaDomainType` to\n TriggerFailoverOnTargetDefinition returned by\n `PUT /polaris/failover/target/{id}/resume`.\n * Added new Field `configuredSlaDomainType` to\n UnmanagedObjectSummary returned by `GET /unmanaged_object`.\n * Added new Field `configuredSlaDomainType` to\n UpsertSnappableRecoverySpecResponse returned by\n `POST /polaris/failover/recovery_spec/upsert`.\n * Added new Field `configuredSlaDomainType` to VcdClusterDetail returned by\n `GET /vcd/cluster/{id}`.\n * Added new Field `configuredSlaDomainType` to VcdClusterDetail returned by\n `PATCH /vcd/cluster/{id}`.\n * Added new Field `configuredSlaDomainType` to VcdClusterSummary returned by\n `GET /vcd/cluster`.\n * Added new Field `configuredSlaDomainType` to VcdHierarchyObjectSummary\n returned by `GET /organization/{id}/vcd`.\n * Added new Field `configuredSlaDomainType` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}`.\n * Added new Field `configuredSlaDomainType` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}/children`.\n * Added new Field `configuredSlaDomainType` to VcdHierarchyObjectSummary\n returned by `GET /vcd/hierarchy/{id}/descendants`.\n * Added new Field `configuredSlaDomainType` to VcdVappDetail returned by\n `GET /vcd/vapp/{id}`.\n * Added new Field `configuredSlaDomainType` to VcdVappDetail returned by\n `PATCH /vcd/vapp/{id}`.\n * Added new Field `configuredSlaDomainType` to VcdVappSnapshotDetail returned\n by `GET /vcd/vapp/snapshot/{id}`.\n * Added new Field `configuredSlaDomainType` to VcdVappSummary returned by\n `GET /vcd/vapp`.\n * Added new Field `configuredSlaDomainType` to VmwareVmMountSummary returned\n by `GET /vmware/vm/snapshot/mount`.\n * Added new Field `configuredSlaDomainType` to VolumeGroupDetail returned by\n `GET /volume_group/{id}`.\n * Added new Field `configuredSlaDomainType` to VolumeGroupDetail returned by\n `PATCH /volume_group/{id}`.\n * Added new Field `configuredSlaDomainType` to VolumeGroupSummary returned by\n `GET /volume_group`.\n * Added new Field `configuredSlaDomainType` to VsphereCategory returned by\n `GET /vmware/vcenter/{id}/tag_category`.\n * Added new Field `configuredSlaDomainType` to VsphereCategory returned by\n `GET /vmware/vcenter/tag_category/{tag_category_id}`.\n * Added new Field `configuredSlaDomainType` to VsphereTag returned by\n `GET /vmware/vcenter/{id}/tag`.\n * Added new Field `configuredSlaDomainType` to VsphereTag returned by\n `GET /vmware/vcenter/tag/{tag_id}`.\n * Added a new optional query parameter `name` to\n `GET /user/{id}/organization`.\n * Added new field `hostLogRetentionHours` to OracleDbSummary returned by\n `GET /oracle/db`.\n * Added new field `isCustomRetentionApplied` to AppBlueprintSnapshotSummary\n returned by `GET /app_blueprint/{id}/snapshot`.\n * Added new field `isCustomRetentionApplied` to AppBlueprintSnapshotDetail\n returned by `GET /app_blueprint/snapshot/{id}` .\n * Added new field `isCustomRetentionApplied` to AwsEc2InstanceSummary returned\n by `GET /aws/ec2_instance`.\n * Added new field `isCustomRetentionApplied` to AwsEc2InstanceDetail returned\n by `GET /aws/ec2_instance/{id}`.\n * Added new field `isCustomRetentionApplied` to AwsEc2InstanceDetail returned\n by `PATCH /aws/ec2_instance/{id}`.\n * Added new field `isCustomRetentionApplied` to\n HypervVirtualMachineSnapshotSummary returned by\n `GET /hyperv/vm/{id}/snapshot`.\n * Added new field `isCustomRetentionApplied` to\n HypervVirtualMachineSnapshotDetail returned by\n `GET /hyperv/vm/snapshot/{id}`.\n * Added new field `isCustomRetentionApplied` to ManagedVolumeSnapshotSummary\n returned by `GET /managed_volume/{id}/snapshot`.\n * Added new field `isCustomRetentionApplied` to ManagedVolumeSnapshotSummary\n returned by `POST /managed_volume/{id}/end_snapshot`.\n * Added new field `isCustomRetentionApplied` to ManagedVolumeSnapshotDetail\n returned by `GET /managed_volume/snapshot/{id}`.\n * Added new field `isCustomRetentionApplied` to NutanixVmSnapshotSummary\n returned by `GET /nutanix/vm/{id}/snapshot`.\n * Added new field `isCustomRetentionApplied` to NutanixVmSnapshotDetail\n returned by `GET /nutanix/vm/snapshot/{id}`.\n * Added new field `isCustomRetentionApplied` to OracleDbSnapshotSummary\n returned by `GET /oracle/db/{id}/snapshot`.\n * Added new field `isCustomRetentionApplied` to OracleDbSnapshotDetail returned\n by `GET /oracle/db/snapshot/{id}`.\n * Added new field `isCustomRetentionApplied` to\n StorageArrayVolumeGroupSnapshotSummary returned by\n `GET /storage/array_volume_group/{id}/snapshot`.\n * Added new field `isCustomRetentionApplied` to\n StorageArrayVolumeGroupSnapshotDetail returned by\n `GET /storage/array_volume_group/snapshot/{id}`.\n * Added new field `isCustomRetentionApplied` to VcdVappSnapshotSummary returned\n by `GET /vcd/vapp/{id}/snapshot`.\n * Added new field `isCustomRetentionApplied` to VcdVappSnapshotDetail returned\n by `GET /vcd/vapp/snapshot/{id}`.\n * Added new field `isCustomRetentionApplied` to VolumeGroupSnapshotSummary\n returned by `GET /volume_group/{id}/snapshot`.\n * Added new field `isCustomRetentionApplied` to VolumeGroupSnapshotDetail\n returned by `GET /volume_group/snapshot/{id}`.\n * Added optional field `isQueuedSnapshot` to the response of\n GET `/managed_volume/{id}/snapshot`, GET `/managed_volume/snapshot/{id}`.\n and POST `/managed_volume/{id}/end_snapshot`.\n The field specifies if ManagedVolume snapshots are in queue to be stored\n as patch file.\n * Added new field `securityLevel` to `SnmpTrapReceiverConfig` object as\n optional input parameter for SNMPv3, which is used in\n `PATCH /cluster/{id}/snmp_configuration` and\n `GET /cluster/{id}/snmp_configuration`.\n * Added new field `advancedRecoveryConfigBase64` to `ExportOracleDbConfig`.\n and `MountOracleDbConfig` objects as optional input parameter\n during Oracle recovery.\n * Added new optional field `isRemote` to UnmanagedObjectSummary object, which\n is returned from a `GET /unmanaged_object` call.\n * Added new field `hostLogRetentionHours` to OracleRacDetail returned by\n `GET /oracle/rac/{id}` and `PATCH /oracle/rac/{id}`.\n * Added new field `hostLogRetentionHours` to OracleHostDetail returned by\n `GET /oracle/host/{id}` and `PATCH /oracle/host/{id}`.\n * Added new field `hostLogRetentionHours` to OracleDbDetail returned by\n `GET /oracle/db/{id}` and `PATCH /oracle/db/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of AppBlueprintSnapshotSummary returned\n by `GET /app_blueprint/{id}/snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of AppBlueprintSnapshotDetail returned by\n `GET /app_blueprint/snapshot/{id}` .\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of AwsEc2InstanceSummary returned by\n `GET /aws/ec2_instance`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of AwsEc2InstanceDetail returned by\n `GET /aws/ec2_instance/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of AwsEc2InstanceDetail returned by\n `PATCH /aws/ec2_instance/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of HypervVirtualMachineSnapshotSummary\n returned by `GET /hyperv/vm/{id}/snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of HypervVirtualMachineSnapshotDetail\n returned by `GET /hyperv/vm/snapshot/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of ManagedVolumeSnapshotSummary returned\n by `GET /managed_volume/{id}/snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of ManagedVolumeSnapshotSummary returned by\n `POST /managed_volume/{id}/end_snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of ManagedVolumeSnapshotDetail returned\n by `GET /managed_volume/snapshot/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of NutanixVmSnapshotSummary returned by\n `GET /nutanix/vm/{id}/snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of NutanixVmSnapshotDetail returned by\n `GET /nutanix/vm/snapshot/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of OracleDbSnapshotSummary returned by\n `GET /oracle/db/{id}/snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of OracleDbSnapshotDetail returned by\n `GET /oracle/db/snapshot/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of StorageArrayVolumeGroupSnapshotSummary\n returned by `GET /storage/array_volume_group/{id}/snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of StorageArrayVolumeGroupSnapshotDetail\n returned by `GET /storage/array_volume_group/snapshot/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of VcdVappSnapshotSummary returned by\n `GET /vcd/vapp/{id}/snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of VcdVappSnapshotDetail returned by\n `GET /vcd/vapp/snapshot/{id}`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of VolumeGroupSnapshotSummary returned by\n `GET /volume_group/{id}/snapshot`.\n * Added new field `snapshotFrequency` to `snapshotLocationRetentionInfo` field\n of `SnapshotRetentionInfo` field of VolumeGroupSnapshotDetail returned by\n `GET /volume_group/snapshot/{id}`.\n * Added new field `SnapshotRetentionInfo` to AppBlueprintSnapshotSummary\n returned by `GET /app_blueprint/{id}/snapshot`.\n * Added new field `SnapshotRetentionInfo` to AppBlueprintSnapshotDetail\n returned by `GET /app_blueprint/snapshot/{id}` .\n * Added new field `SnapshotRetentionInfo` to AwsEc2InstanceSummary returned\n by `GET /aws/ec2_instance`.\n * Added new field `SnapshotRetentionInfo` to AwsEc2InstanceDetail returned\n by `GET /aws/ec2_instance/{id}`.\n * Added new field `SnapshotRetentionInfo` to AwsEc2InstanceDetail returned\n by `PATCH /aws/ec2_instance/{id}`.\n * Added new field `SnapshotRetentionInfo` to\n HypervVirtualMachineSnapshotSummary returned by\n `GET /hyperv/vm/{id}/snapshot`.\n * Added new field `SnapshotRetentionInfo` to\n HypervVirtualMachineSnapshotDetail returned by\n `GET /hyperv/vm/snapshot/{id}`.\n * Added new field `SnapshotRetentionInfo` to ManagedVolumeSnapshotSummary\n returned by `GET /managed_volume/{id}/snapshot`.\n * Added new field `SnapshotRetentionInfo` to ManagedVolumeSnapshotSummary\n returned by `POST /managed_volume/{id}/end_snapshot`.\n * Added new field `SnapshotRetentionInfo` to ManagedVolumeSnapshotDetail\n returned by `GET /managed_volume/snapshot/{id}`.\n * Added new field `SnapshotRetentionInfo` to NutanixVmSnapshotSummary\n returned by `GET /nutanix/vm/{id}/snapshot`.\n * Added new field `SnapshotRetentionInfo` to NutanixVmSnapshotDetail\n returned by `GET /nutanix/vm/snapshot/{id}`.\n * Added new field `SnapshotRetentionInfo` to OracleDbSnapshotSummary\n returned by `GET /oracle/db/{id}/snapshot`.\n * Added new field `SnapshotRetentionInfo` to OracleDbSnapshotDetail returned\n by `GET /oracle/db/snapshot/{id}`.\n * Added new field `SnapshotRetentionInfo` to\n StorageArrayVolumeGroupSnapshotSummary returned by\n `GET /storage/array_volume_group/{id}/snapshot`.\n * Added new field `SnapshotRetentionInfo` to\n StorageArrayVolumeGroupSnapshotDetail returned by\n `GET /storage/array_volume_group/snapshot/{id}`.\n * Added new field `SnapshotRetentionInfo` to VcdVappSnapshotSummary returned\n by `GET /vcd/vapp/{id}/snapshot`.\n * Added new field `SnapshotRetentionInfo` to VcdVappSnapshotDetail returned\n by `GET /vcd/vapp/snapshot/{id}`.\n * Added new field `SnapshotRetentionInfo` to VolumeGroupSnapshotSummary\n returned by `GET /volume_group/{id}/snapshot`.\n * Added new field `SnapshotRetentionInfo` to VolumeGroupSnapshotDetail\n returned by `GET /volume_group/snapshot/{id}`.\n * Added optional field `networkInterface` to `NetworkThrottleUpdate`. The\n field allows users to specify non standard network interfaces. This applies\n to the `PATCH /network_throttle/{id}` endpoint.\n * Added mandatory field `networkInterface` to `NetworkThrottleSummary`.\n This applies to the endpoints `GET /network_throttle` and\n `GET /network_throttle/{id}`.\n * Added endpoint `POST /cluster/{id}/manual_discover`, which allows\n the customer to manually input data that would be learned using\n mDNS discovery. Returns same output as discover.\n * `PATCH /cluster/{id}/snmp_configuration` will now use\n `SnmpConfigurationPatch` as a parameter.\n * Added optional field `user` to `SnmpTrapReceiverConfig`. The field\n specifies which user to use for SNMPv3 traps.\n * Added optional field `users` to `SnmpConfiguration`. The field contains\n usernames of users configured for SNMPv3.\n * Added two new models `SnmpUserConfig` to store user credentials and\n `SnmpConfigurationPatch`.\n * Added new endpoint `POST /role/authorization_query` to get authorizations\n granted to roles.\n * Added new endpoint `GET /role/{id}/authorization` to get authorizations\n granted to a role.\n * Added new endpoint `POST /role/{id}/authorization` to grant authorizations\n to a role.\n * Added new endpoint `POST /role/{id}/authorization/bulk_revoke` to revoke\n authorizations from a role.\n * Added optional field `recoveryInfo` to UnmanagedObjectSummary.\n * Added optional field `isRetentionLocked` to SlaInfo.\n The parameter indicates that the SLA Domain associated with the job is a\n Retention Lock SLA Domain.\n * Added optional field `legalHoldDownloadConfig` to\n `FilesetDownloadFilesJobConfig`,`HypervDownloadFileJobConfig`,\n `DownloadFilesJobConfig`,`ManagedVolumeDownloadFileJobConfig`,\n `NutanixDownloadFilesJobConfig`,`StorageArrayDownloadFilesJobConfig`,\n `VolumeGroupDownloadFilesJobConfig`.This is an optional argument\n containing a Boolean parameter to depict if the download is being\n triggered for Legal Hold use case. This change applies to\n /fileset/snapshot/{id}/download_files,\n /hyperv/vm/snapshot/{id}/download_file,\n /vmware/vm/snapshot/{id}/download_files,\n /managed_volume/snapshot/{id}/download_file,\n /nutanix/vm/snapshot/{id}/download_files,\n /storage/array_volume_group/snapshot/{id}/download_files and\n /volume_group/snapshot/{id}/download_files endpoints.\n * Added optional field isPlacedOnLegalHold to BaseSnapshotSummary.\n The Boolean parameter specifies whether the snapshot is placed under a\n Legal Hold.\n * Added new endpoint `GET /ods_configuration`.\n Returns the current configuration of on-demand snapshot handling.\n * Added new endpoint `PUT /ods_configuration`.\n Update the configuration of on-demand snapshot handling.\n * Added two new models `OdsConfigurationSummary`, `OdsPolicyOnPause` and a new\n enum `SchedulingType`.\n * Added `odsPolicyOnPause` field in `OdsConfigurationSummary` to include the\n policy followed by the on-demand snapshots, during an effective pause.\n * Added new enum field `schedulingType` in `OdsPolicyOnPause` to support\n deferring the on-demand snapshots during an effective pause.\n * Added optional query parameter `show_snapshots_legal_hold_status` to\n `GET /archive/location` endpoint, indicating if `isLegalHoldSnapshotPresent`.\n field should be populated in response.\n * Added storage array volume group asynchronous request status endpoint\n `GET /storage/array_volume_group/request/{id}`. Request statuses for\n storage array volume groups which previously used\n `/storage/array/request/{id}` must now use this new endpoint.\n * Added forceFull parameter to the properties of patch volume group object\n to permit forcing a full snapshot for a specified volume group.\n * Added `isDcaAccountInstance` field to `AwsEc2InstanceSummary` to indicate\n whether the EC2 instance belongs to a DCA account. This impacts the endpoints\n `GET /aws/ec2_instance` and `GET /aws/ec2_instance/{id}`.\n * Added `encryptionKeyId` as an optional field in CreateCloudInstanceRequest\n definition used in the on-demand API conversion API `/cloud_on/aws/instance`.\n to support KMS encryption for CloudOn conversion in AWS.\n * Added new endpoint `GET /job/{id}/child_job_instance`.\n Returns the child job instances (if any) spawned by the given parent job\n instance. This endpoint requires a support token to access.\n * Updated `ArchivalLocationSummary` returned by `GET /archive/location`.\n endpoint to include the `isConsolidationEnabled` field, to indicate\n if consolidation is enabled for the given archival location.\n * Changed `encryptionPassword` parameter to optional in\n `NfsLocationCreationDefinition` to support creating NFS archival location\n without encryption via `POST /archive/nfs`.\n * Added an optional parameter `disabledEncryption` to\n `NfsLocationCreationDefinition` with a default value of false, to enable or\n disable encryption via `POST /archive/nfs`.\n * Added a new model `ValidationResponse` and REST API endpoints\n `/cloud_on/validate/instantiate_on_cloud` and\n `/cloud_on/validate/cloud_image_conversion` for validation of cloud\n conversion.\n * Added `sortBy` and `sortOrder` parameters to `GET /hyperv/vm/snapshot/mount`.\n to allow sorting of Hyper-V mounts.\n Added the enum `HypervVirtualMachineMountListSortAttribute`, defining which\n properties of Hyper-V mounts are sortable.\n * Added an optional field `shouldApplyToExistingSnapshots` in\n `SlaDomainAssignmentInfo` to apply the new SLA configuration to existing\n snapshots of protected objects.\n * Added a new optional field `isOracleHost` to `HostRegister` in\n `POST /host/bulk` and `HostUpdate` in `PATCH /host/bulk` to indicate if we\n should discover Oracle information during registration and host refresh.\n * Added a new model `NutanixVirtualDiskSummary` that is returned by\n `GET /nutanix/vm/{id}` to include the disks information for a Nutanix\n virtual machine.\n * Added mandatory field `pendingSnapshot` to `SystemStorageStats`, which is\n returned by `GET /stats/system_storage`.\n * Added optional isIsilonChangelistEnabled in the NasBaseConfig and NasConfig.\n NasBaseConfig is returned as part of HostSummary, which is returned by the\n `Get /host/envoy` and `Get /host` endpoints. NasConfig is used by\n HostRegister and HostUpdate. The HostRegister is used by the\n `Post /host/bulk` endpoint and the HostUpdate is used by the\n `PATCH /host/bulk` endpoint.\n * Added a new model `HostShareParameters`. This model has two fields,\n isNetAppSnapDiffEnabled and isIsilonChangelistEnabled. The\n isNetAppSnapDiffEnabled is a Boolean value that specifies whether the\n SnapDiff feature of NetApp NAS is used to back up the NAS share. The\n isIsilonChangelistEnabled is a Boolean value that specifies whether\n the Changelist feature of Isilon NAS is used to back up the NAS share.\n * Added optional field `HostShareParameters` in `HostFilesetShareSummary`,\n `HostFilesetShareDetail` and `HostShareDetail`. The HostShareDetail impacts\n the endpoints `Get /host/share` and `Post /host/share`. The\n `HostFilesetShareDetail` impacts the endpoint `Get /host_fileset/share/{id}`.\n . The HostFilesetShareSummary impacts the endpoint\n `Get /host_fileset/share`.\n * Added `isInVmc` in `GET /vcd/vapp/{id}`, and `PATCH /vcd/vapp/{id}`.\n to return whether the virtual machine is in a VMC setup.\n * Added new endpoint `GET /vmware/hierarchy/{id}/export`. Returns the\n VmwareHierarchyInfo object with the given ID.\n * Added optional field `platformDetails` to `PlatformInfo`, which is returned\n by `GET /cluster/{id}/platforminfo`.\n * Added optional field `cpuCount` to `PlatformInfo`, which is returned by\n `GET /cluster/{id}/platforminfo`.\n * Added optional field `ramSize` to `PlatformInfo`, which is returned by\n `GET /cluster/{id}/platforminfo`.\n * Added new value `RangeInTime` to `RecoveryPointType` enum, which is used in\n the `ReportTableRequest` object for the POST `/report/{id}/table` and POST\n `/report/data_source/table` endpoints.\n * Added the optional field `shouldForceFull` to `MssqlDbUpdate` object,\n which is referred by `MssqlDbUpdateId`, which is referred as the\n body parameter of `PATCH /mssql/db/bulk`.\n\n ### Changes to Internal API in Rubrik version 5.1.1\n ## Breaking changes:\n * Changed response code of a successful\n `POST /managed_volume/{id}/begin_snapshot` API from 201 to 200.\n\n ### Changes to Internal API in Rubrik version 5.1.0\n ## Breaking changes:\n * Changed response type of percentInCompliance and percentOutOfCompliance\n in ComplianceSummary to double.\n * Renamed new enum field `MissedSnapshots` to `MissedLocalSnapshots`.\n and `LastSnapshot` to `LatestLocalSnapshot`, in the\n following properties:\n measure property in ChartSummary, column property in TableSummary,\n and sortBy property in ReportTableRequest.\n * Renamed effectiveThroughput to throughput in EventSeriesMonitoredJobSummary.\n * Renamed realThroughput to throughput in EventSeriesSummary.\n * Updated response of GET /event_series/{id} to remove effectiveThroughput.\n * Renamed paths `/storage/array/volume/group` to `/storage/array_volume_group`.\n * Renamed the field cassandraSetup in ReplaceNodeStatus to metadataSetup\n * Renamed the field cassandraSetup in RecommisionNodeStatus to metadataSetup\n * Renamed the field cassandraSetup in AddNodesStatus to metadataSetup\n * Renamed the field cassandraSetup in ClusterConfigStatus to metadataSetup\n * Renamed the field removeCassandra in RemoveNodeStatus to removeMetadatastore\n for the GET /cluster/{id}/remove_node endpoint.\n * Moved the `GET /blackout_window` endpoint from internal to V1.\n * Moved the `PATCH /blackout_window` endpoint from internal to V1.\n * Removed endpoint POST /report/global_object endpoint.\n /report/data_source/table can be used to get the same information.\n * Made accessKey optional in ObjectStoreLocationDetail as accessKey is not\n defined in Cross Account Role Based locations. Also made accessKey required\n again in ObjectStoreLocationDefinition.\n * Removed `progressPercentage` from `EventSeriesMonitoredJobSummary` object.\n * Removed endpoint `POST cluster-id-security-password-strength` since it is\n no longer used at bootstrap.\n * Moved the GET `/mssql/hierarchy/{id}/descendants` and\n GET `/mssql/hierarchy/{id}/children` endpoints from internal to v1.\n\n ## Feature Additions/improvements:\n * GET POST /cluster/{id}/node now accepts an optional encryption\n password in the encryptionPassword field.\n * GET /node_management/replace_node now accepts an optional encryption\n password in the encryptionPassword field.\n * Added optional field `shouldSkipScheduleRecoverArchivedMetadataJob` to\n the body parameter of `POST /archive/object_store/reader/connect`, to\n determine whether to schedule the archival recovery job.\n When the value is 'false,' the recovery job is scheduled normally.\n When the value is 'true,' the recovery job is not scheduled.\n The default behavior is to schedule the recovery job.\n * Added mandatory field `cdp` to SystemStorageStats.\n * Added optional field `agentStatus` to NutanixHierarchyObjectSummary.\n The field indicates whether a Rubrik backup agent is registered to the\n Nutanix object.\n * Added optional field `shouldUseAgent` to `RestoreFilesJobConfig`.\n in `POST /vmware/vm/snapshot/{id}/restore_files` to specify\n whether to use Rubrik Backup Service to restore files. Default value is true.\n * GET /managed_object/bulk/summary and GET\n /managed_object/{managed_id}/summary no longer include archived objects\n with no unexpired snapshots in their results.\n * Added new required Boolean field `isDbLocalToTheCluster` to\n `OracleDbSummary` and `OracleDbDetail`.\n * Added optional field `awsAccountId` to ObjectStoreLocationSummary.\n * Added optional field `shouldRecoverSnappableMetadataOnly` to all the\n reader location connect definitions.\n * Added new enum value `ArchivalComplianceStatus` to the following properties:\n attribute property in ChartSummary and column property in TableSummary\n * Added new enum fields `ArchivalInComplianceCount`,\n `ArchivalNonComplianceCount` and `MissedArchivalSnapshots` to the\n following properties:\n measure property in ChartSummary, column property in TableSummary,\n and sortBy property in ReportTableRequest.\n * GET /managed_object/bulk/summary and GET\n /managed_object/{managed_id}/summary will always include the correct relic\n status for hosts and their descendants.\n * Added field `isLocked` to PrincipalSummary.\n * Added optional query parameter `snappableStatus` to /vmware/data_center and\n /vmware/host. This parameter enables a user to fetch the set of protectable\n objects from the list of objects visible to that user.\n * Added optional field `archivalComplianceStatus` to RequestFilters\n * Added optional field `archivalComplianceStatus` to FilterSummary\n * Added optional field `alias` to HostSummary, HostRegister, and HostUpdate\n schemas. This field will allow the user to specify an alias for each host\n which can be used for search.\n * Added optional field `subnet` to ManagedVolumeExportConfig\n * Added optional field `status` to oracle/hierarchy/{id}/children\n * Added optional field `status` to oracle/hierarchy/{id}/descendants\n * Added optional field `status` to hyperv/hierarchy/{id}/children\n * Added optional field `status` to hyperv/hierarchy/{id}/descendants\n * Added optional field `numNoSla` to ProtectedObjectsCount\n * Added optional field `numDoNotProtect` to ProtectedObjectsCount\n * Added optional field `limit`, `offset`, `sort_by`, `sort_order` to\n /node/stats\n * Added optional field encryptionAtRestPassword to configure password-based\n encryption for an edge instance.\n * Added new endpoint GET /report/data_source/{data_source_name}/csv.\n * Added new endpoint POST /report-24_hour_complianace_summary.\n * Added new endpoint POST /report/data-source/{data_source_name} to get\n columns directly from report data source.\n * Added optional field compliance24HourStatus to RequestFilters object.\n * Added the `port` optional field to QstarLocationDefinition. The `port` field\n enables a user to specify the server port when adding a new location or\n editing an existing location.\n * Added optional field archivalTieringSpec to ArchivalSpec and ArchivalSpecV2\n to support archival tiering. This enables the user to configure either\n Instant Tiering or Smart Tiering (with a corresponding minimum accessible\n duration) on an SLA domain with archival configured to an Azure archival\n location.\n * Updated endpoints /vcd/vapp, /oracle/db and /aws/ec2_instance\n to have a new optional query paramter, indicating if backup task information\n should be included.\n * Added optional field logConfig to SlaDomainSummaryV2, SlaDomainDefinitionV2\n and SlaDomainPatchDefintionV2 to support CDP (ctrlc). The parameters\n distinguish SLAs with CDP enabled from SLAs with CDP disabled, and enable\n users to specify log retention time. The field also provides an optional\n frequency parameter whhich can be used by Oracle and SQL Server log backups.\n * Added optional field logRetentionLimit to ReplicationSpec to support\n CDP replication. The field gives the retention limit for logs at the\n specified location.\n * Moved the `GET /vmware/compute_cluster` endpoint from internal to V1.\n * Moved the `GET /vmware/compute_cluster/{id}` endpoint from internal to V1.\n * Changed the existing `PATCH mssql/db/bulk` endpoint to return an\n unprotectable reason as a string in the `unprotectableReason` field instead\n of a JSON struct.\n * Added optional field `kmsMasterKeyId` and changed the existing field\n `pemFileContent` to optional field in `DcaLocationDefinition`.\n * Added new optional field `enableHardlinkSupport` to FilesetSummary and\n FilesetCreate in `POST /fileset`, \"GET /fileset\" and \"PATCH /fileset/{id}\"\n endpoints to enable recognition and deduplication of hardlinks in\n fileset backup.\n * Added optional query parameter to `GET /archive/location` endpoint,\n indicating if `isRetentionLockedSnapshotProtectedPresent` field should\n be populated in response.\n * Added continuous data protection state for each VMware virtual machine\n * Added new endpoint `PUT /polaris/archive/proxy_setting`.\n * Added new endpoint `GET /polaris/archive/proxy_setting/{id}`.\n * Added new endpoint `DELETE /polaris/archive/proxy_setting/{id}`.\n * Added new endpoint `PUT /polaris/archive/aws_compute_setting`.\n * Added new endpoint `GET /polaris/archive/aws_compute_setting/{id}`.\n * Added new endpoint `DELETE /polaris/archive/aws_compute_setting/{id}`.\n * Added new endpoint `PUT /polaris/archive/azure_compute_setting`.\n * Added new endpoint `GET /polaris/archive/azure_compute_setting/{id}`.\n * Added new endpoint `DELETE /polaris/archive/azure_compute_setting/{id}`.\n * Added new endpoint `PUT /polaris/archive/aws_iam_location`.\n * Added new endpoint `GET /polaris/archive/aws_iam_location/{id}`.\n * Added new endpoint `PUT /polaris/archive/azure_oauth_location`.\n * Added new endpoint `GET /polaris/archive/azure_oauth_location/{id}`.\n * Added new endpoint `PUT /polaris/archive/aws_iam_customer_account`.\n * Added new endpoint `GET /polaris/archive/aws_iam_customer/{id}`.\n * Added new endpoint `DELETE /polaris/archive/aws_iam_customer/{id}`.\n * Added new endpoint `PUT /polaris/archive/azure_oauth_customer`.\n * Added new endpoint `GET /polaris/archive/azure_oauth_customer/{id}`.\n * Added new endpoint `DELETE /polaris/archive/azure_oauth_customer/{id}`.\n * Updated `ArchivalLocationSummary` returned by `GET /archive/location`.\n endpoint to include `currentState` field, to indicate whether the archival\n location is connected or temporarily disconnected.\n * Updated `ArchivalLocationSummary` returned by `GET /archive/location`.\n endpoint to include `isComputeEnabled` field, to indicate whether the\n archival location has cloud compute enabled.\n * Added optional field `cloudStorageTier` to `BaseSnapshotSummary`, to indicate\n the current storage tier of the archived copy of a snapshot.\n * Added endpoint `PUT /polaris/archive/aws_iam_location/reader_connect`.\n to connect as a reader to an IAM based AWS archival location.\n * Added endpoint `PUT /polaris/archive/azure_oauth_location/reader_connect`.\n to connect as a reader to an OAuth based Azure archival location.\n * Added endpoint `POST polaris/archive/location/{id}/reader/promote`.\n to promote the current cluster to be the owner of a specified IAM based AWS\n archival location that is currently connected as a reader location.\n * Added endpoint `POST polaris/archive/location/{id}/reader/refresh`.\n to sync the current reader cluster with the contents on the IAM based AWS\n archival location.\n * Added effectiveSlaDomainName and effectiveSlaDomainSourceId fields\n to `GET /vmware/vcenter/{id}/tag_category` response object.\n * Added effectiveSlaDomainName and effectiveSlaDomainSourceId fields\n to `GET /vmware/vcenter/{id}/tag` response object.\n * Added continuous data protection status for reporting.\n * Added optional field `localCdpStatus` to the following components:\n ChartSummary, TableSummary, ReportTableRequest, RequestFilters and\n FilterSummary.\n * Added `ReportSnapshotIndexState` and `ReportObjectIndexType` to\n `/internal_report_models/internal/definitions/enums/internal_report.yml`.\n * Added optional field `latestSnapshotIndexState` and `objectIndexType` to\n the following components:\n TableSummary, ReportTableRequest, RequestFilters and FilterSummary.\n * Added 24 hour continuous data protection healthy percentage for reporting.\n * Added optional field `PercentLocal24HourCdpHealthy` to the following\n components: TableSummary, ReportTableRequest.\n * Added optional field `replicas` to MssqlHierarchyObjectSummary.\n * Added optional field `hosts` to MssqlHierarchyObjectSummary.\n * Added continuous data protection local log storage size and local throughput\n consumption for reporting.\n * Added optional fields `localCdpThroughput` and `localCdpLogStorage` to the\n following components: ChartSummary, TableSummary and ReportTableRequest.\n * Added optional field requestExclusionFilters to ReportTableRequest.\n * Added an optional field to ManagedVolumeSummary to retrieve the associated\n subnet.\n * Added optional field isEffectiveSlaDomainRetentionLocked to Snappable.\n The parameter depicts if the effective SLA domain for the snappable is\n a Retention Lock SLA Domain.\n * Updated the set of possible continuous data protection statuses for each\n VmwareVirtualMachine.\n * Added the optional field isEffectiveSlaDomainRetentionLocked to\n FilesetSummary. The field is a Boolean that specifies whether the effective\n SLA Domain of a fileset is retention locked.\n * Added optional field iConfiguredSlaDomainRetentionLocked to SlaAssignable.\n The parameter depicts if the configured SLA domain for the object is a\n Retention Lock SLA Domain.\n * Updated `ArchivalLocationSummary` returned by `GET /archive/location`.\n endpoint to include the `isTieringSupported` field, to indicate\n whether a given archival location supports tiering.\n * Added continuous data protection replication status for reporting.\n * Added CdpReplicationStatus as an optional field to the TableSummary and\n ReportTableRequest components.\n * Added optional CdpReplicationStatus field to RequestFilters and\n FiltersSummary.\n * Added optional field isEffectiveSlaDomainRetentionLocked to\n SearchItemSummary. The Boolean parameter specifies whether the effective\n SLA Domain for the search item is a Retention Lock SLA Domain.\n * Updated `OracleMountSummary` returned by GET /oracle/db/mount\n endpoint to include the isInstantRecovered field, to indicate\n whether the mount was created during an Instant Recovery or Live Mount.\n * Added optional field isEffectiveSlaDomainRetentionLocked to\n ManagedObjectSummary. The Boolean parameter specifies whether the effective\n SLA Domain for the search item is a Retention Lock SLA Domain.\n * Added optional field `isRetentionSlaDomainRetentionLocked` to\n UnmanagedSnapshotSummary. The parameter indicates that the retention SLA\n Domain associated with the snapshot is a Retention Lock SLA Domain.\n * Added optional field `isSlaRetentionLocked` to EventSeriesSummary.\n The parameter indicates that the SLA Domain associated with the event\n series is a Retention Lock SLA Domain.\n * Updated `ArchivalLocationSummary` returned by `GET /archive/location`.\n endpoint to include the `isConsolidationEnabled` field, to indicate\n if consolidation is enabled for the given archival location.\n * Added the `hasUnavailableDisks` field to `NodeStatus` to indicate whether a\n node has unavailable (failed or missing) disks. This change affects the\n endpoints `GET /cluster/{id}/node`, `GET /node`, `GET /node/{id}`, `GET\n /node/stats`, and `GET /node/{id}/stats`.\n * Added optional NAS vendor type to the HostShareDetail\n This change affectes the endpoints `Get /host/share`, `Post /host/share` and\n `Get /host/share/{id}`.\n * Added optional isSnapdiffEnabled in the NasBaseConfig and NasConfig\n NasBaseConfig is returned as part of HostSummary, which is returned by the\n `Get /host/envoy` and `Get /host` endpoints. NasConfig is used by\n HostRegister and HostUpdate. The HostRegister field is used by the\n `Post /host/bulk` endpoint and the HostUpdate is field used by the\n `PATCH /host/bulk` endpoint.\n * Added optional snapdiffUsed in the FilesetSnapshotSummary\n The FilesetSnapshotSummary is used by FilesetDetail and\n FilesetSnapshotDetail. This change affects the endpoints `Post\n /fileset/bulk`, `Get /host_fileset/share/{id}` and\n `Get /fileset/snapshot/{id}`.\n\n ### Changes to Internal API in Rubrik version 5.0.4\n ## Feature Additions/improvements:\n * Added objectState to FilterSummary which is part of body parameter of\n PATCH/report/{id}\n * Added objectState to RequestFilters which is part of body parameter of\n POST /report/data_source/table\n\n ### Changes to Internal API in Rubrik version 5.0.3\n ## Breaking changes:\n * Removed fields 'virtualMedia' and 'ssh' from IpmiAccess and\n IpmiAccessUpdate.\n\n ## Feature Additions/improvements:\n * Added a new optional field 'oracleQueryUser' to HostRegister, HostUpdate\n and HostDetail objects, for setting the Oracle username for account with\n query privileges on the host. This applies to the following endpoints:\n `POST /host/bulk`, `PATCH /host/{id}`, and `GET /host/{id}`.\n * Added a field `affectedNodeIds` to the `SystemStatus` object. This object is\n returned by `GET /cluster/{id}/system_status`.\n * Made `nodeId` a required field of the `DiskStatus` object. This object, or\n an object containing this object, is returned by the following endpoints:\n `GET /cluster/{id}/disk`, `PATCH /cluster/{id}/disk/{disk_id}`, and\n `GET /node/{id}`.\n\n ### Changes to Internal API in Rubrik version 5.0.2\n ## Feature Additions/improvements:\n * Added an optional fields `subnet` to `ManagedVolumeSummary` to retrieve the associated\n subnet.\n * Added `tablespaces` field in `OracleDbSnapshotSummary` to include the list\n of tablespaces in the Oracle database snapshot.\n * Added new endpoint `POST /hierarchy/bulk_sla_conflicts`.\n * Added optional field `limit`, `offset`, `sort_by`, `sort_order` to\n `GET /node/stats`.\n * Added optional field `numNoSla` to `ProtectedObjectsCount`.\n * Added optional field `numDoNotProtect` to `ProtectedObjectsCount`.\n * Introduced optional field `logicalSize` to `VirtualMachineDetail`. This\n field gives the sum of logical sizes of all the disks in the virtual\n machine.\n * Added optional fields `nodeIds`, `slaId`, `numberOfRetries`, and\n `isFirstFullSnapshot` to the response of `GET /event_series/{id}`.\n * Added `SapHanaLog` tag in `applicationTag` field of `ManagedVolumeConfig`.\n for SAP HANA log managed volumes.\n * Added required field `dbSnapshotSummaries` in `OracleRecoverableRange` to include\n the list of database snapshots in each Oracle recoverable range.\n * Added field `isOnline` to MssqlDbSummary and changed `hasPermissions` to\n required field.\n * Added `DbTransactionLog` tag, in applicationTag field of\n `ManagedVolumeConfig`, for generic log managed volumes. ApplicationTag has\n to be specified in the request field of POST /managed_volume.\n\n ### Changes to Internal API in Rubrik version 5.0.1\n ## Breaking changes:\n * Removed `GET/POST /smb/enable_security` endpoints.\n * Changed the `objectId` type in `EventSeriesMonitoredJobSummary` and\n `EventSeriesSummary` to a user-visible ID instead of a simple ID.\n * Updated endpoint `POST /smb/domain` to accept a list of domain controllers.\n * Removed endpoint `POST /report/global_object`.\n * Added optional field `kmsMasterKeyId` and changed the existing field\n `pemFileContent` to optional field in `DcaLocationDefinition`.\n * Removed `progressPercentage` from `EventSeriesMonitoredJobSummary` object.\n\n ## Feature Additions/improvements:\n * Updated `ArchivalLocationSummary` returned by `GET /archive/location`.\n endpoint to include `currentState` field, to indicate whether the archival\n location is connected or temporarily disconnected.\n * Added optional field `subnet` to ManagedVolumeExportConfig.\n * Added the`PUT /smb/config` endpoint to manage SMB configuration.\n * Added the following two endpoints.\n - `GET /stats/per_vm_storage`.\n - `GET /stats/per_vm_storage/{vm_id}`.\n * Added optional field `isStickySmbService` to the response of\n `GET /smb/domain` and `POST /smb/domain`.\n * Added new endpoint `GET /report/data_source/{data_source_name}/csv`.\n * Added new endpoint `POST /report_24_hour_complianace_summary`.\n * Added new endpoint `POST /report/data-source/{data_source_name}` to get\n columns directly from report data source.\n * Added new report API endpoints:\n - `GET /report/summary/physical_storage_time_series`.\n - `GET /report/summary/average_local_growth_per_day`.\n * Added `GET /node/stats` which returns stats for all nodes.\n * Added `GET /cluster/{id}/security/password/zxcvbn` to return\n the enabled or disabled status of ZXCVBN validation for new passwords.\n * Added `POST /cluster/{id}/security/password/zxcvbn` to toggle\n ZXCVBN validation for new passwords.\n\n ### Changes to Internal API in Rubrik version 5.0.0\n ## Breaking changes:\n * Removed `/user_notification` endpoints.\n * Added `rawName` field in `ArchivalLocationSummary`, which contains the\n raw name of the archival location.\n * Removed `shareType` from config field in PATCH /managedvolume request.\n * Changed `/cluster/me/ntp_server` endpoint to accept symmetric keys\n and the corresponding hashing algorithm.\n * Removed `/job/type/prune_job_instances` endpoint.\n * Removed `/kmip/configuration` endpoint.\n * Removed `/session/api_token` endpoint.\n * Added `subnet` field in `ManagedVolumeConfig`, which specifies an outgoing\n VLAN interface for a Rubrik node. This is a required value when creating a\n managed volume on a Rubrik node that has multiple VLAN interfaces.\n * Removed the `VolumeGroupVolumeSummary`, and replaced it with\n `HostVolumeSummary`.\n * Removed `volumeIdsIncludedInSnapshots` from `VolumeGroupDetail`.\n * Added new optional fields `mssqlCbtEnabled`, `mssqlCbtEffectiveStatus`,\n `mssqlCbtDriverInstalled`, `hostVfdEnabled` and `hostVfdDriverState` to\n GET /host/{id} response.\n * Responses for `/cluster/{id}/dns_nameserver` and\n `/cluster/{id}/dns_search_domain` changed to be array of strings.\n * Added new required field `language` in `UserPreferencesInfo` for\n GET /user/{id}/preferences and PATCH /user/{id}/preferences\n * Added new field `missedSnapshotTimeUnits` in `MissedSnapshot`.\n * Removed `localStorage` and `archiveStorage` from `UnmanagedSnapshotSummary`.\n * Moved the `Turn on or off a given AWS cloud instance` endpoint from PATCH of\n `/cloud_on/aws/instance` to PATCH of `/cloud_on/aws/instance/{id}/cloud_vm`.\n Also removed the `id` field from the definition of `CloudInstanceUpdate`.\n * Moved the `Turn on or off a given Azure cloud instance` endpoint from PATCH\n of `/cloud_on/azure/instance` to PATCH of\n `/cloud_on/azure/instance/{id}/cloud_vm`. Also removed the `id` field from\n the definition of `CloudInstanceUpdate`.\n * Moved the `Delete a given AWS cloud instance` endpoint from DELETE of\n `/cloud_on/aws/instance/{id}` to DELETE of\n `/cloud_on/aws/instance/{id}/cloud_vm`.\n * Moved the `Delete a given Azure cloud instance` endpoint from DELETE of\n `/cloud_on/azure/instance/{id}` to DELETE of\n `/cloud_on/azure/instance/{id}/cloud_vm`.\n * Modified the existing endpoint DELETE `/cloud_on/aws/instance/{id}` to\n remove entry of a given AWS cloud instance instead of terminating the\n instance.\n * Modified the existing endpoint DELETE `/cloud_on/azure/instance/{id}` to\n remove entry of a given Azure cloud instance instead of terminating the\n instance.\n * Removed `/job/type/job-schedule_gc_job_start_time_now` endpoint. Use\n endpoint POST `/job/type/garbageCollection` to schedule a GC job to\n run now.\n * Removed `config` parameter from `/job/type/garbageCollection`.\n * Added optional parameter `jobInstanceId` to `EventSummary`.\n * Added `jobInstanceId` as a new optional query parameter for\n GET /event_series/{id}/status endpoint.\n * Modified the endpoint GET /event_series/status to a POST and changed the\n input parameter to a request body of type `EventSeriesDetail`.\n * Modified the endpoint PATCH /replication/target/{id} to take a request body\n of type ReplicationTargetUpdate instead of ReplicationTargetDefinition.\n * Added Discovery EventType.\n * Added `name` and deprecated `hostname` in `HostSummary` and `HostDetail`.\n response.\n * Added `isDeleted` and deprecated `isArchived` in MssqlDbReplica response.\n * Removed `GET /stats/cloud_storage` endpoint.\n * Removed DELETE /oracle/db/{id} endpoint to delete an Oracle database.\n * By default, a volume group is not associated with any volumes at creation\n time. This default is a change from the 4.2 implementation, where newly\n created volume groups contain all of the host volumes. On 5.0 clusters,\n use the `GET /host/{id}/volume` endpoint to query all host volumes.\n\n ## Feature Additions/improvements:\n * Added new endpoint POST/report/data-source/{data_source_name} to get columns\n directly from report data source.\n * Added optional field compliance24HourStatus to RequestFilters object.\n * Added GET /event/event_count-by-status to get job counts based on job status.\n * Added GET /event/event_count-by-job-type to get job counts based on job type.\n * Added GET /event_series endpoint to get all event series information in the\n past 24 hours.\n * Added `oracleDatabase` to ManagedObjectDescendantCounts.\n * Introduced `POST /session/realm/{name}` endpoint to generate session\n tokens in the LDAP display name of {name}.\n * Added optional `storageClass` field to `ObjectStoreReaderConnectDefinition`.\n to store `storageClass` field for the newly connected reader location.\n * Added optional `encryptionType` field to `ObjectStoreLocationSummary` to\n return encryption type used for an object store archival location.\n * Added a new endpoint POST /oracle/db/download/{snapshot_id} to download\n a particular snapshot (and corresponding logs) for Oracle.\n * Added optional `ownerId` and `reference` fields to\n `/managed_volume/{id}/begin_snapshot`.\n * Added new endpoints regarding references to Managed Volumes, which track\n the processes writing to the Managed Volume.\n - GET `/managed_volume/{id}/snapshot/{snapshot_id}/reference/{reference_id}`.\n PUT `/managed_volume/{id}/snapshot/{snapshot_id}/reference/{reference_id}`.\n PATCH\n `/managed_volume/{id}/snapshot/{snapshot_id}/reference/{reference_id}`.\n DELETE\n `/managed_volume/{id}/snapshot/{snapshot_id}/reference/{reference_id}`.\n are the endpoints for viewing, adding, editing and deleting a Managed\n Volume snapshot reference respectively.\n * Added optional `apiToken` and `apiEndpoint` fields to NasConfig to support\n Pure FlashBlade devices.\n * Added optional `smbValidIps`, `smbDomainName` and `smbValidUsers` fields\n to `VolumeGroupMountSnapshotJobConfig` to support secure SMB.\n * Added optional `smbDomainName`, `smbValidIps`, `smbValidUsers` fields to\n ManagedVolumeExportConfig to support secure SMB.\n * Added a new optional field `oracleSysDbaUser` to /host/{id} POST endpoint\n during register host for setting the Oracle username for account with sysdba\n privileges on this host.\n * Added a new endpoint DELETE /smb/domain/{domain_name} to delete the\n SMB Domain.\n * Added a new endpoint POST /smb/domain/{domain_name}/join to configure\n SMB Domain.\n * Added a new optional filed `oracleSysDbaUser` to /host/{id} endpoint for\n changing the Oracle username for account with sysdba privileges on this\n host.\n * Added a new endpoint POST /smb/enable_security to enable Live Mount\n security\n * Made the `numChannels` field in ManagedVolumeConfig optional.\n * Added `applicationTag` field to ManagedVolumeConfig to specify workload\n type for a managed volume.\n * Added Maintenance EventType\n * Added POST `/report/global_object` endpoint to directly query table data\n from GlobalObject based on ReportTableRequest\n * Added new API endpoint GET `/diagnostic/snappable/{id}` returns\n diagnostic information of all backup tasks of a data source.\n * Added new API endpoint GET `/diagnostic/snappable/{id}/latest` returns\n diagnostic information of the most recent backup task of a data source.\n * Added `shareType` field to ManagedVolumeSummary and ManagedVolumeDetail.\n * Added oracle instant recovery API to trigger instant recovery of a\n database.\n * Added RAC, Oracle host and Oracle database fields to the the oracle\n hierarchy API\n * Added a new endpoint GET /smb/domain to get a list of discovered\n SMB domains in the environment.\n * Added a new endpoint GET /notification_setting to get all Notification\n Settings.\n * Added a new endpoint POST /notification_setting to create a new\n Notification Setting.\n * Added a new endpoint GET /notification_setting/{id} to get a Notification\n Setting specified by the input id.\n * Added a new endpoint PATCH /notification_setting/{id} to update the values\n for a specified Notification Setting.\n * Added a new endpoint DELTE /notification_setting/{id} to delete a\n specified Notification Setting.\n * Introduced `POST /oracle/db/snapshot/{id}/export/tablespace` endpoint to\n trigger the export of a single tablespace in an Oracle database.\n * Added a new optional field `shouldRestoreFilesOnly` to POST\n /oracle/db/snapshot/{id}/export endpoint, used when exporting an Oracle\n database, to specify whether the user requires a full recovery of the\n database or a restoration of the database files.\n * Added /oracle/hierarchy/{id}/children endpoint to get children of\n object in Oracle hierarchy\n * Added /oracle/hierarchy/{id}/descendants endpoint to get descendants of\n object in Oracle hierarchy\n * Added a new endpoint POST /fileset/{id}/unprotect, which can be used to\n unprotect a fileset and specify a retention policy to apply to existing\n snapshots.\n * Added a new optional field `existingSnapshotRetention` to POST\n /sla_domain/{id}/assign, used when unprotecting an object, to specify whether\n to retain existing snapshots according to the current SLA domain, keep\n existing snapshots forever, or expire all snapshots immediately. If not\n specified, this field will default to the existing behavior of keeping\n snapshots forever.\n * Introduced `GET /kmip/client` endpoint to get the stored KMIP client\n configuration.\n * Introduced `PUT /kmip/client` endpoint to set the KMIP client configuration.\n * Introduced `GET /kmip/server` endpoint to get stored KMIP server\n information.\n * Introduced `PUT /kmip/server` endpoint to add a a KMIP server.\n * Introduced `DELETE /kmip/server` endpoint to remove a a KMIP server.\n * Introduced `POST /session` endpoint to generate session tokens.\n * Added a new optional field `mfaServerId` to /user endpoint for\n associating a configured MFA server.\n * Added REST support for Oracle RAC, Oracle Host.\n Updated the detail and summary for Oracle Database.\n * Added support to run on-demand backup jobs, export snapshots, live\n mount for Oracle Database.\n * Introduced `POST /mfa/rsa/server` endpoint to\n create a new RSA server configuration for MFA integration.\n * Introduced `GET /mfa/rsa/server` endpoint to\n get a list of RSA server configured for MFA integration.\n * Introduced `PATCH /mfa/rsa/server/{id}` endpoint to\n modify RSA server configuration.\n * Introduced `GET /mfa/rsa/server/{id}` endpoint to\n get RSA server configuration.\n * Introduced `POST /mfa/initialize` to initialize an attempt\n to perform Multifactor authentication for a user.\n * Introduced `POST /mfa/session` to perform Multifactor\n authentication for a user.\n * Introduced `POST /session/api_token` to create an API Token.\n * Added a new optional field `isArrayEnabled` to `FilesetTemplateCreate`.\n for creation of storage array-enabled fileset templates. We also include\n this new field in `FilesetTemplateDetail`.\n * Added a new optional field `arraySpec` to `FilesetCreate` for\n creation of storage array-enabled filesets. We also include\n this new field in `FilesetSummary` and `FilesetDetail`.\n * Introduced `GET /cluster/{id}/is_azure_cloud_only` to query if the cluster\n supports only Azure public cloud.\n * Introduced `POST /unmanaged_object/assign_retention_sla` to set Retention\n SLA of unmanaged objects.\n * Introduced `POST /unmanaged_object/snapshot/assign_sla` to set Retention\n SLA of unmanaged snapshots.\n * Introduced `POST /mssql/db/bulk/snapshot/{id}` to take an on-demand snapshot\n of multiple SQL Server databases. The result of this asynchronous request\n can be obtained from `GET /mssql/db/bulk/snapshot/{id}`.\n * Added a new field unprotectable_reasons to GET /mssql/db/{id} and\n GET /mssql/instance/{id}. This field keeps track of the reasons that a\n SQL Server database or instance cannot be protected by Rubrik.\n * Introduced a new `GET /cluster/me/login_banner` and\n `PUT /cluster/me/login_banner` endpoints to get and set the banner\n that displays after each successful login.\n * Introduced a new `GET /cluster/me/security_classification` and\n `PUT /cluster/me/security_classification` endpoints to get and set\n the security classification banner for the cluster. The cluster UI\n displays the banner in the specified color.\n * Introduced `GET /cluster/{id}/security/rksupport_cred` to provide\n the status of the rksupport credentials.\n * Introduced `POST /cluster/{id}/security/rksupport_cred` to update\n the cluster-wide credentials for the specified cluster.\n * Introduced `POST /vmware/vm/snapshot/{id}/mount_disks` to attach VMDKs\n from a mount snapshot to an existing virtual machine\n * Introduced new `GET /host/{id}/volume` endpoint to query the HostVolume\n from the host.\n * Added the `HostVolumeSummary`, which is the response of the endpoint\n `GET /host/{id}/volume` and a part of `VolumeGroupDetail`.\n * Introduced a new `GET /volume_group/host_layout/{snapshot_id}` and\n `GET /volume_group/{host_id}/host_layout` to get the Windows host layout\n of all disks and volumes.\n * Added `WindowsHostLayout` which is the response of\n `GET /volume_group/host_layout/{snapshot_id}` and\n `GET /volume_group/{host_id}/host_layout`.\n * Added support for Blueprint.\n * Added new fields `retentionSlaDomainId` and `retentionSlaDomainName` to\n UnmanagedObjectSummary object, which is returned from a\n `GET /unmanaged_object` call.\n * Removed `unmanagedSnapshotCount` and added new fields `autoSnapshotCount`.\n and `manualSnapshotCount` to UnmanagedObjectSummary object, which is\n returned from a `GET /unmanaged_object` call.\n * Added new fields `retentionSlaDomainId` and `retentionSlaDomainName` to\n UnmanagedSnapshotSummary object, which is returned from a\n `GET /unmanaged_object/{id}/snapshot` call.\n * Added a new field `hasAttachingDisk` to `GET /vmware/vm/snapshot/mount` and\n `GET /vmware/vm/snapshot/mount/{id}` that indicates to the user whether\n this is an attaching disk mount job.\n * Added a new field `attachingDiskCount` to `GET /vmware/vm/snapshot/mount`.\n and `GET /vmware/vm/snapshot/mount/{id}` that indicate to the user how many\n disks are attached.\n * Added field `RetentionSlaDomainName` to sort_by of a\n `GET * /unmanaged_object/{id}/snapshot` call.\n * Added field `excludedDiskIds` to NutanixVmDetail which is returned from a\n `GET /nutanix/vm/{id}` to exclude certain disks from backup. Also added\n field to NutanixVmPatch via `PATCH /nutanix/vm/{id}` to allow the field\n to be updated.\n * Introduced the `PATCH /aws/ec2_instance/indexing_state` endpoint for\n enabling/disabling indexing per EC2 instance.\n * Added new optional fields `organizationId` and `organizationName` to\n `/host/{id}` and `/host` endpoints to get the organization a host is\n assigned to due to Envoy.\n * Introduced a new `GET /host/envoy` endpoint. Acts similar to queryHost but\n also includes Envoy organization info if Envoy is enabled.\n * Added a new endpoint `GET /vmware/vcenter/{id}/tag_category` to get a list of\n Tag Categories associated with a vCenter.\n * Added a new endpoint `Get /vmware/vcenter/tag_category/{tag_category_id}` to\n get a specific Tag Category associated with a vCenter.\n * Added a new endpoint `GET /vmware/vcenter/{id}/tag` to get a list of Tags\n associated with a vCenter. The optional category_id parameter allow the\n response to be filtered by Tag Category.\n * Added a new endpoint `GET /vmware/vcenter/tag/{tag_id}` to get a\n specific Tag associated with a vCenter.\n * Introduced `GET /cluster/{id}/global_manager_connectivity` to\n retrieve a set of URLs that are pingable from the CDM cluster.\n * Added optional field `instanceName` in `ManagedObjectProperties`.\n * Added new endpoint GET `/cloud_on/aws/app_image/{id}` to retrieve a specified\n AWS AppBlueprint image.\n * Added new endpoint DELETE `/cloud_on/aws/app_image/{id}` to delete the\n given AWS AppBlueprint image.\n * Added new endpoint GET `/cloud_on/azure/app_image/{id}` to retrieve a\n specified Azure AppBlueprint image.\n * Added new endpoint DELETE `/cloud_on/azure/app_image/{id}` to delete the\n given Azure AppBlueprint image.\n * Added organization endpoint for Oracle.\n * Added new endpoint GET `/cloud_on/aws/app_image` to retrieve all\n AWS AppBlueprint images.\n * Added new endpoints `GET /stats/cloud_storage/physical`, `GET\n /stats/cloud_storage/ingested` and `GET /stats/cloud_storage/logical` which\n return respective stats aggregated across all archival locations\n * Added a new endpoint `POST /vmware/standalone_host/datastore` to get a list\n of datastore names for a given ESXi host.\n * Added a new optional field `apiEndpoint` to `NasBaseConfig`.\n\n ### Changes to Internal API in Rubrik version 4.2\n ## Breaking changes:\n * Introduced a new `GET /cluster/{id}/ipv6` endpoint for getting all IPv6\n addresses configured on a specific or all network interfaces.\n * Introduced a new `PATCH /cluster/{id}/ipv6` endpoint for configuring IPv6\n addresses on a specific network interface for each nodes in cluster.\n * Introduced a new `GET /cluster/{id}/trial_edge` for getting whether the\n cluster is a trial edge.\n * Moved the /auth_domain/ endpoint from internal APIs to the v1 APIs.\n * Deprecated `POST /archive/nfs/reconnect` endpoint. Use\n `POST /archive/nfs/reader/connect` instead to connect as a reader to an\n existing NFS archival location.\n * Deprecated `POST /archive/object_store/reconnect` endpoint. Use\n `POST /archive/object_store/reader/connect` instead to connect as a reader to\n an existing object store location.\n * Deprecated `POST /archive/qstar/reconnect` endpoint. Use\n `POST /archive/qstar/reader/connect` instead to connect as a reader to an\n existing QStar archival location.\n * Deprecated `POST /archive/dca/reconnect` endpoint. Use\n `POST /archive/dca/reader/connect` instead to connect as a reader to an\n existing DCA archival location.\n * Removed `POST /hyperv/vm/snapshot/{id}/restore_file` endpoint. Use\n `POST /hyperv/vm/snapshot/{id}/restore_files` instead to support\n multi-files restore for Hyper-V vm.\n * Removed `POST /nutanix/vm/snapshot/{id}/restore_file` endpoint. Use\n `POST /nutainx/vm/snapshot/{id}/restore_files` instead to support\n multi-files restore for Nutanix vm.\n * Removed `search_timezone_offset` parameter from\n `GET /unmanaged_object/{id}/snapshot` endpoint. The endpoint will now\n use configured timezone on the cluster.\n * Renamed the field `id` in `UserDefinition` to `username` for `POST /user`.\n endpoint.\n * Removed the `/mssql/db/sla/{id}/availability_group_conflicts` endpoint.\n * Removed the `/mssql/db/sla/{id}/assign` endpoint.\n * Added support for Envoy VMs for Organization.\n * Modified the `DELETE /storage/array/{id}` endpoint so that it now triggers\n an asynchronous deletion job, responds with an async request object, and\n archives the storage array's hierarchy.\n * Added `numStorageArrayVolumeGroupsArchived` to `DataLocationUsage` which\n is the response of the `GET /stats/data_location/usage` endpoint.\n * Modified `POST /storage/array` endpoint so that it now triggers an\n asynchronous refresh job, and responds with an async request object.\n * Modified the `GET /storage/array/{id}` and `DELETE /storage/array/{id}`.\n endpoints so that the `id` field now corresponds to the managed ID\n instead of the simple ID. The `managed ID` is the ID assigned to the\n storage array object by the Rubrik REST API server.\n * Moved /throttle endpoint to /backup_throttle.\n * Introduced a new `EmailSubscriptionUpdate` object for the request of the\n `PATCH /report/email_subscription/{subscription_id}` endpoint.\n * Introduced a new `ReportSubscriptionOwner` object for the response of\n `GET /report/email_subscription/{subscription_id}` and\n `GET /report/{id}/email_subscription` endpoints.\n * Added the envoyStatus field to the response of the GET /organization\n endpoint.\n * Added new `attachments` field to the `POST /report/{id}/email_subscription`.\n and `PATCH /report/email_subscription/{subscription_id}` endpoints.\n * Removed fields `length` and `isLog` in response of\n `/mssql/db/{id}/restore_files`.\n * Moved the `/cluster/decommissionNode` endpoint to\n `/cluster/decommissionNodes`. The `DecommissionNodeConfig` object is renamed\n as `DecommissionNodesConfig` and now takes in a list of strings which\n correspond to the IDs of the nodes that are to be decommissioned.\n * Moved the `POST /vmware/vm/{id}/register_agent` endpoint from internal\n APIs to the v1 APIs.\n * Added a required field for environment in AzureComputeSummary to support\n Azure Gov Cloud.\n * Remove `POST internal/vmware/vm/snapshot/{id}/mount` endpoint. Use public\n API of `POST v1/vmware/vm/snapshot/{id}/mount`.\n * The input field OperatingSystemType value `Linux` is replaced by `UnixLike`.\n in FilesetTemplateCreateDefinition, used by POST /fileset-template, and\n in FilesetTemplatePatchDefinition, used by PATCH /fileset_template/{id}.\n * The input field operating_system_type value `Linux` is replaced by `UnixLike`.\n in GET /host-fileset and GET /host-count.\n * Added `snmpAgentPort` field to SnmpConfig object.\n\n ## Feature Additions/improvements:\n * Introduced the `GET /node_management/default_gateway` and `POST\n /node_management/default_gateway` endpoint to get and set default gateway.\n * Introduced the `GET cloud_on/aws/instance_type_list` and `GET\n cloud_on/azure/instance_type_list` endpoint to fetch list of instance types\n for aws and azures.\n * Introduced the `GET /aws/account/{id}/subnet` endpoint to fetch an\n information summary for each of the subnets available in an AWS account.\n * Introduced the `GET /aws/account/{id}/security_group` endpoint to fetch an\n information summary for each of the security groups belonging to a particular\n virtual network in an AWS account.\n * Moved definitions `Subnet` and `SecurityGroup` of `definitions/cloud_on.yml`.\n to `definitions/cloud_common.yml` so that both the CloudOn and CloudNative\n features can use them.\n * Introduced the `GET /host/{id}/diagnose` endpoint to support target host\n diagnosis features. Network connectivity (machine/agent ping) implemented\n in the current version.\n * Added vCD endpoints to support vCloud Director. The following endpoints\n have been added to the vcdCluster object:\n - `POST /vcd/cluster` to add a new vCD cluster object.\n * Added support for CRUD operations on vCloud Director cluster objects.\n - POST /vcd/cluster, PATCH /vcd/cluster/{id}, DELETE /vcd/cluster/{id},\n POST /vcd/cluster/{id}/refresh are the endpoints for adding, editing,\n deleting and refreshing a vCD cluster object respectively.\n * Introduced endpoint `GET /search/snapshot_search` to search files in a\n given snapshot. The search supports prefix search only.\n * Introduced the new `POST /storage/array/{id}/refresh` endpoint to\n create a new refresh job to update the Storage Array metadata.\n * Introduced the new `GET /storage/array/request/{id}` endpoint to\n get status of a storage array-related asynchronous request.\n * Introduced the new `POST /storage/array/volume/group` endpoint\n to add a new storage array volume group.\n * Introduced the new `GET /storage/array/volume/group/{id}` endpoint\n to get details of a storage array volume group.\n * Introduced the new `DELETE /storage/array/volume/group/{id}` endpoint\n to remove a storage array volume group.\n * Introduced the new `GET /storage/array/hierarchy/{id}` endpoint\n to get a summary of an object in the storage array hierarchy.\n * Introduced the new `GET /storage/array/hierarchy/{id}/children` endpoint\n to get the children of an object in the storage array hierarchy.\n * Introduced the new `GET /storage/array/hierarchy/{id}/descendants` endpoint\n to get the descendants of an object in the storage array hierarchy.\n * Introduced the new `GET /storage/array/volume` endpoint to get\n summary information of all storage array volumes.\n * Introduced the new `GET /storage/array/volume/{id}` endpoint to get\n details of a storage array volume.\n * Introduced the new `POST /storage/array/volume/group/{id}/snapshot`.\n endpoint to create a new on-demand backup job for a storage array\n volume group.\n * Introduced the new `PATCH /storage/array/volume/group/{id}` endpoint to\n update the properties of a storage array volume group object.\n * Introduced the new `GET /storage/array/volume/group` endpoint to\n get all storage array volume groups subject to specified filters.\n * Introduced endpoint `POST /archive/location/{id}/owner/pause` to pause\n archiving to a given archival location that is owned by the current cluster.\n * Introduced endpoint `POST /archive/location/{id}/owner/resume` to resume\n archiving to a given archival location that is owned by the current cluster.\n * Introduced endpoint `POST /archive/location/{id}/reader/promote` to promote\n the current cluster to be the owner of a specified archival location that is\n currently connected as a reader location.\n * Introduced endpoint `POST /archive/location/{id}/reader/refresh` to sync the\n current reader cluster with the contents on the archival location. This pulls\n in any changes made by the owner cluster to the archival location since the\n last time the current cluster was synced.\n * Introduced endpoint `POST /archive/dca/reader/connect` to connect as a reader\n to a DCA archival location.\n * Introduced endpoint `POST /archive/nfs/reader/connect` to connect as a reader\n to an NFS archival location.\n * Introduced endpoint `POST /archive/object_store/reader/connect` to connect as\n a reader to an object store location.\n * Introduced endpoint `POST /archive/dca/qstar/connect` to connect as a reader\n to a QStar archival location.\n * Updated `ArchivalLocationSummary` returned by `GET /archive/location`.\n endpoint to include `ownershipStatus` field, to indicate whether the current\n cluster is connected to the archival location as an owner (active or paused),\n as a reader, or if the archival location is deleted.\n * Added the `ca_certs` field to `StorageArrayDefinition` to allow admins\n to specify certificates used for validation when making network\n requests to the storage array API service. This effects endpoints\n `POST /storage/array`, `GET /storage/array/{id}`, and\n `PUT /storage/array/{id}`.\n * Introduced the `POST /vmware/vm/snapshot/{id}/download_files` endpoint to\n download multiple files/folders from a given vm snapshot. The URL to\n download the zip file including the files will be presented to the users.\n * Introduced the `POST /fileset/snapshot/{id}/download_files` endpoint to\n download multiple files/folders from a given fileset snapshot. The URL to\n download the zip file including the specific files/folders will be presented\n to the users.\n * Introduced the `POST /nutanix/vm/snapshot/{id}/download_files` endpoint to\n download multiple files/folders from a given nutanix snapshot. The URL to\n download the zip file including the specific files/folders will be presented\n to the users.\n * Removed the `POST /nutanix/vm/snapshot/{id}/download_file` endpoint as\n downloading a single file/folder from the nutanix backup is just a special\n case of downloading multiple files/folders.\n * Introduced the `POST /hyperv/snapshot/{id}/download_files` endpoint to\n download multiple files/folders from a given Hyper-V snapshot. The URL to\n download the zip file including the specific files/folders will be presented\n to the users.\n * Introduced the POST /managed_volume/snapshot/{id}/download_files endpoint\n to download multiple files and/or folders from a given managed volume\n snapshot. This endpoint returns the URL to download the ZIP file that\n contains the specified files and/or folders.\n * Introduced the new `GET /storage/array/volume/group/{id}/search` endpoint to\n search storage array volume group for a file.\n * Introduced the new `GET /storage/array/volume/group/snapshot/{id}`.\n endpoint to retrieve details of a storage array volume group snapshot.\n * Introduced the new `DELETE /storage/array/volume/group/snapshot/{id}`.\n endpoint to remove a storage array volume group snapshot.\n * Introduced the new `DELETE /storage/array/volume/group/{id}` endpoint\n to delete all snapshots of a storage array volume group.\n * Introduced the new `POST /storage/array/volume/group/{id}/download`.\n endpoint to download a storage array volume group snapshot from archival.\n * Introduced new `GET/storage/array/volume/group/snapshot/{id}/restore_files`.\n endpoint to restore files from snapshot of a storage array volume group.\n * Added storage volume endpoints for AWS cloud native workload protection.\n Endpoints added:\n - GET /aws/ec2_instance/{id}/storage_volume/ to retrieve\n all storage volumes details attached to an ec2 instance object.\n - GET /aws/ec2_instance/{ec2_instance_id}/storage_volume/{id} to retrieve\n details of a storage volume attached to an ec2 instance object.\n - POST /aws/ec2_intance/snapshot/{id}/export to export the snapshot of\n an ec2 instance object to a new ec2 instance object.\n * Introduced the new `POST /storage/array/volume/group/{id}/download_file`.\n endpoint to download a file from an archived storage array volume group\n snapshot.\n * Introduced the new `GET /storage/array/volume/group/{id}/missed_snapshot`.\n endpoint to get details about all missed snapshots of a storage array volume\n group.\n * Introduced the `GET /network_throttle` endpoint for retrieving the list of\n network throttles.\n * Introduced the `PATCH /network_throttle/{id}` endpoint for updating\n network throttles.\n * Introduced the new `GET /storage/array/host/{id}` endpoint to get details\n about all storage array volumes connected to a host.\n * Introduced the `GET /organization/{id}/storage/array` endpoint for getting\n information for authorized storage array resources in an organization.\n * Introduced the `GET /organization/{id}/storage/array/volume_group/metric`.\n endpoint for getting storage array volume groups metrics in an\n organization.\n * Introduced the new POST /vmware/vm/snapshot/mount/{id}/rollback endpoint to\n rollback the datastore used by a virtual machine, after an Instant Recovery\n that used the preserve MOID setting. This endpoint `rolls back` the\n recovered virtual machine's datastore from the Rubrik cluster to the\n original datastore.\n * Added `owner` and `status` fields to the `EmailSubscriptionSummary`.\n object used in responses for many `/report/{id}/email_subscription`.\n and `/report/email_subscription/{subscription_id}` endpoints.\n * Added `availableSpace` and `readerLocationSummary` fields to the\n `NfsLocationDetail` object used in responses for `/archive/nfs` and\n `/archive/nfs/{id}` endpoints.\n * Added `availableSpace` and `readerLocationSummary` fields to the\n `QstarLocationSummary` object used in responses for the `/archive/qstar`.\n endpoint.\n * Added `availableSpace` and `readerLocationSummary` fields to the\n `QstarLocationDetail` object used in responses for the `/archive/qstar/{id}`.\n endpoint.\n * Added `readerLocationSummary` field to the `ObjectStoreLocationDetail`.\n object used in responses for the `/archive/object_store` and\n `/archive/object_store/{id}` endpoints.\n * Added `readerLocationSummary` field to the `DcaLocationDetail` object\n used in responses for the `/archive/dca` and `/archive/dca/{id}` endpoints.\n * Added a new field `guestOsType` to `HypervVirtualMachineDetail`.\n object used in response of `GET /hyperv/vm/{id}`.\n * Added a new field `guestOsType` to `VirtualMachineDetail`.\n object referred by `VappVmDetail`.\n * Added new field `fileType` in response of `/mssql/db/{id}/restore_files`.\n * Added an optional field `agentStatus` to `VirtualMachineSummary` object used\n in response of `GET /vmware/vm` endpoint. This allows user to check the\n Rubrik Backup Service connection status of the corresponding VMware VM.\n * Introduced the new `POST /fileset/snapshot/{id}/export_files` endpoint to\n export multiple files or directories to destination host.\n * Introduced the new `GET /vmware/config/esx_subnets` endpoint to get the\n the preferred subnets to reach ESX hosts.\n * Introduced the new `PATCH /vmware/config/reset_esx_subnets` endpoint to\n reset the preferred subnets to reach ESX hosts.\n * Changed the `PATCH /vmware/config/reset_esx_subnets` endpoint to\n `PATCH /vmware/config/set_esx_subnets`.\n * Removed the `needsInspection` field from the NodeStatus object returned in\n the `/cluster/{id}/node` and `/node` endpoints.\n * Introduced the new `PATCH /auth_domain/{id}` endpoint to update the Active\n Directory configuration parameters.\n * Introduced the new `GET /cluster/{id}/auto_removed_node` endpoint to\n query for unacknowledged automatic node removals by the Rubrik cluster.\n * Introduced the new\n `DELETE /cluster/{id}/auto_removed_node/{node_id}/acknowledge` endpoint to\n acknowledge an automatic node removal.\n * Introduced the new `GET /cluster/{id}/system_status` endpoint to retrieve\n information about the status of the Rubrik cluster.\n * Changed the `POST /cloud_on/azure/subscription` endpoint to to take\n the parameter `AzureSubscriptionRequest` instead of\n `AzureSubscriptionCredential` in body.\n * Changed the `POST /cloud_on/azure/storage_account` endpoint to to take\n the parameter `AzureStorageAccountRequest` instead of\n `AzureStorageAccountCredential` in body.\n * Changed the `POST /cloud_on/azure/resource_group` endpoint to take\n the parameter `AzureResourceGroupRequest` instead of\n `AzureResourceGroupCredential` in body.\n * Added a `reportTemplate` field to the response of both the\n `GET /report/{id}/table` and `GET /report/{id}/chart` endpoints.\n\n ### Changes to Internal API in Rubrik version 4.1\n ## Changes to support instance from image\n * POST /aws/instance and /azure/instance was supported only from a Rubrik\n snapshot. Now it is changed to support instantiation from Rubrik snapshot as\n well as pre-existing image. Rest end point is same, we just changed the\n CreateCloudInstanceRequest object type.\n * Add a new field `ignoreErrors` to POST /vmware/vm/snapshot/{id}/restore_files\n that will let job restore ignore file errors during restore job.\n ## Breaking changes:\n * None is removed as a Nutanix snapshot consistency mandate so it is no\n longer valid in GET /nutanix/vm, GET /nutanix/vm/{id}, and\n PATCH /nutanix/vm/{id}.\n * computeSecurityGroupId is replaced by the object defaultComputeNetworkConfig\n in ObjectStoreLocationSummary ,ObjectStoreUpdateDefinition and\n ObjectStoreReconnectDefinition which are used by\n GET /archive/object_store/{id}, PATCH /archive/object_store/{id} and\n POST /archive/object_store/reconnect respectively.\n * The PUT /throttle endpoint was changed to provide configuration for\n Hyper-V adaptive throttling. Three parameters were added:\n hypervHostIoLatencyThreshold, hypervHostCpuUtilizationThreshold, and\n hypervVmCpuUtilizationThreshold. To differentiate between the multiple\n hypervisors, the existing configuration parameters for VMware were renamed\n VmwareVmIoLatencyThreshold, VmwareDatastoreIoLatencyThreshold and\n VmwareCpuUtilizationThreshold. These changes also required modifications\n and additions to the GET /throttle endpoint.\n * For `POST /cluster/{id}/node` endpoint, it gets now `AddNodesConfig` in body\n instead of `Map_NodeConfig` directly.\n * For `POST /node_management/replace_node` endpoint, added the `ipmiPassword`.\n field to the `ReplaceNodeConfig` object.\n * For `POST /stats/system_storage` endpoint, added the miscellaneous, liveMount\n and snapshot field to `SystemStorageStats` object.\n * For `POST /principal_search`, removed `managedId` field from the\n `PrincipalSummary` object and changed the `id` field of the\n `PrincipalSummary` object to correspond to the managed id instead of the\n simple id.\n * For `GET /cluster/{id}/timezone` and `PATCH /cluster/{id}/timezone`, the\n functionality has merged into `GET /cluster/{id}` and `PATCH /cluster/{id}`.\n in v1.\n * Removed the `GET /cluster/{id}/decommissionNodeStatus` endpoint.\n Decommission status is now available through queries of the `jobId` that is\n returned by a decommission request. Queries can be performed at the\n `GET /job/{id}` endpoint.\n * For `GET /api/internal/managed_volume/?name=`, the name match is now\n exact instead of infix\n * Updated the list of available attribute and measure values for the `chart0`.\n and `chart1` parameters for the `PATCH /report/{id}` endpoint.\n * Updated the list of available column values for the `table` parameter for the\n `PATCH /report/{id}` endpoint.\n * Updated the `FolderHierarchy` response object to include\n `effectiveSlaDomainId`, `effectiveSlaDomainName`,\n `effectiveSlaSourceObjectId`, and `effectiveSlaSourceObjectName`.\n\n ## Feature Additions/improvements:\n * Added the field `pendingSnapshotCount` to ManagedVolumeSummary and\n ManagedVolumeDetail objects used in responses for endpoints\n `GET /managed_volume`, `POST /managed_volume`, `GET /managed_volume/{id}`,\n `PATCH /managed_volume/{id}`, `GET /organization/{id}/managed_volume`.\n * Introduced the `GET /managed_volume/snapshot/export/{id}` endpoint\n to retrieve details of a specific managed volume snapshot export.\n * Added the `name` filter for GET requests on the /replication/target endpoint.\n This filter allows users to filter results based on the name of a\n replication target.\n * Added the `name` filter for GET requests on the /archive/location endpoint.\n This filter allows users to filter results based on the name of an\n archival location.\n * Added new fields `replicas` and `availabilityGroupId` on GET /mssql\n and GET /mssql/{id}. If a database is an availability database,\n it will have some number of replicas, which are copies of the database\n running on different instances. Otherwise, there will only be one\n replica, which represents the single copy of the database. The field\n `availabilityGroupId` will be set only for availability databases\n and points to the availability group of the database. Also deprecated\n several fields on these endpoints, as they should now be accessed via\n the `replicas` field.\n * Added `Cluster` notification type.\n * Added optional `organizationId` parameter to to the grant/revoke and get\n authorization endpoints. This parameter can be used to\n grant/revoke/get authorizations with respect to a specific Organization.\n * Added endpoint to get/set whether the Rubrik Backup Service is automatically\n deployed to a guest OS.\n * Added cloudInstantiationSpec field to Hyper-V VM endpoint for configuring\n automatic cloud conversion\n * Introduced a new end point /cluster/{id}/platforminfo to GET information\n about the platform the current software is running on\n * Introduced the `GET /organization` and `GET /organization/{id}` endpoints\n for retrieving the list of organizations and a single organization.\n * Introduced the `POST /organization` endpoint for creating organizations,\n the `PATCH /organization/{id}` endpoint for updating organizations and the\n `DELETE /organization/{id}` endpoint for deleting organizations.\n * Introduced the `GET /organization/{id}/stats/storage_growth_timeseries`.\n endpoint and the `GET /organization/{id}/stats/total_storage_usage` for\n getting Physical Storage Growth over Time and Total Physical Storage Usage\n on a per Organization basis.\n * Introduced a number of endpoints of the format\n `GET /organization/{id}/` for retrieving all the resources of\n the corresponding type in a given organization.\n * Introduced a number of endpoints of the format\n `GET /organization/{id}//metric` for retrieving the protection\n counts of the resources of the corresponding type in a given organization.\n * Added the `reportTemplate` filter for GET requests on the /report endpoint.\n This allows queried reports to be filtered and sorted by report template.\n * Introduced the `POST /cluster/{id}/security/password/strength` endpoint\n for assessing the strength of passwords during bootstrap through rkcli.\n * Added a new `ipv6` field in the response of the `GET /cluster/{id}/discover`.\n endpoint.\n * Added relatedIds field for EventSummary object to give more context about\n the event.\n * Added operatingSystemType field for NutanixSummary object. This field\n represents the type of operating system on the Nutanix virtual machine.\n\n ### Changes to Internal API in Rubrik version 4.0\n ## Breaking changes:\n * For `GET /unmanaged_object` endpoint, replaced the `Fileset` of object_type\n filter with more specific object types: `WindowsFileset`, `LinuxFileset` and\n `ShareFileset`. Also added filter value for additional unmanaged objects\n we now support.\n * For /mssql/db/{id}/compatible_instance added recoveryType as mandatory\n query parameter\n\n ## Feature Additions/improvements:\n * Added QStar end points to support it as an archival location. The location\n is always encrypted and an encryption password must be set while adding the\n location. End points added:\n - `DELETE /archive/qstar` to clean up the data in the bucket in the QStar\n archival location.\n - `GET /archive/qstar` to retrieve a summary of all QStar archival locations.\n - `POST /archive/qstar` to add a QStar archival location.\n - `POST /archive/qstar/reconnect` to reconnect to a specific QStar archival\n location.\n - `POST /archive/qstar/remove_bucket` to remove buckets matching a prefix\n from QStar archival location.\n - `GET /archive/qstar/{id}` to retrieve a summary information from a specific\n QStar archival location.\n - `PATCH /archive/qstar/{id}` to update a specific QStar archival location.\n * Added the `name` filter for GET requests on the /archive/location endpoint.\n This filter allows users to filter results based on the name of an\n archival location.\n * Introduced an optional parameter `encryptionPassword` for the\n `/data_location/nfs` `POST` endpoint. This password is used for\n deriving the master key for encrypting the NFS archival location.\n * Introduced /managed\\_volume, /managed\\_volume/snapshot/export/{id},\n and other child endpoints for creating, deleting, and updating\n Managed Volumes and its exports and snapshots.\n * Added support for Hyper-V.\n * Add new /hierarchy endpoint to support universal hierarchy view.\n * Added support for Nutanix.\n * Moved and merged vCenter refresh status and delete status from independent\n internal endpoints to a single status field in v1 vCenter detail.\n * Added endpoint to get/set whether the Rubrik Backup Service is automatically\n deployed to a guest OS.\n * Introduced an optional parameter `minTolerableNodeFailures` for the\n `/cluster/decommissionNode` `POST` endpoint. This parameter specifies the\n minimum fault tolerance to node failures that must exist when a node is\n decommissioned.\n * Added `nodeId` to `AsyncRequestStatus` to improve debugging job failures.\n\n ### Changes to Internal API in Rubrik version 3.2.0\n ## Breaking changes:\n * Introduced endpoint /host/share/id/search to search for\n files on the network share.\n * Introduced endpoints /host/share and /host/share/id to\n support native network shares under /host endpoint.\n * For /unmanaged_object endpoints, change sort_attr to sort_by\n sort_attr used to accept a comma separated list of column names to sort.\n Now sort_by only accepts a single column name.\n * For /unmanaged_object endpoints, removed the need for object type when\n deleting unmanaged objects and its snapshots.\n\n ## Feature Additions/improvements:\n * Added internal local_ end points. These are used for\n handling operations on per-node auto-scaling config values.\n Please see src/spec/local-config/comments for details.\n * For the response of /mssql/db/{id}/restore_files, added two more fields\n for each file object. They are the original file name and file length\n of the file to be restore.\n * Introduced a new end point /cluster/{id}/is_registered to GET registration\n status. With this change, we can query if the cluster is registered in the\n Rubrik customer database.\n * Introduced a new end point /cluster/{id}/registration_details to POST\n registration details. Customers are expected to get the registration details\n from the support portal. On successful submission of registration details\n with a valid registration id, the cluster will mark itself as registered.\n * For the /mssql/instance/{id} end point, added fields configuredSlaDomainId,\n configuredSlaDomainName, logBackupFrequencyInSeconds, logRetentionHours,\n and copyOnly.\n * Introduced optional parameter keepMacAddresses to\n POST /vmware/vm/snapshot/{id}/mount, /vmware/vm/snapshot/{id}/export, and\n /vmware/vm/snapshot/{id}/instant_recovery endpints.\n This allows new VMs to have the same MAC address as their source VMs.\n\n ## Bug fixes:\n * Made path parameter required in GET /browse. Previously, an error was\n thrown when path was not passed in. This solves that bug.\n", + "x-logo": { + "url": "https://www.rubrik.com/wp-content/uploads/2016/11/Rubrik-Snowflake-small.png" + } + }, + "paths": { + "/polaris/replication/source/replicate_app/{snappable_id}": { + "post": { + "parameters": [ + { + "name": "snappable_id", + "in": "path", + "description": "Snappable ID of which we are replicating snapshots.", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "definition", + "description": "Polaris source pull replicate definition.", + "required": true, + "schema": { + "$ref": "#/definitions/PolarisPullReplicateDefinition" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "definitions": { + "PolarisPullReplicateDefinition": { + "type": "object", + "required": [ + "accessKey", + "isOnDemand", + "polarisId", + "secretKey", + "snapshotInfo" + ], + "properties": { + "polarisId": { + "type": "string", + "description": "Managed ID of the Polaris source cluster." + }, + "snapshotInfo": { + "description": "Info of the snapshot which this cluster is replicating from Polaris.", + "$ref": "#/definitions/ReplicationSnapshotInfo" + }, + "accessKey": { + "type": "string", + "description": "The access key used for accessing customer's volumes to pull replicate snapshots." + }, + "secretKey": { + "type": "string", + "description": "The secret key used for accessing customer's volumes to pull replicate snapshots.", + "x-secret": true + }, + "isOnDemand": { + "type": "boolean", + "description": "Indicates if snapshot is on-demand." + } + } + }, + "ReplicationSnapshotInfo": { + "type": "object", + "required": ["snappableId", "snapshotId"], + "properties": { + "snappableId": { + "type": "string", + "description": "The ID of the snappable stored on this cluster." + }, + "snapshotId": { + "type": "string", + "description": "The ID of the snapshot that is being replicated." + }, + "snapshotDate": { + "type": "integer", + "format": "int64", + "description": "The date when the snapshot was taken in number of milliseconds since the UNIX epoch. This is a required field when the replication source is Polaris." + }, + "snapshotDiskInfos": { + "type": "array", + "description": "An array of the details of the snapshot disks that need to be replicated. This is a required field when the replication source is Polaris.", + "items": { + "$ref": "#/definitions/ReplicationSnapshotDiskInfo" + } + }, + "appMetadata": { + "type": "string", + "description": "Serialized metadata specific to the snappable which is being replicated. This is a required field when the replication source is Polaris." + }, + "childSnapshotInfos": { + "type": "array", + "description": "An array of child snapshots information.", + "items": { + "$ref": "#/definitions/ReplicationSnapshotInfo" + } + } + } + }, + "ReplicationSnapshotDiskInfo": { + "type": "object", + "required": [ + "diskFailoverInfo", + "diskId", + "isOsDisk", + "logicalSizeInBytes", + "snapshotDiskId" + ], + "properties": { + "diskId": { + "type": "string", + "description": "The ID of the disk/volume that is being replicated." + }, + "snapshotDiskId": { + "type": "string", + "description": "The ID of the snapshot of the disk/volume taken on the source that needs to be replicated." + }, + "logicalSizeInBytes": { + "type": "integer", + "format": "int64", + "description": "Size of the disk/volume that is being replicated." + }, + "isOsDisk": { + "type": "boolean", + "description": "Flag to specify if the disk is OS disk." + }, + "diskFailoverInfo": { + "description": "Details specific to the target snappable required to failover the EBS volumes.", + "$ref": "#/definitions/InstanceFailoverInfo" + } + } + }, + "InstanceFailoverInfo": { + "type": "object", + "required": ["originalDiskIdentifier"], + "properties": { + "originalDiskIdentifier": { + "type": "string", + "description": "The identifier used to map the original disks before failover to the disks being replicated. For vmware to AWS, this would be the deviceKey of the vmware virtual disk this EBS volume corresponds to." + } + } + } + } +} diff --git a/openapi3/testdata/issue638/test1.yaml b/openapi3/testdata/issue638/test1.yaml new file mode 100644 index 000000000..f2ab5555c --- /dev/null +++ b/openapi3/testdata/issue638/test1.yaml @@ -0,0 +1,15 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: reference test part 1 + description: reference test part 1 +components: + schemas: + test1a: + $ref: "test2.yaml#/components/schemas/test2a" + test1b: + $ref: "#/components/schemas/test1c" + test1c: + type: int + test1d: + $ref: "test2.yaml#/components/schemas/test2b" diff --git a/openapi3/testdata/issue638/test2.yaml b/openapi3/testdata/issue638/test2.yaml new file mode 100644 index 000000000..d3ca4648b --- /dev/null +++ b/openapi3/testdata/issue638/test2.yaml @@ -0,0 +1,13 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: reference test part 2 + description: reference test part 2 +components: + schemas: + test2a: + type: number + test2b: + $ref: "test1.yaml#/components/schemas/test1b" + test1c: + type: string diff --git a/openapi3/testdata/issue652/definitions.yml b/openapi3/testdata/issue652/definitions.yml new file mode 100644 index 000000000..98ef69254 --- /dev/null +++ b/openapi3/testdata/issue652/definitions.yml @@ -0,0 +1,4 @@ +components: + schemas: + TestSchema: + type: string diff --git a/openapi3/testdata/issue652/nested/schema.yml b/openapi3/testdata/issue652/nested/schema.yml new file mode 100644 index 000000000..ef321a101 --- /dev/null +++ b/openapi3/testdata/issue652/nested/schema.yml @@ -0,0 +1,4 @@ +components: + schemas: + ReferenceToParentDirectory: + $ref: "../definitions.yml#/components/schemas/TestSchema" diff --git a/openapi3/testdata/issue697.yml b/openapi3/testdata/issue697.yml new file mode 100644 index 000000000..71a4b2ae2 --- /dev/null +++ b/openapi3/testdata/issue697.yml @@ -0,0 +1,14 @@ +openapi: 3.0.1 +components: + schemas: + API: + properties: + dateExample: + type: string + format: date + example: 2019-09-12 +info: + title: sample + version: version not set +paths: {} + diff --git a/openapi3/testdata/issue753.yml b/openapi3/testdata/issue753.yml new file mode 100644 index 000000000..2123a6dbd --- /dev/null +++ b/openapi3/testdata/issue753.yml @@ -0,0 +1,53 @@ +openapi: '3' +info: + version: 0.0.1 + title: 'test' +paths: + /test1: + post: + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: 'test' + callbacks: + callback1: + '{$request.body#/callback}': + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/test' + responses: + '200': + description: 'test' + /test2: + post: + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: 'test' + callbacks: + callback2: + '{$request.body#/callback}': + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/test' + responses: + '200': + description: 'test' +components: + schemas: + test: + type: string diff --git a/openapi3/testdata/link-example.yaml b/openapi3/testdata/link-example.yaml new file mode 100644 index 000000000..735e7dbb7 --- /dev/null +++ b/openapi3/testdata/link-example.yaml @@ -0,0 +1,203 @@ +openapi: 3.0.0 +info: + title: Link Example + version: 1.0.0 +paths: + /2.0/users/{username}: + get: + operationId: getUserByName + parameters: + - name: username + in: path + required: true + schema: + type: string + responses: + '200': + description: The User + content: + application/json: + schema: + $ref: '#/components/schemas/user' + links: + userRepositories: + $ref: '#/components/links/UserRepositories' + /2.0/repositories/{username}: + get: + operationId: getRepositoriesByOwner + parameters: + - name: username + in: path + required: true + schema: + type: string + responses: + '200': + description: repositories owned by the supplied user + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/repository' + links: + userRepository: + $ref: '#/components/links/UserRepository' + /2.0/repositories/{username}/{slug}: + get: + operationId: getRepository + parameters: + - name: username + in: path + required: true + schema: + type: string + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: The repository + content: + application/json: + schema: + $ref: '#/components/schemas/repository' + links: + repositoryPullRequests: + $ref: '#/components/links/RepositoryPullRequests' + /2.0/repositories/{username}/{slug}/pullrequests: + get: + operationId: getPullRequestsByRepository + parameters: + - name: username + in: path + required: true + schema: + type: string + - name: slug + in: path + required: true + schema: + type: string + - name: state + in: query + schema: + type: string + enum: + - open + - merged + - declined + responses: + '200': + description: an array of pull request objects + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/pullrequest' + /2.0/repositories/{username}/{slug}/pullrequests/{pid}: + get: + operationId: getPullRequestsById + parameters: + - name: username + in: path + required: true + schema: + type: string + - name: slug + in: path + required: true + schema: + type: string + - name: pid + in: path + required: true + schema: + type: string + responses: + '200': + description: a pull request object + content: + application/json: + schema: + $ref: '#/components/schemas/pullrequest' + links: + pullRequestMerge: + $ref: '#/components/links/PullRequestMerge' + /2.0/repositories/{username}/{slug}/pullrequests/{pid}/merge: + post: + operationId: mergePullRequest + parameters: + - name: username + in: path + required: true + schema: + type: string + - name: slug + in: path + required: true + schema: + type: string + - name: pid + in: path + required: true + schema: + type: string + responses: + '204': + description: the PR was successfully merged +components: + links: + UserRepositories: + # returns array of '#/components/schemas/repository' + operationId: getRepositoriesByOwner + parameters: + username: $response.body#/username + UserRepository: + # returns '#/components/schemas/repository' + operationId: getRepository + parameters: + username: $response.body#/owner/username + slug: $response.body#/slug + RepositoryPullRequests: + # returns '#/components/schemas/pullrequest' + operationId: getPullRequestsByRepository + parameters: + username: $response.body#/owner/username + slug: $response.body#/slug + PullRequestMerge: + # executes /2.0/repositories/{username}/{slug}/pullrequests/{pid}/merge + operationId: mergePullRequest + parameters: + username: $response.body#/author/username + slug: $response.body#/repository/slug + pid: $response.body#/id + schemas: + user: + type: object + properties: + username: + type: string + uuid: + type: string + repository: + type: object + properties: + slug: + type: string + owner: + $ref: '#/components/schemas/user' + pullrequest: + type: object + properties: + id: + type: integer + title: + type: string + repository: + $ref: '#/components/schemas/repository' + author: + $ref: '#/components/schemas/user' diff --git a/openapi3/testdata/lxkns.yaml b/openapi3/testdata/lxkns.yaml new file mode 100644 index 000000000..6e1bee5d6 --- /dev/null +++ b/openapi3/testdata/lxkns.yaml @@ -0,0 +1,988 @@ +# https://raw.githubusercontent.com/thediveo/lxkns/71e8fb5e40c612ecc89d972d211221137e92d5f0/api/openapi-spec/lxkns.yaml +openapi: 3.0.2 +security: + - {} +info: + title: lxkns + version: 0.22.0 + description: |- + Discover Linux-kernel namespaces, almost everywhere in a Linux host. Also look + for mount points and their hierarchy, as well as for containers. + contact: + url: 'https://github.com/thediveo/lxkns' + license: + name: Apache 2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0' +servers: + - + url: /api + description: lxkns as-a-service +paths: + /processes: + summary: Process discovery + get: + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ProcessTable' + description: |- + Returns information about all processes and their position within the process + tree. + summary: Linux processes + description: |- + Map of all processes in the process tree, with the keys being the PIDs in + decimal string format. + /pidmap: + summary: Discover the translation of PIDs between PID namespaces + get: + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PIDMap' + description: |- + The namespaced PIDs of processes. For each process, the PIDs in their PID + namespaces along the PID namespace hierarchy are returned. + summary: PID translation data + description: | + Discovers the PIDs that processes have in different PID namespaces, + according to the hierarchy of PID namespaces. + + > **IMPORTANT:** The order of processes is undefined. However, the order of + > the namespaced PIDs of a particular process is well-defined. + /namespaces: + summary: Namespace discovery (includes process discovery for technical reasons) + get: + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoveryResult' + description: The discovered namespaces and processes. + summary: Linux kernel namespaces + description: |- + Information about the Linux-kernel namespaces and how they relate to processes + and vice versa. +components: + schemas: + PIDMap: + title: Root Type for PIDMap + description: |- + A "map" of the PIDs of processes in PID namespaces for translating a specific + PID from one PID namespace into another PID namespace. + + > **IMPORTANT:** The order of *processes* is undefined. However, the order of + > the namespaced PIDs of a particular process is well-defined: from the PID in + > the process' own PID namespace up the hierarchy to the PID in the initial + > PID namespace. + + The PID map is represented in a "condensed" format, which is designed to + minimize transfer volume. Consuming applications thus might want to transfer + this external representation into a performance-optimized internal + representation, optimized for translating PIDs. + type: array + items: + $ref: '#/components/schemas/NamespacedPIDs' + example: + - + - + pid: 12345 + nsid: 4026531905 + - + pid: 1 + nsid: 4026538371 + - + - + pid: 666 + nsid: 4026538371 + NamespacedPID: + title: Root Type for NamespacedPID + description: |- + A process identifier (PID) valid only in the accompanying PID namespace, + referenced by the ID (inode number) of the PID namespace. Outside that PID + namespace the PID is invalid and might be confused with some other process that + happens to have the same PID in the other PID namespace. For instance, PID 1 + can be found not only in the initial PID namespace, but usually also in all + other PID namespaces, but referencing completely different processes each time. + required: + - pid + - nsid + type: object + properties: + pid: + description: a process identifier + type: integer + nsid: + format: int64 + description: |- + a PID namespace identified and referenced by its inode number (without any + device number). + type: integer + example: + pid: 1 + nsid: 4026531905 + NamespacedPIDs: + description: |- + The list of namespaced PIDs of a process, ordered according to the PID + namespace hierarchy the process is in. The order is from the "bottom-most" PID + namespace a particular process is joined to up to the initial PID namespace. + Thus, the PID in the initial PID namespace always comes last. + type: array + items: + $ref: '#/components/schemas/NamespacedPID' + example: + - + pid: 12345 + nsid: 4026531905 + - + pid: 1 + nsid: 4026532382 + Process: + description: |- + Information about a specific process, such as its PID, name, and command line + arguments, the references (IDs) of the namespaces the process is joined to. + required: + - pid + - ppid + - name + - cmdline + - starttime + - namespaces + - cpucgroup + # - fridgecgroup + # - fridgefrozen + type: object + properties: + pid: + format: int32 + description: The process identifier (PID) of this process. + type: integer + ppid: + format: int32 + description: |- + The PID of the parent process, or 0 if there is no parent process. On Linux, the + only processes without a parent are the initial process PID 1 and the PID 2 + kthreadd kernel threads "process". + type: integer + name: + description: |- + A synthesized name of the process: + - a name set by the process itself, + - a name derived from the command line of the process. + type: string + cmdline: + description: |- + The command line arguments of the process, including the process binary file + name. Taken from /proc/$PID/cmdline, see also + [https://man7.org/linux/man-pages/man5/proc.5.html](proc(5)). + type: array + items: + type: string + starttime: + format: int64 + description: |- + The time this process started after system boot and expressed in clock ticks. + It is taken from /proc/$PID/stat, see also + [https://man7.org/linux/man-pages/man5/proc.5.html](proc(5)). + type: integer + cpucgroup: + description: |- + The (CPU) cgroup (control group) path name in the hierarchy this process is in. The + path name does not specify the root mount path of the complete hierarchy, but + only the (pseudo) absolute path starting from the root of the particular (v1) or + unified (v2) cgroup hierarchy. + type: string + namespaces: + $ref: '#/components/schemas/NamespacesSet' + description: |- + References the namespaces this process is joined to, in form of the namespace + IDs (inode numbers). + fridgecgroup: + description: The freezer cgroup path name in the hierarchy this process is in. + type: string + fridgefrozen: + description: The effective freezer state of this process. + type: boolean + example: + namespaces: + mnt: 4026531840 + cgroup: 4026531835 + uts: 4026531838 + ipc: 4026531839 + user: 4026531837 + pid: 4026531836 + net: 4026531905 + pid: 1 + ppid: 0 + name: systemd + cmdline: + - /sbin/init + - fixrtc + - splash + starttime: 0 + cpucgroup: /init.scope + ProcessTable: + description: |- + Information about all processes in the process tree, with each process item + being keyed by its PID in string form. Besides information about the process + itself and its position in the process tree, the processes also reference the + namespaces they are currently joined to. + type: object + additionalProperties: + $ref: '#/components/schemas/Process' + example: + '1': + namespaces: + mnt: 4026531840 + cgroup: 4026531835 + uts: 4026531838 + ipc: 4026531839 + user: 4026531837 + pid: 4026531836 + net: 4026531905 + pid: 1 + ppid: 0 + name: systemd + cmdline: + - /sbin/init + - fixrtc + - splash + starttime: 0 + cpucgroup: /init.scope + '137024': + namespaces: + mnt: 4026532517 + cgroup: 4026531835 + uts: 4026531838 + ipc: 4026531839 + user: 4026532518 + pid: 4026531836 + net: 4026531905 + pid: 137024 + ppid: 1 + name: upowerd + cmdline: + - /usr/lib/upower/upowerd + starttime: 3132568 + cpucgroup: /system.slice/upower.service + DiscoveryResult: + description: |- + The discovered namespaces and processes with their mutual relationships, and + optionally PID translation data. + required: + - namespaces + - processes + - containers + - container-engines + - container-groups + type: object + properties: + processes: + $ref: '#/components/schemas/ProcessTable' + description: 'Information about all processes, including the process hierarchy.' + namespaces: + $ref: '#/components/schemas/NamespacesDict' + description: Map of namespaces. + pidmap: + $ref: '#/components/schemas/PIDMap' + description: Data for translating PIDs between different PID namespaces. + options: + $ref: '#/components/schemas/DiscoveryOptions' + description: The options specified for discovery. + mounts: + $ref: '#/components/schemas/NamespacedMountPaths' + description: Map of mount namespace'd mount paths with mount points. + containers: + $ref: '#/components/schemas/ContainerMap' + description: Discovered containers. + container-engines: + $ref: '#/components/schemas/ContainerEngineMap' + description: Container engines managing the discovered containers. + container-groups: + $ref: '#/components/schemas/ContainerGroupMap' + description: Groups of containers. + example: + discovery-options: + skipped-procs: false + skipped-tasks: false + skipped-fds: false + skipped-bindmounts: false + skipped-hierarchy: false + skipped-ownership: false + skipped-freezer: false + scanned-namespace-types: + - time + - mnt + - cgroup + - uts + - ipc + - user + - pid + - net + namespaces: + '4026531835': + nsid: 4026531835 + type: cgroup + owner: 4026531837 + reference: /proc/2/ns/cgroup + leaders: + - 2 + - 1 + '4026531836': + nsid: 4026531836 + type: pid + owner: 4026531837 + reference: /proc/2/ns/pid + leaders: + - 2 + - 1 + children: + - 4026532338 + '4026531837': + nsid: 4026531837 + type: user + reference: /proc/1/ns/user + leaders: + - 1 + - 2 + children: + - 4026532518 + user-id: 0 + '4026531838': + nsid: 4026531838 + type: uts + owner: 4026531837 + reference: /proc/2/ns/uts + leaders: + - 2 + - 1 + '4026531839': + nsid: 4026531839 + type: ipc + owner: 4026531837 + reference: /proc/2/ns/ipc + leaders: + - 2 + - 1 + '4026532268': + nsid: 4026532268 + type: mnt + owner: 4026531837 + reference: /proc/1761/ns/mnt + leaders: + - 1761 + '4026532324': + nsid: 4026532324 + type: uts + owner: 4026531837 + reference: /proc/1781/ns/uts + leaders: + - 1781 + '4026532337': + nsid: 4026532337 + type: ipc + owner: 4026531837 + reference: /proc/33536/ns/ipc + leaders: + - 33536 + '4026532340': + nsid: 4026532340 + type: net + owner: 4026531837 + reference: /proc/33536/ns/net + leaders: + - 33536 + '4026532398': + nsid: 4026532398 + type: pid + owner: 4026531837 + reference: /proc/34110/ns/pid + leaders: + - 34110 + parent: 4026532338 + '4026532400': + nsid: 4026532400 + type: net + owner: 4026531837 + reference: /proc/34110/ns/net + leaders: + - 34110 + '4026532517': + nsid: 4026532517 + type: mnt + owner: 4026531837 + reference: /proc/137024/ns/mnt + leaders: + - 137024 + '4026532518': + nsid: 4026532518 + type: user + reference: /proc/137024/ns/user + leaders: + - 137024 + parent: 4026531837 + user-id: 0 + processes: + '1': + namespaces: + mnt: 4026531840 + cgroup: 4026531835 + uts: 4026531838 + ipc: 4026531839 + user: 4026531837 + pid: 4026531836 + net: 4026531905 + pid: 1 + ppid: 0 + name: systemd + cmdline: + - /sbin/init + - fixrtc + - splash + starttime: 0 + cpucgroup: /init.scope + '17': + namespaces: + mnt: 4026531840 + cgroup: 4026531835 + uts: 4026531838 + ipc: 4026531839 + user: 4026531837 + pid: 4026531836 + net: 4026531905 + pid: 17 + ppid: 2 + name: migration/1 + cmdline: + - '' + starttime: 0 + cpucgroup: '' + '1692': + namespaces: + mnt: 4026532246 + cgroup: 4026531835 + uts: 4026532247 + ipc: 4026531839 + user: 4026531837 + pid: 4026531836 + net: 4026531905 + pid: 1692 + ppid: 1 + name: systemd-timesyn + cmdline: + - /lib/systemd/systemd-timesyncd + starttime: 2032 + cpucgroup: /system.slice/systemd-timesyncd.service + Namespace: + description: |- + Information about a single Linux-kernel namespace. Depending on the extent of + the discovery, not all namespace types might have been discovered, or data might + be missing about the PID and user namespace hierarchies as well as which user + namespace owns other namespaces. + + For more details, please see also: + https://man7.org/linux/man-pages/man7/namespaces.7.html. + required: + - type + - nsid + type: object + properties: + nsid: + format: int64 + description: |- + Identifier of this namespace: an inode number. + + - lxkns only uses the inode number in the API, following current Linux kernel + and CLI tool practise, which generally identify individual namespaces only by + inode numbers (and leaving out the device number). + - Namespace identifiers are not UUIDs, but instead reused by the kernel after a + namespace has been destroyed. + type: integer + type: + $ref: '#/components/schemas/NamespaceType' + description: Type of this namespace. + owner: + format: int64 + description: The ID of the owning user namespace. + type: integer + reference: + description: |- + File system reference to the namespace, if available. The hierarchical PID and + user namespaces can also exist without any file system references, as long as + there are still child namespaces present for such a PID or user namespace. + type: array + items: + type: string + leaders: + description: |- + List of PIDs of "leader" processes joined to this namespace. + + Instead of listing all processes joined to this namespace, lxkns only lists the + "most senior" processes: these processes are the highest processes in the + process tree still joined to a namespace. Child processes also joined to this + namespace can then be found using the child process relations from the process + table information. + type: array + items: + format: int32 + type: integer + ealdorman: + format: int32 + description: PID of the most senior leader process joined to this namespace. + type: integer + parent: + format: int64 + description: 'Only for PID and user namespaces: the ID of the parent namespace.' + type: integer + user-id: + description: |- + Only for user namespaces: the UID of the Linux user who created this user + namespace. + type: integer + user-name: + description: |- + Only for user namespaces: the name of the Linux user who created this user + namespace. + type: string + children: + description: 'For user and PID namespaces: the list of child namespace IDs.' + type: array + items: + format: int64 + type: integer + possessions: + description: 'Only user namespaces: list of namespace IDs of owned (non-user) namespaces.' + type: array + items: + format: int64 + type: integer + example: + '4026532338': + nsid: 4026532338 + type: pid + owner: 4026531837 + reference: /proc/33536/ns/pid + leaders: + - 33536 + parent: 4026531836 + children: + - 4026532398 + NamespaceType: + description: |- + Type of Linux-kernel namespace. For more information about namespaces, please + see also: https://man7.org/linux/man-pages/man7/namespaces.7.html. + enum: + - cgroup + - ipc + - net + - mnt + - pid + - user + - uts + - time + type: string + example: 'net' + NamespacesDict: + description: | + "Dictionary" or "map" of Linux-kernel namespaces, keyed by their namespace IDs in stringified + form. Contrary to what the term "namespace" might suggest, namespaces do not + have names but are identified by their (transient) inode numbers. + + > **Note:** following current best practice of the Linux kernel and CLI tools, + > namespace references are only in the form of the inode number, without the + > device number. + + For further details, please see also: + https://man7.org/linux/man-pages/man7/namespaces.7.html. + type: object + additionalProperties: + $ref: '#/components/schemas/Namespace' + example: + '4026532267': + nsid: 4026532267 + type: mnt + owner: 4026531837 + reference: /proc/1714/ns/mnt + leaders: + - 1714 + '4026532268': + nsid: 4026532268 + type: mnt + owner: 4026531837 + reference: /proc/1761/ns/mnt + leaders: + - 1761 + DiscoveryOptions: + title: Root Type for DiscoveryOptions + description: '' + required: + - scanned-namespace-types + type: object + properties: + from-procs: + type: boolean + from-tasks: + type: boolean + from-fds: + type: boolean + from-bindmounts: + type: boolean + with-hierarchy: + type: boolean + with-ownership: + type: boolean + with-freezer: + description: |- + true if the discovery of the (effective) freezer states of processes has been + skipped, so that all processes always appear to be "thawed" (running). + type: boolean + scanned-namespace-types: + description: |- + List of namespace types included in the discovery. This information might help + consuming tools to understand which types of namespaces were scanned and which + were not scanned for at all. + type: array + items: + $ref: '#/components/schemas/NamespaceType' + with-mounts: + description: true if mount namespace'd mount paths with mount points were discovered. + type: boolean + labels: + description: |- + Dictionary of key=value pairs passed to decorators to optionally control the + decoration of discovered containers. + example: + skipped-procs: false + skipped-tasks: false + skipped-fds: false + skipped-bindmounts: false + skipped-hierarchy: false + skipped-ownership: false + skipped-freezer: false + scanned-namespace-types: + - time + - mnt + - cgroup + - uts + - ipc + - user + - pid + - net + NamespacesSet: + description: |- + The set of 7 namespaces (8 namespaces since Linux 5.6+) every process is always + joined to. The namespaces are referenced by their IDs (inode numbers): + - cgroup namespace + - IPC namespace + - network namespace + - mount namespace + - PID namespace + - user namespace + - UTS namespace + - time namespace (Linux kernel 5.6+) + + > **Note:** Since lxkns doesn't officially support Linux kernels before 4.9 + > all namespaces except the "time" namespace can safely be assumed to be + > always present. + + For more details about namespaces, please see also: + https://man7.org/linux/man-pages/man7/namespaces.7.html. + type: object + properties: + cgroup: + format: int64 + description: |- + References a cgroup namespace by ID (inode number). Please see also: + https://www.man7.org/linux/man-pages/man7/cgroup_namespaces.7.html. + type: integer + ipc: + format: int64 + description: |- + References an IPC namespace by ID (inode number). Please see also: + https://www.man7.org/linux/man-pages/man7/ipc_namespaces.7.html. + type: integer + net: + format: int64 + description: |- + References a network namespace by ID (inode number). Please see also: + https://www.man7.org/linux/man-pages/man7/network_namespaces.7.html. + type: integer + mnt: + format: int64 + description: |- + References a mount namespace by ID (inode number). Please see also: + https://www.man7.org/linux/man-pages/man7/mount_namespaces.7.html. + type: integer + pid: + format: int64 + description: |- + References a PID namespace by ID (inode number). Please see also: + https://www.man7.org/linux/man-pages/man7/pid_namespaces.7.html. + type: integer + user: + format: int64 + description: |- + References a user namespace by ID (inode number). Please see also: + https://www.man7.org/linux/man-pages/man7/user_namespaces.7.html. + type: integer + uts: + format: int64 + description: |- + References a UTS (*nix timesharing system) namespace by ID (inode number). + Please see also: https://www.man7.org/linux/man-pages/man7/uts_namespaces.7.html. + type: integer + time: + format: int64 + description: |- + References a (monotonous) time namespace by ID (inode number). Time namespaces + are only supported on Linux kernels 5.6 or later. Please see also: + https://www.man7.org/linux/man-pages/man7/time_namespaces.7.html. + type: integer + example: + mnt: 4026531840 + cgroup: 4026531835 + uts: 4026531838 + ipc: 4026531839 + user: 4026531837 + pid: 4026531836 + net: 4026531905 + MountPoint: + description: |- + Information about a mount point as discovered from the proc filesystem. See also + [proc(5)](https://man7.org/linux/man-pages/man5/procfs.5.html), and details about + `/proc/[PID]/mountinfo` in particular. + required: + - mountid + - parentid + - major + - minor + - root + - mountpoint + - mountoptions + - tags + - source + - fstype + - superoptions + - hidden + type: object + properties: + parentid: + description: |- + ID of the parent mount. Please note that the parent mount might be outside a + mount namespace. + type: integer + mountid: + description: 'unique ID for the mount, might be reused after umount(2).' + type: integer + major: + description: major ID for the st_dev for files on this filesystem. + type: integer + minor: + description: minor ID for the st_dev for filed on this filesystem. + type: integer + root: + description: pathname of the directory in the filesystem which forms the root of this mount. + type: string + mountpoint: + description: pathname of the mount point relative to root directory of the process. + type: string + mountoptions: + description: mount options specific to this mount. + type: array + items: + type: string + tags: + $ref: '#/components/schemas/MountTags' + description: |- + optional tags with even more optional values. Tags cannot be a single hyphen + "-". + fstype: + description: 'filesystem type in the form "type[.subtype]".' + type: string + source: + description: filesystem-specific information or "none". + type: string + superoptions: + description: per-superblock options. + type: string + hidden: + description: |- + true if this mount point is hidden by an "overmount" either at the same mount + path or higher up the path hierarchy. + type: boolean + MountTags: + description: |- + dictionary of mount point tags with optional values. Tag names cannot be a single + hyphen "-". + type: object + additionalProperties: + type: string + MountPath: + description: |- + path of one or more mount points in the Virtual File System (VFS). In case of + multiple mount points at the same path, only at most one of them can be visible + and all others (or all in case of an overmount higher up the path) will be hidden. + required: + - mounts + - pathid + - parentid + type: object + properties: + mounts: + description: one or more mount points at this path in the Virtual File System (VFS). + type: array + items: + $ref: '#/components/schemas/MountPoint' + pathid: + description: 'unique mount path identifier, per mount namespace.' + type: integer + parentid: + description: 'identifier of parent mount path, if any, otherwise 0.' + type: integer + MountPathsDict: + description: |- + "Dictionary" or "map" of mount paths with their corresponding mount points, keyed + by the mount paths. + + Please note that additionally the mount path entries are organized in a "sparse" + hierarchy with the help of mount path identifiers (these are user-space generated + by lxkns). + type: object + additionalProperties: + $ref: '#/components/schemas/MountPath' + NamespacedMountPaths: + description: 'the mount paths of each discovered mount namespace, separated by mount namespace.' + type: object + additionalProperties: + $ref: '#/components/schemas/MountPathsDict' + Container: + description: 'Alive container with process(es), either running or paused.' + required: + - id + - name + - type + - flavor + - pid + - paused + - labels + - groups + - engine + type: object + properties: + id: + description: Container identifier + type: string + name: + description: 'Container name as opposed to its id, might be the same for some container engines.' + type: string + type: + description: 'Type of container identifier, such as "docker.com", et cetera.' + type: string + flavor: + description: 'Flavor of container, might be the same as the type or different.' + type: string + pid: + description: Process ID of initial container process. + type: integer + paused: + description: Indicates whether the container is running or paused. + type: boolean + labels: + $ref: '#/components/schemas/Labels' + description: Label name=value pairs attached to this container. + groups: + description: |- + List of group reference identifiers this container is a member of. For instance, + (Docker) composer projects, Kubernetes pods, ... + type: array + items: + type: integer + engine: + description: Reference identifier of the container engine managing this container. + type: integer + Labels: + description: 'Dictionary (map) of KEY=VALUE pairs, with KEY and VALUE both strings.' + type: object + additionalProperties: + type: string + ContainerEngine: + description: Information about a container engine managing a set of discovered containers. + required: + - id + - type + - version + - api + - pid + - containers + type: object + properties: + id: + description: 'Container engine instance identifier, such as UUID, unique string, et cetera.' + type: string + type: + description: 'Engine type identifier, such as "containerd.io", et cetera.' + type: string + version: + description: 'Engine version information.' + type: string + api: + description: Engine API path. + type: string + pid: + description: 'Engine''s PID (in initial PID namespace) when known, otherwise zero.' + type: integer + containers: + description: List of reference IDs (=PIDs) of containers managed by this engine. + type: array + items: + type: integer + ContainerGroup: + description: A group of containers somehow related. + required: + - name + - type + - flavor + - containers + - labels + type: object + properties: + name: + description: |- + Name of group, such as a (Docker) composer project name, Kubernetes pod + namespace/name, et cetera. + type: string + type: + description: Group type identifier. + type: string + flavor: + description: 'Group flavor identifier, might be identical with group type identifier.' + type: string + containers: + description: List of reference IDs (=PIDs) of containers belonging to this group. + type: array + items: + type: integer + labels: + $ref: '#/components/schemas/Labels' + description: Additional KEY=VALUE information. + ContainerMap: + description: |- + Maps container PIDs to containers. Container PIDs are the PIDs of initial + container processes only, but not any child processes. + type: object + additionalProperties: + $ref: '#/components/schemas/Container' + ContainerEngineMap: + description: Maps reference IDs to container engines. + type: object + additionalProperties: + $ref: '#/components/schemas/ContainerEngine' + ContainerGroupMap: + description: Maps reference IDs to container groups. + type: object + additionalProperties: + $ref: '#/components/schemas/ContainerGroup' diff --git a/openapi3/testdata/main.yaml b/openapi3/testdata/main.yaml new file mode 100644 index 000000000..e973cbecd --- /dev/null +++ b/openapi3/testdata/main.yaml @@ -0,0 +1,7 @@ +openapi: "3.0.0" +info: + title: "test file" + version: "n/a" +paths: + /testpath: + $ref: "testpath.yaml#/paths/~1testpath" diff --git a/openapi3/testdata/my-openapi.json b/openapi3/testdata/my-openapi.json new file mode 100644 index 000000000..b75d9ff3e --- /dev/null +++ b/openapi3/testdata/my-openapi.json @@ -0,0 +1,18 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "My API", + "version": "0.1.0" + }, + "paths": { + "/foo": { + "get": { + "responses": { + "200": { + "$ref": "my-other-openapi.json#/components/responses/DefaultResponse" + } + } + } + } + } +} diff --git a/openapi3/testdata/my-other-openapi.json b/openapi3/testdata/my-other-openapi.json new file mode 100644 index 000000000..0c92486b3 --- /dev/null +++ b/openapi3/testdata/my-other-openapi.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "My other API", + "version": "0.1.0" + }, + "components": { + "schemas": { + "DefaultObject": { + "type": "object", + "properties": { + "foo": { + "type": "string" + }, + "bar": { + "type": "integer" + } + } + } + }, + "responses": { + "DefaultResponse": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultObject" + } + } + } + } + } + } +} diff --git a/openapi3/testdata/recursiveRef/components/Bar.yml b/openapi3/testdata/recursiveRef/components/Bar.yml new file mode 100644 index 000000000..cc59fc27b --- /dev/null +++ b/openapi3/testdata/recursiveRef/components/Bar.yml @@ -0,0 +1,2 @@ +type: string +example: bar diff --git a/openapi3/testdata/recursiveRef/components/Cat.yml b/openapi3/testdata/recursiveRef/components/Cat.yml new file mode 100644 index 000000000..c476aa1a5 --- /dev/null +++ b/openapi3/testdata/recursiveRef/components/Cat.yml @@ -0,0 +1,4 @@ +type: object +properties: + cat: + $ref: ../openapi.yml#/components/schemas/Cat diff --git a/openapi3/testdata/recursiveRef/components/Foo.yml b/openapi3/testdata/recursiveRef/components/Foo.yml new file mode 100644 index 000000000..53a233666 --- /dev/null +++ b/openapi3/testdata/recursiveRef/components/Foo.yml @@ -0,0 +1,4 @@ +type: object +properties: + bar: + $ref: ../openapi.yml#/components/schemas/Bar diff --git a/openapi3/testdata/recursiveRef/components/Foo/Foo2.yml b/openapi3/testdata/recursiveRef/components/Foo/Foo2.yml new file mode 100644 index 000000000..aeac81f48 --- /dev/null +++ b/openapi3/testdata/recursiveRef/components/Foo/Foo2.yml @@ -0,0 +1,4 @@ +type: object +properties: + foo: + $ref: ../../openapi.yml#/components/schemas/Foo diff --git a/openapi3/testdata/recursiveRef/components/models/error.yaml b/openapi3/testdata/recursiveRef/components/models/error.yaml new file mode 100644 index 000000000..b4d404793 --- /dev/null +++ b/openapi3/testdata/recursiveRef/components/models/error.yaml @@ -0,0 +1,2 @@ +type: object +title: ErrorDetails diff --git a/openapi3/testdata/recursiveRef/issue615.yml b/openapi3/testdata/recursiveRef/issue615.yml new file mode 100644 index 000000000..d1370e32e --- /dev/null +++ b/openapi3/testdata/recursiveRef/issue615.yml @@ -0,0 +1,60 @@ +openapi: "3.0.3" +info: + title: Deep recursive cyclic refs example + version: "1.0" +paths: + /foo: + $ref: ./paths/foo.yml +components: + schemas: + FilterColumnIncludes: + type: object + properties: + $includes: + $ref: '#/components/schemas/FilterPredicate' + additionalProperties: false + maxProperties: 1 + minProperties: 1 + FilterPredicate: + oneOf: + - $ref: '#/components/schemas/FilterValue' + - type: array + items: + $ref: '#/components/schemas/FilterPredicate' + minLength: 1 + - $ref: '#/components/schemas/FilterPredicateOp' + - $ref: '#/components/schemas/FilterPredicateRangeOp' + FilterPredicateOp: + type: object + properties: + $any: + oneOf: + - type: array + items: + $ref: '#/components/schemas/FilterPredicate' + $none: + oneOf: + - $ref: '#/components/schemas/FilterPredicate' + - type: array + items: + $ref: '#/components/schemas/FilterPredicate' + additionalProperties: false + maxProperties: 1 + minProperties: 1 + FilterPredicateRangeOp: + type: object + properties: + $lt: + $ref: '#/components/schemas/FilterRangeValue' + additionalProperties: false + maxProperties: 2 + minProperties: 2 + FilterRangeValue: + oneOf: + - type: number + - type: string + FilterValue: + oneOf: + - type: number + - type: string + - type: boolean \ No newline at end of file diff --git a/openapi3/testdata/recursiveRef/openapi.yml b/openapi3/testdata/recursiveRef/openapi.yml new file mode 100644 index 000000000..9f884c710 --- /dev/null +++ b/openapi3/testdata/recursiveRef/openapi.yml @@ -0,0 +1,33 @@ +openapi: "3.0.3" +info: + title: Recursive refs example + version: "1.0" +paths: + /foo: + $ref: ./paths/foo.yml + /double-ref-foo: + get: + summary: Double ref response + description: Reference response with double reference. + responses: + "400": + $ref: "#/components/responses/400" +components: + schemas: + Foo: + $ref: ./components/Foo.yml + Foo2: + $ref: ./components/Foo/Foo2.yml + Bar: + $ref: ./components/Bar.yml + Cat: + $ref: ./components/Cat.yml + Error: + $ref: ./components/models/error.yaml + responses: + "400": + description: 400 Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml b/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml new file mode 100644 index 000000000..0d508527a --- /dev/null +++ b/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml @@ -0,0 +1,110 @@ +{ + "components": { + "parameters": { + "number": { + "in": "query", + "name": "someNumber", + "schema": { + "type": "string" + } + } + }, + "responses": { + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "400 Bad Request" + } + }, + "schemas": { + "Bar": { + "example": "bar", + "type": "string" + }, + "Error":{ + "title":"ErrorDetails", + "type":"object" + }, + "Foo": { + "properties": { + "bar": { + "$ref": "#/components/schemas/Bar" + } + }, + "type": "object" + }, + "Foo2": { + "properties": { + "foo": { + "$ref": "#/components/schemas/Foo" + } + }, + "type": "object" + }, + "error":{ + "title":"ErrorDetails", + "type":"object" + }, + "Cat": { + "properties": { + "cat": { + "$ref": "#/components/schemas/Cat" + } + }, + "type": "object" + } + } + }, + "info": { + "title": "Recursive refs example", + "version": "1.0" + }, + "openapi": "3.0.3", + "paths": { + "/double-ref-foo": { + "get": { + "description": "Reference response with double reference.", + "responses": { + "400": { + "$ref": "#/components/responses/400" + } + }, + "summary": "Double ref response" + } + }, + "/foo": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "foo2": { + "$ref": "#/components/schemas/Foo2" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "$ref": "#/components/responses/400" + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/number" + } + ] + } + } +} diff --git a/openapi3/testdata/recursiveRef/parameters/number.yml b/openapi3/testdata/recursiveRef/parameters/number.yml new file mode 100644 index 000000000..29f0f2640 --- /dev/null +++ b/openapi3/testdata/recursiveRef/parameters/number.yml @@ -0,0 +1,4 @@ +name: someNumber +in: query +schema: + type: string diff --git a/openapi3/testdata/recursiveRef/paths/foo.yml b/openapi3/testdata/recursiveRef/paths/foo.yml new file mode 100644 index 000000000..4c845b532 --- /dev/null +++ b/openapi3/testdata/recursiveRef/paths/foo.yml @@ -0,0 +1,15 @@ +parameters: + - $ref: ../parameters/number.yml +get: + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + foo2: + $ref: ../openapi.yml#/components/schemas/Foo2 + "400": + $ref: "../openapi.yml#/components/responses/400" diff --git a/openapi3/testdata/refInLocalRef/messages/data.json b/openapi3/testdata/refInLocalRef/messages/data.json new file mode 100644 index 000000000..cfdc18efb --- /dev/null +++ b/openapi3/testdata/refInLocalRef/messages/data.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "ref_prop_part": { + "$ref": "./dataPart.json" + } + } +} diff --git a/openapi3/testdata/refInLocalRef/messages/dataPart.json b/openapi3/testdata/refInLocalRef/messages/dataPart.json new file mode 100644 index 000000000..9ecb5850a --- /dev/null +++ b/openapi3/testdata/refInLocalRef/messages/dataPart.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "idPart": { + "type": "integer", + "format": "int64" + } + } +} diff --git a/openapi3/testdata/refInLocalRef/messages/request.json b/openapi3/testdata/refInLocalRef/messages/request.json new file mode 100644 index 000000000..7225ff190 --- /dev/null +++ b/openapi3/testdata/refInLocalRef/messages/request.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required": [ + "definition_reference" + ], + "properties": { + "definition_reference": { + "$ref": "./data.json" + } + } +} diff --git a/openapi3/testdata/refInLocalRef/messages/response.json b/openapi3/testdata/refInLocalRef/messages/response.json new file mode 100644 index 000000000..b636f528b --- /dev/null +++ b/openapi3/testdata/refInLocalRef/messages/response.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + } + } +} diff --git a/openapi3/testdata/refInLocalRef/openapi.json b/openapi3/testdata/refInLocalRef/openapi.json new file mode 100644 index 000000000..f0c9915c7 --- /dev/null +++ b/openapi3/testdata/refInLocalRef/openapi.json @@ -0,0 +1,46 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Reference in reference example", + "version": "1.0.0" + }, + "paths": { + "/api/test/ref/in/ref": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties" : { + "data": { + "$ref": "#/components/schemas/Request" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "messages/response.json" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Request": { + "$ref": "messages/request.json" + } + } + } +} diff --git a/openapi3/testdata/refInLocalRefInParentsSubdir/messages/data.json b/openapi3/testdata/refInLocalRefInParentsSubdir/messages/data.json new file mode 100644 index 000000000..cfdc18efb --- /dev/null +++ b/openapi3/testdata/refInLocalRefInParentsSubdir/messages/data.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "ref_prop_part": { + "$ref": "./dataPart.json" + } + } +} diff --git a/openapi3/testdata/refInLocalRefInParentsSubdir/messages/dataPart.json b/openapi3/testdata/refInLocalRefInParentsSubdir/messages/dataPart.json new file mode 100644 index 000000000..9ecb5850a --- /dev/null +++ b/openapi3/testdata/refInLocalRefInParentsSubdir/messages/dataPart.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "idPart": { + "type": "integer", + "format": "int64" + } + } +} diff --git a/openapi3/testdata/refInLocalRefInParentsSubdir/messages/request.json b/openapi3/testdata/refInLocalRefInParentsSubdir/messages/request.json new file mode 100644 index 000000000..7225ff190 --- /dev/null +++ b/openapi3/testdata/refInLocalRefInParentsSubdir/messages/request.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required": [ + "definition_reference" + ], + "properties": { + "definition_reference": { + "$ref": "./data.json" + } + } +} diff --git a/openapi3/testdata/refInLocalRefInParentsSubdir/messages/response.json b/openapi3/testdata/refInLocalRefInParentsSubdir/messages/response.json new file mode 100644 index 000000000..b636f528b --- /dev/null +++ b/openapi3/testdata/refInLocalRefInParentsSubdir/messages/response.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + } + } +} diff --git a/openapi3/testdata/refInLocalRefInParentsSubdir/spec/openapi.json b/openapi3/testdata/refInLocalRefInParentsSubdir/spec/openapi.json new file mode 100644 index 000000000..0bf9bd36e --- /dev/null +++ b/openapi3/testdata/refInLocalRefInParentsSubdir/spec/openapi.json @@ -0,0 +1,46 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Reference in reference example", + "version": "1.0.0" + }, + "paths": { + "/api/test/ref/in/ref": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "../messages/request.json" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ref_prop": { + "$ref": "#/components/schemas/Data" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Data": { + "$ref": "../messages/data.json" + } + } + } +} diff --git a/openapi3/testdata/refInRef/messages/definitions.json b/openapi3/testdata/refInRef/messages/definitions.json new file mode 100644 index 000000000..78b942836 --- /dev/null +++ b/openapi3/testdata/refInRef/messages/definitions.json @@ -0,0 +1,7 @@ +{ + "definitions": { + "External": { + "type": "string" + } + } +} diff --git a/openapi3/testdata/refInRef/messages/request.json b/openapi3/testdata/refInRef/messages/request.json new file mode 100644 index 000000000..10ff329bc --- /dev/null +++ b/openapi3/testdata/refInRef/messages/request.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required": [ + "definition_reference" + ], + "properties": { + "definition_reference": { + "$ref": "definitions.json#/definitions/External" + } + } +} diff --git a/openapi3/testdata/refInRef/messages/response.json b/openapi3/testdata/refInRef/messages/response.json new file mode 100644 index 000000000..b636f528b --- /dev/null +++ b/openapi3/testdata/refInRef/messages/response.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + } + } +} diff --git a/openapi3/testdata/refInRef/openapi.json b/openapi3/testdata/refInRef/openapi.json new file mode 100644 index 000000000..0e9a5b1be --- /dev/null +++ b/openapi3/testdata/refInRef/openapi.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Reference in reference example", + "version": "1.0.0" + }, + "paths": { + "/api/test/ref/in/ref": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "messages/request.json" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "messages/response.json" + } + } + } + } + } + } + } + } +} diff --git a/openapi3/testdata/refInRefInProperty/common-data-objects/problem-details-0.0.1.schema.json b/openapi3/testdata/refInRefInProperty/common-data-objects/problem-details-0.0.1.schema.json new file mode 100644 index 000000000..47547ab08 --- /dev/null +++ b/openapi3/testdata/refInRefInProperty/common-data-objects/problem-details-0.0.1.schema.json @@ -0,0 +1,93 @@ +{ + "title": "Problem details", + "description": "Common data object for describing an error details", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "title", + "status" + ], + "properties": { + "type": { + "type": "string", + "description": "Unique error code", + "minLength": 1, + "example": "unauthorized", + "x-docs-examples": [ + "validation-error", + "unauthorized", + "forbidden", + "internal-server-error", + "wrong-basket", + "not-found" + ] + }, + "title": { + "type": "string", + "description": "Human readable error message", + "minLength": 1, + "example": "Your request parameters didn't validate", + "x-docs-examples": [ + "Your request parameters didn't validate", + "Requested resource is not available", + "Internal server error" + ] + }, + "status": { + "type": "integer", + "description": "HTTP status code", + "maximum": 599, + "minimum": 100, + "example": 200, + "x-docs-examples": [ + "200", + "201", + "400", + "503" + ] + }, + "detail": { + "type": "string", + "description": "Human readable error description. Only for human", + "example": "Basket must have more then 1 item", + "x-docs-examples": [ + "Basket must have more then 1 item" + ] + }, + "invalid-params": { + "type": "array", + "description": "Param list with errors", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "reason" + ], + "properties": { + "name": { + "type": "string", + "description": "field name", + "minLength": 1, + "example": "age", + "x-docs-examples": [ + "age", + "color" + ] + }, + "reason": { + "type": "string", + "description": "Field validation error text", + "minLength": 1, + "example": "must be a positive integer", + "x-docs-examples": [ + "must be a positive integer", + "must be 'green', 'red' or 'blue'" + ] + } + } + } + } + } +} diff --git a/openapi3/testdata/refInRefInProperty/components/errors.yaml b/openapi3/testdata/refInRefInProperty/components/errors.yaml new file mode 100644 index 000000000..1dc8fa7e3 --- /dev/null +++ b/openapi3/testdata/refInRefInProperty/components/errors.yaml @@ -0,0 +1,66 @@ +components: + schemas: + Error: + type: object + description: Error info in problem-details-0.0.1 format + properties: + error: + $ref: "../common-data-objects/problem-details-0.0.1.schema.json" + + responses: + 400: + description: "" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + json: + $ref: "#/components/examples/BadRequest" + 401: + description: "" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + json: + $ref: "#/components/examples/Unauthorized" + 500: + description: "" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + json: + $ref: "#/components/examples/InternalServerError" + + examples: + BadRequest: + summary: Wrong format + value: + error: + type: validation-error + title: Your request parameters didn't validate. + status: 400 + invalid-params: + - name: age + reason: must be a positive integer + - name: color + reason: must be 'green', 'red' or 'blue' + Unauthorized: + summary: Not authenticated + value: + error: + type: unauthorized + title: The request has not been applied because it lacks valid authentication credentials + for the target resource. + status: 401 + InternalServerError: + summary: Not handled internal server error + value: + error: + type: internal-server-error + title: Internal server error. + status: 500 diff --git a/openapi3/testdata/refInRefInProperty/openapi.yaml b/openapi3/testdata/refInRefInProperty/openapi.yaml new file mode 100644 index 000000000..d44b21687 --- /dev/null +++ b/openapi3/testdata/refInRefInProperty/openapi.yaml @@ -0,0 +1,29 @@ +openapi: 3.0.3 + +info: + title: "Reference in reference in property example" + version: "1.0.0" +paths: + /api/test/ref/in/ref/in/property: + post: + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: array + items: + type: string + format: binary + required: true + responses: + 200: + description: "Files are saved successfully" + 400: + $ref: "./components/errors.yaml#/components/responses/400" + 401: + $ref: "./components/errors.yaml#/components/responses/401" + 500: + $ref: "./components/errors.yaml#/components/responses/500" diff --git a/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader.yml b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader.yml index 9d12ac352..e5cf2b15b 100644 --- a/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader.yml +++ b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader.yml @@ -1 +1 @@ -description: header \ No newline at end of file +description: header diff --git a/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader1.yml b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader1.yml new file mode 100644 index 000000000..4a5d8f994 --- /dev/null +++ b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader1.yml @@ -0,0 +1 @@ +description: header1 diff --git a/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader1bis.yml b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader1bis.yml new file mode 100644 index 000000000..532e79203 --- /dev/null +++ b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader1bis.yml @@ -0,0 +1,2 @@ +header: + description: header1 diff --git a/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader2.yml b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader2.yml new file mode 100644 index 000000000..71536c564 --- /dev/null +++ b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader2.yml @@ -0,0 +1 @@ +description: header2 diff --git a/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader2bis.yml b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader2bis.yml new file mode 100644 index 000000000..c14ae4710 --- /dev/null +++ b/openapi3/testdata/relativeDocsUseDocumentPath/CustomTestHeader2bis.yml @@ -0,0 +1,2 @@ +header: + description: header2 diff --git a/openapi3/testdata/relativeDocsUseDocumentPath/openapi/paths/nesteddir/CustomTestPath.yml b/openapi3/testdata/relativeDocsUseDocumentPath/openapi/paths/nesteddir/CustomTestPath.yml index 45046c421..6e608808c 100644 --- a/openapi3/testdata/relativeDocsUseDocumentPath/openapi/paths/nesteddir/CustomTestPath.yml +++ b/openapi3/testdata/relativeDocsUseDocumentPath/openapi/paths/nesteddir/CustomTestPath.yml @@ -10,6 +10,10 @@ patch: headers: X-Rate-Limit-Reset: $ref: "../../../CustomTestHeader.yml" + X-Another: + $ref: ../../../CustomTestHeader1.yml + X-And-Another: + $ref: ../../../CustomTestHeader2.yml content: application/json: schema: diff --git a/openapi3/testdata/relativeDocsUseDocumentPath/openapi/paths/nesteddir/morenested/CustomTestPath.yml b/openapi3/testdata/relativeDocsUseDocumentPath/openapi/paths/nesteddir/morenested/CustomTestPath.yml index 647852fc5..35c5ccf51 100644 --- a/openapi3/testdata/relativeDocsUseDocumentPath/openapi/paths/nesteddir/morenested/CustomTestPath.yml +++ b/openapi3/testdata/relativeDocsUseDocumentPath/openapi/paths/nesteddir/morenested/CustomTestPath.yml @@ -10,6 +10,10 @@ patch: headers: X-Rate-Limit-Reset: $ref: "../../../../CustomTestHeader.yml" + X-Another: + $ref: '../../../../CustomTestHeader1bis.yml#/header' + X-And-Another: + $ref: '../../../../CustomTestHeader2bis.yml#/header' content: application/json: schema: diff --git a/openapi3/testdata/schema618.yml b/openapi3/testdata/schema618.yml new file mode 100644 index 000000000..1ab400075 --- /dev/null +++ b/openapi3/testdata/schema618.yml @@ -0,0 +1,62 @@ +components: + schemas: + Account: + required: + - name + - nature + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + type: + type: string + enum: + - assets + - liabilities + nature: + type: string + enum: + - asset + - liability + Record: + required: + - account + - concept + type: object + properties: + account: + $ref: "#/components/schemas/Account" + concept: + type: string + partial: + type: number + credit: + type: number + debit: + type: number + JournalEntry: + required: + - type + - creationDate + - records + type: object + properties: + id: + type: string + type: + type: string + enum: + - daily + - ingress + - egress + creationDate: + type: string + format: date + records: + type: array + items: + $ref: "#/components/schemas/Record" diff --git a/openapi3/testdata/spec.yaml b/openapi3/testdata/spec.yaml new file mode 100644 index 000000000..781312f8e --- /dev/null +++ b/openapi3/testdata/spec.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.1 +info: + version: 1.0.0 + title: Some Swagger + license: + name: MIT +paths: {} +components: + schemas: + Test: + type: object + properties: + test: + $ref: 'ext.json#/definitions/b' diff --git a/openapi3/testdata/spec.yaml.internalized.yml b/openapi3/testdata/spec.yaml.internalized.yml new file mode 100644 index 000000000..feca4a00c --- /dev/null +++ b/openapi3/testdata/spec.yaml.internalized.yml @@ -0,0 +1,36 @@ +{ + "components": { + "schemas": { + "Test": { + "properties": { + "test": { + "$ref": "#/components/schemas/b" + } + }, + "type": "object" + }, + "a": { + "type": "string" + }, + "b": { + "description": "I use a local reference.", + "properties": { + "name": { + "$ref": "#/components/schemas/a" + } + }, + "type": "object" + } + } + }, + "info": { + "license": { + "name": "MIT" + }, + "title": "Some Swagger", + "version": "1.0.0" + }, + "openapi": "3.0.1", + "paths": { + } +} diff --git a/openapi3/testdata/testpath.yaml b/openapi3/testdata/testpath.yaml new file mode 100644 index 000000000..de85bb418 --- /dev/null +++ b/openapi3/testdata/testpath.yaml @@ -0,0 +1,15 @@ +paths: + /testpath: + get: + responses: + "200": + $ref: "#/components/responses/testpath_200_response" + +components: + responses: + testpath_200_response: + description: a custom response + content: + application/json: + schema: + type: string diff --git a/openapi3/testdata/testref.openapi.json b/openapi3/testdata/testref.openapi.json index 5beb61e34..f25ffbd3a 100644 --- a/openapi3/testdata/testref.openapi.json +++ b/openapi3/testdata/testref.openapi.json @@ -1,7 +1,8 @@ { "openapi": "3.0.0", "info": { - "title": "", + "title": "OAI Specification w/ refs in JSON", + "x-my-extension": {"k": 42}, "version": "1" }, "paths": {}, diff --git a/openapi3/testdata/testref.openapi.yml b/openapi3/testdata/testref.openapi.yml index fe755936c..eace2456a 100644 --- a/openapi3/testdata/testref.openapi.yml +++ b/openapi3/testdata/testref.openapi.yml @@ -2,9 +2,10 @@ openapi: 3.0.0 info: title: 'OAI Specification w/ refs in YAML' + # x-my-extension: {k: 42}, version: '1' paths: {} components: schemas: AnotherTestSchema: - "$ref": components.openapi.yml#/components/schemas/CustomTestSchema + $ref: 'components.openapi.yml#/components/schemas/CustomTestSchema' diff --git a/openapi3/testdata/testref.openapi.yml.internalized.yml b/openapi3/testdata/testref.openapi.yml.internalized.yml new file mode 100644 index 000000000..e35a50041 --- /dev/null +++ b/openapi3/testdata/testref.openapi.yml.internalized.yml @@ -0,0 +1,19 @@ +{ + "components": { + "schemas": { + "AnotherTestSchema": { + "type": "string" + }, + "CustomTestSchema": { + "type": "string" + } + } + }, + "info": { + "title": "OAI Specification w/ refs in YAML", + "version": "1" + }, + "openapi": "3.0.0", + "paths": { + } +} diff --git a/openapi3/unique_items_checker_test.go b/openapi3/unique_items_checker_test.go new file mode 100644 index 000000000..270b797e1 --- /dev/null +++ b/openapi3/unique_items_checker_test.go @@ -0,0 +1,37 @@ +package openapi3_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestRegisterArrayUniqueItemsChecker(t *testing.T) { + var ( + schema = openapi3.Schema{ + Type: "array", + UniqueItems: true, + Items: openapi3.NewStringSchema().NewRef(), + } + val = []interface{}{"1", "2", "3"} + ) + + // Fist checked by predefined function + err := schema.VisitJSON(val) + require.NoError(t, err) + + // Register a function will always return false when check if a + // slice has unique items, then use a slice indeed has unique + // items to verify that check unique items will failed. + openapi3.RegisterArrayUniqueItemsChecker(func(items []interface{}) bool { + return false + }) + defer openapi3.RegisterArrayUniqueItemsChecker(nil) // Reset for other tests + + err = schema.VisitJSON(val) + require.Error(t, err) + require.True(t, strings.HasPrefix(err.Error(), "duplicate items found")) +} diff --git a/openapi3/validation_issue409_test.go b/openapi3/validation_issue409_test.go new file mode 100644 index 000000000..561594fca --- /dev/null +++ b/openapi3/validation_issue409_test.go @@ -0,0 +1,50 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestIssue409PatternIgnored(t *testing.T) { + l := openapi3.NewLoader() + s, err := l.LoadFromFile("testdata/issue409.yml") + require.NoError(t, err) + + err = s.Validate(l.Context, openapi3.DisableSchemaPatternValidation()) + assert.NoError(t, err) +} + +func TestIssue409PatternNotIgnored(t *testing.T) { + l := openapi3.NewLoader() + s, err := l.LoadFromFile("testdata/issue409.yml") + require.NoError(t, err) + + err = s.Validate(l.Context) + assert.Error(t, err) +} + +func TestIssue409HygienicUseOfCtx(t *testing.T) { + l := openapi3.NewLoader() + doc, err := l.LoadFromFile("testdata/issue409.yml") + require.NoError(t, err) + + err = doc.Validate(l.Context, openapi3.DisableSchemaPatternValidation()) + assert.NoError(t, err) + err = doc.Validate(l.Context) + assert.Error(t, err) + + // and the other way + + l = openapi3.NewLoader() + doc, err = l.LoadFromFile("testdata/issue409.yml") + require.NoError(t, err) + + err = doc.Validate(l.Context) + assert.Error(t, err) + err = doc.Validate(l.Context, openapi3.DisableSchemaPatternValidation()) + assert.NoError(t, err) +} diff --git a/openapi3/validation_options.go b/openapi3/validation_options.go new file mode 100644 index 000000000..0ca12e5ab --- /dev/null +++ b/openapi3/validation_options.go @@ -0,0 +1,112 @@ +package openapi3 + +import "context" + +// ValidationOption allows the modification of how the OpenAPI document is validated. +type ValidationOption func(options *ValidationOptions) + +// ValidationOptions provides configuration for validating OpenAPI documents. +type ValidationOptions struct { + examplesValidationAsReq, examplesValidationAsRes bool + examplesValidationDisabled bool + schemaDefaultsValidationDisabled bool + schemaFormatValidationEnabled bool + schemaPatternValidationDisabled bool + extraSiblingFieldsAllowed map[string]struct{} +} + +type validationOptionsKey struct{} + +// AllowExtraSiblingFields called as AllowExtraSiblingFields("description") makes Validate not return an error when said field appears next to a $ref. +func AllowExtraSiblingFields(fields ...string) ValidationOption { + return func(options *ValidationOptions) { + for _, field := range fields { + if options.extraSiblingFieldsAllowed == nil { + options.extraSiblingFieldsAllowed = make(map[string]struct{}, len(fields)) + } + options.extraSiblingFieldsAllowed[field] = struct{}{} + } + } +} + +// EnableSchemaFormatValidation makes Validate not return an error when validating documents that mention schema formats that are not defined by the OpenAPIv3 specification. +// By default, schema format validation is disabled. +func EnableSchemaFormatValidation() ValidationOption { + return func(options *ValidationOptions) { + options.schemaFormatValidationEnabled = true + } +} + +// DisableSchemaFormatValidation does the opposite of EnableSchemaFormatValidation. +// By default, schema format validation is disabled. +func DisableSchemaFormatValidation() ValidationOption { + return func(options *ValidationOptions) { + options.schemaFormatValidationEnabled = false + } +} + +// EnableSchemaPatternValidation does the opposite of DisableSchemaPatternValidation. +// By default, schema pattern validation is enabled. +func EnableSchemaPatternValidation() ValidationOption { + return func(options *ValidationOptions) { + options.schemaPatternValidationDisabled = false + } +} + +// DisableSchemaPatternValidation makes Validate not return an error when validating patterns that are not supported by the Go regexp engine. +func DisableSchemaPatternValidation() ValidationOption { + return func(options *ValidationOptions) { + options.schemaPatternValidationDisabled = true + } +} + +// EnableSchemaDefaultsValidation does the opposite of DisableSchemaDefaultsValidation. +// By default, schema default values are validated against their schema. +func EnableSchemaDefaultsValidation() ValidationOption { + return func(options *ValidationOptions) { + options.schemaDefaultsValidationDisabled = false + } +} + +// DisableSchemaDefaultsValidation disables schemas' default field validation. +// By default, schema default values are validated against their schema. +func DisableSchemaDefaultsValidation() ValidationOption { + return func(options *ValidationOptions) { + options.schemaDefaultsValidationDisabled = true + } +} + +// EnableExamplesValidation does the opposite of DisableExamplesValidation. +// By default, all schema examples are validated. +func EnableExamplesValidation() ValidationOption { + return func(options *ValidationOptions) { + options.examplesValidationDisabled = false + } +} + +// DisableExamplesValidation disables all example schema validation. +// By default, all schema examples are validated. +func DisableExamplesValidation() ValidationOption { + return func(options *ValidationOptions) { + options.examplesValidationDisabled = true + } +} + +// WithValidationOptions allows adding validation options to a context object that can be used when validationg any OpenAPI type. +func WithValidationOptions(ctx context.Context, opts ...ValidationOption) context.Context { + if len(opts) == 0 { + return ctx + } + options := &ValidationOptions{} + for _, opt := range opts { + opt(options) + } + return context.WithValue(ctx, validationOptionsKey{}, options) +} + +func getValidationOptions(ctx context.Context) *ValidationOptions { + if options, ok := ctx.Value(validationOptionsKey{}).(*ValidationOptions); ok { + return options + } + return &ValidationOptions{} +} diff --git a/openapi3/visited.go b/openapi3/visited.go new file mode 100644 index 000000000..67f970e36 --- /dev/null +++ b/openapi3/visited.go @@ -0,0 +1,41 @@ +package openapi3 + +func newVisited() visitedComponent { + return visitedComponent{ + header: make(map[*Header]struct{}), + schema: make(map[*Schema]struct{}), + } +} + +type visitedComponent struct { + header map[*Header]struct{} + schema map[*Schema]struct{} +} + +// resetVisited clears visitedComponent map +// should be called before recursion over doc *T +func (doc *T) resetVisited() { + doc.visited = newVisited() +} + +// isVisitedHeader returns `true` if the *Header pointer was already visited +// otherwise it returns `false` +func (doc *T) isVisitedHeader(h *Header) bool { + if _, ok := doc.visited.header[h]; ok { + return true + } + + doc.visited.header[h] = struct{}{} + return false +} + +// isVisitedHeader returns `true` if the *Schema pointer was already visited +// otherwise it returns `false` +func (doc *T) isVisitedSchema(s *Schema) bool { + if _, ok := doc.visited.schema[s]; ok { + return true + } + + doc.visited.schema[s] = struct{}{} + return false +} diff --git a/openapi3/xml.go b/openapi3/xml.go new file mode 100644 index 000000000..34ed3be32 --- /dev/null +++ b/openapi3/xml.go @@ -0,0 +1,66 @@ +package openapi3 + +import ( + "context" + "encoding/json" +) + +// XML is specified by OpenAPI/Swagger standard version 3. +// See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#xml-object +type XML struct { + Extensions map[string]interface{} `json:"-" yaml:"-"` + + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` + Attribute bool `json:"attribute,omitempty" yaml:"attribute,omitempty"` + Wrapped bool `json:"wrapped,omitempty" yaml:"wrapped,omitempty"` +} + +// MarshalJSON returns the JSON encoding of XML. +func (xml XML) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}, 5+len(xml.Extensions)) + for k, v := range xml.Extensions { + m[k] = v + } + if x := xml.Name; x != "" { + m["name"] = x + } + if x := xml.Namespace; x != "" { + m["namespace"] = x + } + if x := xml.Prefix; x != "" { + m["prefix"] = x + } + if x := xml.Attribute; x { + m["attribute"] = x + } + if x := xml.Wrapped; x { + m["wrapped"] = x + } + return json.Marshal(m) +} + +// UnmarshalJSON sets XML to a copy of data. +func (xml *XML) UnmarshalJSON(data []byte) error { + type XMLBis XML + var x XMLBis + if err := json.Unmarshal(data, &x); err != nil { + return err + } + _ = json.Unmarshal(data, &x.Extensions) + delete(x.Extensions, "name") + delete(x.Extensions, "namespace") + delete(x.Extensions, "prefix") + delete(x.Extensions, "attribute") + delete(x.Extensions, "wrapped") + *xml = XML(x) + return nil +} + +// Validate returns an error if XML does not comply with the OpenAPI spec. +func (xml *XML) Validate(ctx context.Context, opts ...ValidationOption) error { + ctx = WithValidationOptions(ctx, opts...) + + return validateExtensions(ctx, xml.Extensions) +} diff --git a/openapi3filter/authentication_input.go b/openapi3filter/authentication_input.go index bae7c43d3..a53484b99 100644 --- a/openapi3filter/authentication_input.go +++ b/openapi3filter/authentication_input.go @@ -2,7 +2,6 @@ package openapi3filter import ( "fmt" - "strings" "github.com/getkin/kin-openapi/openapi3" ) @@ -16,19 +15,15 @@ type AuthenticationInput struct { func (input *AuthenticationInput) NewError(err error) error { if err == nil { - scopes := input.Scopes - if len(scopes) == 0 { - err = fmt.Errorf("Security requirement '%s' failed", - input.SecuritySchemeName) + if len(input.Scopes) == 0 { + err = fmt.Errorf("security requirement %q failed", input.SecuritySchemeName) } else { - err = fmt.Errorf("Security requirement '%s' (scopes: '%s') failed", - input.SecuritySchemeName, - strings.Join(input.Scopes, "', '")) + err = fmt.Errorf("security requirement %q (scopes: %+v) failed", input.SecuritySchemeName, input.Scopes) } } return &RequestError{ Input: input.RequestValidationInput, - Reason: "Authorization failed", + Reason: "authorization failed", Err: err, } } diff --git a/openapi3filter/csv_file_upload_test.go b/openapi3filter/csv_file_upload_test.go new file mode 100644 index 000000000..89efb96d9 --- /dev/null +++ b/openapi3filter/csv_file_upload_test.go @@ -0,0 +1,127 @@ +package openapi3filter_test + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestValidateCsvFileUpload(t *testing.T) { + const spec = ` +openapi: 3.0.0 +info: + title: 'Validator' + version: 0.0.1 +paths: + /test: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: string + responses: + '200': + description: Created +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + tests := []struct { + csvData string + wantErr bool + }{ + { + `foo,bar`, + false, + }, + { + `"foo","bar"`, + false, + }, + { + `foo,bar +baz,qux`, + false, + }, + { + `foo,bar +baz,qux,quux`, + true, + }, + { + `"""`, + true, + }, + } + for _, tt := range tests { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + { // Add file data + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="file"; filename="hello.csv"`) + h.Set("Content-Type", "text/csv") + + fw, err := writer.CreatePart(h) + require.NoError(t, err) + _, err = io.Copy(fw, strings.NewReader(tt.csvData)) + + require.NoError(t, err) + } + + writer.Close() + + req, err := http.NewRequest(http.MethodPost, "/test", bytes.NewReader(body.Bytes())) + require.NoError(t, err) + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + if err = openapi3filter.ValidateRequestBody( + context.Background(), + &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + }, + route.Operation.RequestBody.Value, + ); err != nil { + if !tt.wantErr { + t.Errorf("got %v", err) + } + continue + } + if tt.wantErr { + t.Errorf("want err") + } + } +} diff --git a/openapi3filter/errors.go b/openapi3filter/errors.go index 9b46ebda6..b5454a75c 100644 --- a/openapi3filter/errors.go +++ b/openapi3filter/errors.go @@ -1,69 +1,58 @@ package openapi3filter import ( - "errors" + "bytes" "fmt" - "net/http" "github.com/getkin/kin-openapi/openapi3" ) -var ( - errRouteMissingSwagger = errors.New("Route is missing OpenAPI specification") - errRouteMissingOperation = errors.New("Route is missing OpenAPI operation") - ErrAuthenticationServiceMissing = errors.New("Request validator doesn't have an authentication service defined") -) - -type RouteError struct { - Route Route - Reason string -} - -func (err *RouteError) Error() string { - return err.Reason -} +var _ error = &RequestError{} +// RequestError is returned by ValidateRequest when request does not match OpenAPI spec type RequestError struct { Input *RequestValidationInput Parameter *openapi3.Parameter RequestBody *openapi3.RequestBody - Status int Reason string Err error } -func (err *RequestError) HTTPStatus() int { - status := err.Status - if status == 0 { - status = http.StatusBadRequest - } - return status -} +var _ interface{ Unwrap() error } = RequestError{} func (err *RequestError) Error() string { reason := err.Reason if e := err.Err; e != nil { - if len(reason) == 0 { + if len(reason) == 0 || reason == e.Error() { reason = e.Error() } else { reason += ": " + e.Error() } } if v := err.Parameter; v != nil { - return fmt.Sprintf("Parameter '%s' in %s has an error: %s", v.Name, v.In, reason) + return fmt.Sprintf("parameter %q in %s has an error: %s", v.Name, v.In, reason) } else if v := err.RequestBody; v != nil { - return fmt.Sprintf("Request body has an error: %s", reason) + return fmt.Sprintf("request body has an error: %s", reason) } else { return reason } } +func (err RequestError) Unwrap() error { + return err.Err +} + +var _ error = &ResponseError{} + +// ResponseError is returned by ValidateResponse when response does not match OpenAPI spec type ResponseError struct { Input *ResponseValidationInput Reason string Err error } +var _ interface{ Unwrap() error } = ResponseError{} + func (err *ResponseError) Error() string { reason := err.Reason if e := err.Err; e != nil { @@ -76,11 +65,28 @@ func (err *ResponseError) Error() string { return reason } +func (err ResponseError) Unwrap() error { + return err.Err +} + +var _ error = &SecurityRequirementsError{} + +// SecurityRequirementsError is returned by ValidateSecurityRequirements +// when no requirement is met. type SecurityRequirementsError struct { SecurityRequirements openapi3.SecurityRequirements Errors []error } func (err *SecurityRequirementsError) Error() string { - return "Security requirements failed" + buff := &bytes.Buffer{} + buff.WriteString("security requirements failed: ") + for i, e := range err.Errors { + buff.WriteString(e.Error()) + if i != len(err.Errors)-1 { + buff.WriteString(" | ") + } + } + + return buff.String() } diff --git a/openapi3filter/internal.go b/openapi3filter/internal.go index facaf1de5..5c6a8a6c6 100644 --- a/openapi3filter/internal.go +++ b/openapi3filter/internal.go @@ -1,6 +1,7 @@ package openapi3filter import ( + "reflect" "strings" ) @@ -11,3 +12,14 @@ func parseMediaType(contentType string) string { } return contentType[:i] } + +func isNilValue(value interface{}) bool { + if value == nil { + return true + } + switch reflect.TypeOf(value).Kind() { + case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: + return reflect.ValueOf(value).IsNil() + } + return false +} diff --git a/openapi3filter/issue201_test.go b/openapi3filter/issue201_test.go new file mode 100644 index 000000000..ec0b2a1f1 --- /dev/null +++ b/openapi3filter/issue201_test.go @@ -0,0 +1,142 @@ +package openapi3filter + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestIssue201(t *testing.T) { + loader := openapi3.NewLoader() + ctx := loader.Context + spec := ` +openapi: '3.0.3' +info: + version: 1.0.0 + title: Sample API +paths: + /_: + get: + description: '' + responses: + default: + description: '' + content: + application/json: + schema: + type: object + headers: + X-Blip: + description: '' + required: true + schema: + type: string + pattern: '^blip$' + x-blop: + description: '' + schema: + type: string + pattern: '^blop$' + X-Blap: + description: '' + required: true + schema: + type: string + pattern: '^blap$' + X-Blup: + description: '' + required: true + schema: + type: string + pattern: '^blup$' +`[1:] + + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(ctx) + require.NoError(t, err) + + for name, testcase := range map[string]struct { + headers map[string]string + err string + }{ + + "no error": { + headers: map[string]string{ + "X-Blip": "blip", + "x-blop": "blop", + "X-Blap": "blap", + "X-Blup": "blup", + }, + }, + + "missing non-required header": { + headers: map[string]string{ + "X-Blip": "blip", + // "x-blop": "blop", + "X-Blap": "blap", + "X-Blup": "blup", + }, + }, + + "missing required header": { + err: `response header "X-Blip" missing`, + headers: map[string]string{ + // "X-Blip": "blip", + "x-blop": "blop", + "X-Blap": "blap", + "X-Blup": "blup", + }, + }, + + "invalid required header": { + err: `response header "X-Blup" doesn't match schema: string doesn't match the regular expression "^blup$"`, + headers: map[string]string{ + "X-Blip": "blip", + "x-blop": "blop", + "X-Blap": "blap", + "X-Blup": "bluuuuuup", + }, + }, + } { + t.Run(name, func(t *testing.T) { + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + r, err := http.NewRequest(http.MethodGet, `/_`, nil) + require.NoError(t, err) + + r.Header.Add(headerCT, "application/json") + for k, v := range testcase.headers { + r.Header.Add(k, v) + } + + route, pathParams, err := router.FindRoute(r) + require.NoError(t, err) + + err = ValidateResponse(context.Background(), &ResponseValidationInput{ + RequestValidationInput: &RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + }, + Status: 200, + Header: r.Header, + Body: io.NopCloser(strings.NewReader(`{}`)), + }) + if e := testcase.err; e != "" { + require.ErrorContains(t, err, e) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/openapi3filter/issue436_test.go b/openapi3filter/issue436_test.go new file mode 100644 index 000000000..fa106c5a1 --- /dev/null +++ b/openapi3filter/issue436_test.go @@ -0,0 +1,135 @@ +package openapi3filter_test + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func Example_validateMultipartFormData() { + const spec = ` +openapi: 3.0.0 +info: + title: 'Validator' + version: 0.0.1 +paths: + /test: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + categories: + type: array + items: + $ref: "#/components/schemas/Category" + responses: + '200': + description: Created + +components: + schemas: + Category: + type: object + properties: + name: + type: string + required: + - name +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + if err != nil { + panic(err) + } + if err = doc.Validate(loader.Context); err != nil { + panic(err) + } + + router, err := gorillamux.NewRouter(doc) + if err != nil { + panic(err) + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + { // Add a single "categories" item as part data + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="categories"`) + h.Set("Content-Type", "application/json") + fw, err := writer.CreatePart(h) + if err != nil { + panic(err) + } + if _, err = io.Copy(fw, strings.NewReader(`{"name": "foo"}`)); err != nil { + panic(err) + } + } + + { // Add a single "categories" item as part data, again + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="categories"`) + h.Set("Content-Type", "application/json") + fw, err := writer.CreatePart(h) + if err != nil { + panic(err) + } + if _, err = io.Copy(fw, strings.NewReader(`{"name": "bar"}`)); err != nil { + panic(err) + } + } + + { // Add file data + fw, err := writer.CreateFormFile("file", "hello.txt") + if err != nil { + panic(err) + } + if _, err = io.Copy(fw, strings.NewReader("hello")); err != nil { + panic(err) + } + } + + writer.Close() + + req, err := http.NewRequest(http.MethodPost, "/test", bytes.NewReader(body.Bytes())) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + route, pathParams, err := router.FindRoute(req) + if err != nil { + panic(err) + } + + if err = openapi3filter.ValidateRequestBody( + context.Background(), + &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + }, + route.Operation.RequestBody.Value, + ); err != nil { + panic(err) + } + // Output: +} diff --git a/openapi3filter/issue624_test.go b/openapi3filter/issue624_test.go new file mode 100644 index 000000000..1fdbdea34 --- /dev/null +++ b/openapi3filter/issue624_test.go @@ -0,0 +1,69 @@ +package openapi3filter + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestIssue624(t *testing.T) { + loader := openapi3.NewLoader() + ctx := loader.Context + spec := ` +openapi: 3.0.0 +info: + version: 1.0.0 + title: Sample API +paths: + /items: + get: + description: Returns a list of stuff + parameters: + - description: "test non object" + explode: true + style: form + in: query + name: test + required: false + content: + application/json: + schema: + anyOf: + - type: string + - type: integer + responses: + '200': + description: Successful response +`[1:] + + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(ctx) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + for _, testcase := range []string{`test1`, `test[1`} { + t.Run(testcase, func(t *testing.T) { + httpReq, err := http.NewRequest(http.MethodGet, `/items?test=`+testcase, nil) + require.NoError(t, err) + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + requestValidationInput := &RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + } + err = ValidateRequest(ctx, requestValidationInput) + require.NoError(t, err) + }) + } +} diff --git a/openapi3filter/issue625_test.go b/openapi3filter/issue625_test.go new file mode 100644 index 000000000..5642a7e00 --- /dev/null +++ b/openapi3filter/issue625_test.go @@ -0,0 +1,123 @@ +package openapi3filter_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestIssue625(t *testing.T) { + + anyOfArraySpec := ` +openapi: 3.0.0 +info: + version: 1.0.0 + title: Sample API +paths: + /items: + get: + description: Returns a list of stuff + parameters: + - description: test object + explode: false + in: query + name: test + required: false + schema: + type: array + items: + anyOf: + - type: integer + - type: boolean + responses: + '200': + description: Successful response +`[1:] + + oneOfArraySpec := strings.ReplaceAll(anyOfArraySpec, "anyOf", "oneOf") + + allOfArraySpec := strings.ReplaceAll(strings.ReplaceAll(anyOfArraySpec, "anyOf", "allOf"), + "type: boolean", "type: number") + + tests := []struct { + name string + spec string + req string + errStr string + }{ + { + name: "success anyof object array", + spec: anyOfArraySpec, + req: "/items?test=3,7", + }, + { + name: "failed anyof object array", + spec: anyOfArraySpec, + req: "/items?test=s1,s2", + errStr: `parameter "test" in query has an error: path 0: value s1: an invalid boolean: invalid syntax`, + }, + + { + name: "success allof object array", + spec: allOfArraySpec, + req: `/items?test=1,3`, + }, + { + name: "failed allof object array", + spec: allOfArraySpec, + req: `/items?test=1.2,3.1`, + errStr: `parameter "test" in query has an error: path 0: value 1.2: an invalid integer: invalid syntax`, + }, + { + name: "success oneof object array", + spec: oneOfArraySpec, + req: `/items?test=true,3`, + }, + { + name: "faled oneof object array", + spec: oneOfArraySpec, + req: `/items?test="val1","val2"`, + errStr: `parameter "test" in query has an error: item 0: decoding oneOf failed: 0 schemas matched`, + }, + } + + for _, testcase := range tests { + t.Run(testcase.name, func(t *testing.T) { + loader := openapi3.NewLoader() + ctx := loader.Context + + doc, err := loader.LoadFromData([]byte(testcase.spec)) + require.NoError(t, err) + + err = doc.Validate(ctx) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + httpReq, err := http.NewRequest(http.MethodGet, testcase.req, nil) + require.NoError(t, err) + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + requestValidationInput := &openapi3filter.RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + } + err = openapi3filter.ValidateRequest(ctx, requestValidationInput) + if testcase.errStr == "" { + require.NoError(t, err) + } else { + require.Contains(t, err.Error(), testcase.errStr) + } + }, + ) + } +} diff --git a/openapi3filter/issue639_test.go b/openapi3filter/issue639_test.go new file mode 100644 index 000000000..2caf1bd14 --- /dev/null +++ b/openapi3filter/issue639_test.go @@ -0,0 +1,100 @@ +package openapi3filter + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestIssue639(t *testing.T) { + loader := openapi3.NewLoader() + ctx := loader.Context + spec := ` + openapi: 3.0.0 + info: + version: 1.0.0 + title: Sample API + paths: + /items: + put: + requestBody: + content: + application/json: + schema: + properties: + testWithdefault: + default: false + type: boolean + testNoDefault: + type: boolean + type: object + responses: + '200': + description: Successful respons +`[1:] + + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(ctx) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + tests := []struct { + name string + options *Options + expectedDefaultVal interface{} + }{ + { + name: "no defaults are added to requests", + options: &Options{ + SkipSettingDefaults: true, + }, + expectedDefaultVal: nil, + }, + + { + name: "defaults are added to requests", + expectedDefaultVal: false, + }, + } + + for _, testcase := range tests { + t.Run(testcase.name, func(t *testing.T) { + body := "{\"testNoDefault\": true}" + httpReq, err := http.NewRequest(http.MethodPut, "/items", strings.NewReader(body)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + require.NoError(t, err) + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + requestValidationInput := &RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + Options: testcase.options, + } + err = ValidateRequest(ctx, requestValidationInput) + require.NoError(t, err) + bodyAfterValidation, err := ioutil.ReadAll(httpReq.Body) + require.NoError(t, err) + + raw := map[string]interface{}{} + err = json.Unmarshal(bodyAfterValidation, &raw) + require.NoError(t, err) + require.Equal(t, testcase.expectedDefaultVal, + raw["testWithdefault"], "default value must not be included") + }) + } +} diff --git a/openapi3filter/issue641_test.go b/openapi3filter/issue641_test.go new file mode 100644 index 000000000..1c5277d0d --- /dev/null +++ b/openapi3filter/issue641_test.go @@ -0,0 +1,109 @@ +package openapi3filter_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestIssue641(t *testing.T) { + + anyOfSpec := ` +openapi: 3.0.0 +info: + version: 1.0.0 + title: Sample API +paths: + /items: + get: + description: Returns a list of stuff + parameters: + - description: test object + explode: false + in: query + name: test + required: false + schema: + anyOf: + - pattern: "^[0-9]{1,4}$" + - pattern: "^[0-9]{1,4}$" + type: string + responses: + '200': + description: Successful response +`[1:] + + allOfSpec := strings.ReplaceAll(anyOfSpec, "anyOf", "allOf") + + tests := []struct { + name string + spec string + req string + errStr string + }{ + + { + name: "success anyof pattern", + spec: anyOfSpec, + req: "/items?test=51", + }, + { + name: "failed anyof pattern", + spec: anyOfSpec, + req: "/items?test=999999", + errStr: `parameter "test" in query has an error: doesn't match any schema from "anyOf"`, + }, + + { + name: "success allof pattern", + spec: allOfSpec, + req: `/items?test=51`, + }, + { + name: "failed allof pattern", + spec: allOfSpec, + req: `/items?test=999999`, + errStr: `parameter "test" in query has an error: string doesn't match the regular expression "^[0-9]{1,4}$"`, + }, + } + + for _, testcase := range tests { + t.Run(testcase.name, func(t *testing.T) { + loader := openapi3.NewLoader() + ctx := loader.Context + + doc, err := loader.LoadFromData([]byte(testcase.spec)) + require.NoError(t, err) + + err = doc.Validate(ctx) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + httpReq, err := http.NewRequest(http.MethodGet, testcase.req, nil) + require.NoError(t, err) + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + requestValidationInput := &openapi3filter.RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + } + err = openapi3filter.ValidateRequest(ctx, requestValidationInput) + if testcase.errStr == "" { + require.NoError(t, err) + } else { + require.Contains(t, err.Error(), testcase.errStr) + } + }, + ) + } +} diff --git a/openapi3filter/issue689_test.go b/openapi3filter/issue689_test.go new file mode 100644 index 000000000..592d53f74 --- /dev/null +++ b/openapi3filter/issue689_test.go @@ -0,0 +1,168 @@ +package openapi3filter + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestIssue689(t *testing.T) { + loader := openapi3.NewLoader() + ctx := loader.Context + spec := ` + openapi: 3.0.0 + info: + version: 1.0.0 + title: Sample API + paths: + /items: + put: + requestBody: + content: + application/json: + schema: + properties: + testWithReadOnly: + readOnly: true + type: boolean + testNoReadOnly: + type: boolean + type: object + responses: + '200': + description: OK + get: + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + testWithWriteOnly: + writeOnly: true + type: boolean + testNoWriteOnly: + type: boolean +`[1:] + + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(ctx) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + tests := []struct { + name string + options *Options + body string + method string + checkErr require.ErrorAssertionFunc + }{ + // read-only + { + name: "non read-only property is added to request when validation enabled", + body: `{"testNoReadOnly": true}`, + method: http.MethodPut, + checkErr: require.NoError, + }, + { + name: "non read-only property is added to request when validation disabled", + body: `{"testNoReadOnly": true}`, + method: http.MethodPut, + options: &Options{ + ExcludeReadOnlyValidations: true, + }, + checkErr: require.NoError, + }, + { + name: "read-only property is added to requests when validation enabled", + body: `{"testWithReadOnly": true}`, + method: http.MethodPut, + checkErr: require.Error, + }, + { + name: "read-only property is added to requests when validation disabled", + body: `{"testWithReadOnly": true}`, + method: http.MethodPut, + options: &Options{ + ExcludeReadOnlyValidations: true, + }, + checkErr: require.NoError, + }, + // write-only + { + name: "non write-only property is added to request when validation enabled", + body: `{"testNoWriteOnly": true}`, + method: http.MethodGet, + checkErr: require.NoError, + }, + { + name: "non write-only property is added to request when validation disabled", + body: `{"testNoWriteOnly": true}`, + method: http.MethodGet, + options: &Options{ + ExcludeWriteOnlyValidations: true, + }, + checkErr: require.NoError, + }, + { + name: "write-only property is added to requests when validation enabled", + body: `{"testWithWriteOnly": true}`, + method: http.MethodGet, + checkErr: require.Error, + }, + { + name: "write-only property is added to requests when validation disabled", + body: `{"testWithWriteOnly": true}`, + method: http.MethodGet, + options: &Options{ + ExcludeWriteOnlyValidations: true, + }, + checkErr: require.NoError, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + httpReq, err := http.NewRequest(test.method, "/items", strings.NewReader(test.body)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + require.NoError(t, err) + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + requestValidationInput := &RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + Options: test.options, + } + + if test.method == http.MethodGet { + responseValidationInput := &ResponseValidationInput{ + RequestValidationInput: requestValidationInput, + Status: 200, + Header: httpReq.Header, + Body: io.NopCloser(strings.NewReader(test.body)), + Options: test.options, + } + err = ValidateResponse(ctx, responseValidationInput) + + } else { + err = ValidateRequest(ctx, requestValidationInput) + } + test.checkErr(t, err) + }) + } +} diff --git a/openapi3filter/issue707_test.go b/openapi3filter/issue707_test.go new file mode 100644 index 000000000..a7cbc39ed --- /dev/null +++ b/openapi3filter/issue707_test.go @@ -0,0 +1,90 @@ +package openapi3filter + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestIssue707(t *testing.T) { + loader := openapi3.NewLoader() + ctx := loader.Context + spec := ` +openapi: 3.0.0 +info: + version: 1.0.0 + title: Sample API +paths: + /items: + get: + description: Returns a list of stuff + parameters: + - description: parameter with a default value + explode: true + in: query + name: param-with-default + schema: + default: 124 + type: integer + required: false + responses: + '200': + description: Successful response +`[1:] + + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(ctx) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + tests := []struct { + name string + options *Options + expectedQuery string + }{ + { + name: "no defaults are added to requests parameters", + options: &Options{ + SkipSettingDefaults: true, + }, + expectedQuery: "", + }, + + { + name: "defaults are added to requests", + expectedQuery: "param-with-default=124", + }, + } + + for _, testcase := range tests { + t.Run(testcase.name, func(t *testing.T) { + httpReq, err := http.NewRequest(http.MethodGet, "/items", strings.NewReader("")) + require.NoError(t, err) + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + requestValidationInput := &RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + Options: testcase.options, + } + err = ValidateRequest(ctx, requestValidationInput) + require.NoError(t, err) + + require.NoError(t, err) + require.Equal(t, testcase.expectedQuery, + httpReq.URL.RawQuery, "default value must not be included") + }) + } +} diff --git a/openapi3filter/issue722_test.go b/openapi3filter/issue722_test.go new file mode 100644 index 000000000..2ffa9d143 --- /dev/null +++ b/openapi3filter/issue722_test.go @@ -0,0 +1,133 @@ +package openapi3filter_test + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestValidateMultipartFormDataContainingAllOf(t *testing.T) { + const spec = ` +openapi: 3.0.0 +info: + title: 'Validator' + version: 0.0.1 +paths: + /test: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + allOf: + - $ref: '#/components/schemas/Category' + - properties: + file: + type: string + format: binary + description: + type: string + responses: + '200': + description: Created + +components: + schemas: + Category: + type: object + properties: + name: + type: string + required: + - name +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + if err != nil { + t.Fatal(err) + } + if err = doc.Validate(loader.Context); err != nil { + t.Fatal(err) + } + + router, err := gorillamux.NewRouter(doc) + if err != nil { + t.Fatal(err) + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + { // Add file data + fw, err := writer.CreateFormFile("file", "hello.txt") + if err != nil { + t.Fatal(err) + } + if _, err = io.Copy(fw, strings.NewReader("hello")); err != nil { + t.Fatal(err) + } + } + + { // Add a single "name" item as part data + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="name"`) + fw, err := writer.CreatePart(h) + if err != nil { + t.Fatal(err) + } + if _, err = io.Copy(fw, strings.NewReader(`foo`)); err != nil { + t.Fatal(err) + } + } + + { // Add a single "discription" item as part data + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="description"`) + fw, err := writer.CreatePart(h) + if err != nil { + t.Fatal(err) + } + if _, err = io.Copy(fw, strings.NewReader(`description note`)); err != nil { + t.Fatal(err) + } + } + + writer.Close() + + req, err := http.NewRequest(http.MethodPost, "/test", bytes.NewReader(body.Bytes())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + route, pathParams, err := router.FindRoute(req) + if err != nil { + t.Fatal(err) + } + + if err = openapi3filter.ValidateRequestBody( + context.Background(), + &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + }, + route.Operation.RequestBody.Value, + ); err != nil { + t.Error(err) + } +} diff --git a/openapi3filter/issue733_test.go b/openapi3filter/issue733_test.go new file mode 100644 index 000000000..0d2214b58 --- /dev/null +++ b/openapi3filter/issue733_test.go @@ -0,0 +1,109 @@ +package openapi3filter_test + +import ( + "bytes" + "context" + "encoding/json" + "math" + "math/big" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestIntMax(t *testing.T) { + spec := ` +openapi: 3.0.0 +info: + version: 1.0.0 + title: test large integer value +paths: + /test: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + testInteger: + type: integer + format: int64 + testDefault: + type: boolean + default: false + responses: + '200': + description: Successful response +`[1:] + + loader := openapi3.NewLoader() + + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + testOne := func(value *big.Int, pass bool) { + valueString := value.String() + + req, err := http.NewRequest(http.MethodPost, "/test", bytes.NewReader([]byte(`{"testInteger":`+valueString+`}`))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + err = openapi3filter.ValidateRequest( + context.Background(), + &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + }) + if pass { + require.NoError(t, err) + + dec := json.NewDecoder(req.Body) + dec.UseNumber() + var jsonAfter map[string]interface{} + err = dec.Decode(&jsonAfter) + require.NoError(t, err) + + valueAfter := jsonAfter["testInteger"] + require.IsType(t, json.Number(""), valueAfter) + assert.Equal(t, valueString, string(valueAfter.(json.Number))) + } else { + if assert.Error(t, err) { + var serr *openapi3.SchemaError + if assert.ErrorAs(t, err, &serr) { + assert.Equal(t, "number must be an int64", serr.Reason) + } + } + } + } + + bigMaxInt64 := big.NewInt(math.MaxInt64) + bigMaxInt64Plus1 := new(big.Int).Add(bigMaxInt64, big.NewInt(1)) + bigMinInt64 := big.NewInt(math.MinInt64) + bigMinInt64Minus1 := new(big.Int).Sub(bigMinInt64, big.NewInt(1)) + + testOne(bigMaxInt64, true) + // XXX not yet fixed + // testOne(bigMaxInt64Plus1, false) + testOne(bigMaxInt64Plus1, true) + testOne(bigMinInt64, true) + // XXX not yet fixed + // testOne(bigMinInt64Minus1, false) + testOne(bigMinInt64Minus1, true) +} diff --git a/openapi3filter/middleware.go b/openapi3filter/middleware.go new file mode 100644 index 000000000..3bcb9db43 --- /dev/null +++ b/openapi3filter/middleware.go @@ -0,0 +1,283 @@ +package openapi3filter + +import ( + "bytes" + "io" + "io/ioutil" + "log" + "net/http" + + "github.com/getkin/kin-openapi/routers" +) + +// Validator provides HTTP request and response validation middleware. +type Validator struct { + router routers.Router + errFunc ErrFunc + logFunc LogFunc + strict bool + options Options +} + +// ErrFunc handles errors that may occur during validation. +type ErrFunc func(w http.ResponseWriter, status int, code ErrCode, err error) + +// LogFunc handles log messages that may occur during validation. +type LogFunc func(message string, err error) + +// ErrCode is used for classification of different types of errors that may +// occur during validation. These may be used to write an appropriate response +// in ErrFunc. +type ErrCode int + +const ( + // ErrCodeOK indicates no error. It is also the default value. + ErrCodeOK = 0 + // ErrCodeCannotFindRoute happens when the validator fails to resolve the + // request to a defined OpenAPI route. + ErrCodeCannotFindRoute = iota + // ErrCodeRequestInvalid happens when the inbound request does not conform + // to the OpenAPI 3 specification. + ErrCodeRequestInvalid = iota + // ErrCodeResponseInvalid happens when the wrapped handler response does + // not conform to the OpenAPI 3 specification. + ErrCodeResponseInvalid = iota +) + +func (e ErrCode) responseText() string { + switch e { + case ErrCodeOK: + return "OK" + case ErrCodeCannotFindRoute: + return "not found" + case ErrCodeRequestInvalid: + return "bad request" + default: + return "server error" + } +} + +// NewValidator returns a new response validation middlware, using the given +// routes from an OpenAPI 3 specification. +func NewValidator(router routers.Router, options ...ValidatorOption) *Validator { + v := &Validator{ + router: router, + errFunc: func(w http.ResponseWriter, status int, code ErrCode, _ error) { + http.Error(w, code.responseText(), status) + }, + logFunc: func(message string, err error) { + log.Printf("%s: %v", message, err) + }, + } + for i := range options { + options[i](v) + } + return v +} + +// ValidatorOption defines an option that may be specified when creating a +// Validator. +type ValidatorOption func(*Validator) + +// OnErr provides a callback that handles writing an HTTP response on a +// validation error. This allows customization of error responses without +// prescribing a particular form. This callback is only called on response +// validator errors in Strict mode. +func OnErr(f ErrFunc) ValidatorOption { + return func(v *Validator) { + v.errFunc = f + } +} + +// OnLog provides a callback that handles logging in the Validator. This allows +// the validator to integrate with a services' existing logging system without +// prescribing a particular one. +func OnLog(f LogFunc) ValidatorOption { + return func(v *Validator) { + v.logFunc = f + } +} + +// Strict, if set, causes an internal server error to be sent if the wrapped +// handler response fails response validation. If not set, the response is sent +// and the error is only logged. +func Strict(strict bool) ValidatorOption { + return func(v *Validator) { + v.strict = strict + } +} + +// ValidationOptions sets request/response validation options on the validator. +func ValidationOptions(options Options) ValidatorOption { + return func(v *Validator) { + v.options = options + } +} + +// Middleware returns an http.Handler which wraps the given handler with +// request and response validation. +func (v *Validator) Middleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + route, pathParams, err := v.router.FindRoute(r) + if err != nil { + v.logFunc("validation error: failed to find route for "+r.URL.String(), err) + v.errFunc(w, http.StatusNotFound, ErrCodeCannotFindRoute, err) + return + } + requestValidationInput := &RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + Options: &v.options, + } + if err = ValidateRequest(r.Context(), requestValidationInput); err != nil { + v.logFunc("invalid request", err) + v.errFunc(w, http.StatusBadRequest, ErrCodeRequestInvalid, err) + return + } + + var wr responseWrapper + if v.strict { + wr = &strictResponseWrapper{w: w} + } else { + wr = newWarnResponseWrapper(w) + } + + h.ServeHTTP(wr, r) + + if err = ValidateResponse(r.Context(), &ResponseValidationInput{ + RequestValidationInput: requestValidationInput, + Status: wr.statusCode(), + Header: wr.Header(), + Body: ioutil.NopCloser(bytes.NewBuffer(wr.bodyContents())), + Options: &v.options, + }); err != nil { + v.logFunc("invalid response", err) + if v.strict { + v.errFunc(w, http.StatusInternalServerError, ErrCodeResponseInvalid, err) + } + return + } + + if err = wr.flushBodyContents(); err != nil { + v.logFunc("failed to write response", err) + } + }) +} + +type responseWrapper interface { + http.ResponseWriter + + // flushBodyContents writes the buffered response to the client, if it has + // not yet been written. + flushBodyContents() error + + // statusCode returns the response status code, 0 if not set yet. + statusCode() int + + // bodyContents returns the buffered + bodyContents() []byte +} + +type warnResponseWrapper struct { + w http.ResponseWriter + headerWritten bool + status int + body bytes.Buffer + tee io.Writer +} + +func newWarnResponseWrapper(w http.ResponseWriter) *warnResponseWrapper { + wr := &warnResponseWrapper{ + w: w, + } + wr.tee = io.MultiWriter(w, &wr.body) + return wr +} + +// Write implements http.ResponseWriter. +func (wr *warnResponseWrapper) Write(b []byte) (int, error) { + if !wr.headerWritten { + wr.WriteHeader(http.StatusOK) + } + return wr.tee.Write(b) +} + +// WriteHeader implements http.ResponseWriter. +func (wr *warnResponseWrapper) WriteHeader(status int) { + if !wr.headerWritten { + // If the header hasn't been written, record the status for response + // validation. + wr.status = status + wr.headerWritten = true + } + wr.w.WriteHeader(wr.status) +} + +// Header implements http.ResponseWriter. +func (wr *warnResponseWrapper) Header() http.Header { + return wr.w.Header() +} + +// Flush implements the optional http.Flusher interface. +func (wr *warnResponseWrapper) Flush() { + // If the wrapped http.ResponseWriter implements optional http.Flusher, + // pass through. + if fl, ok := wr.w.(http.Flusher); ok { + fl.Flush() + } +} + +func (wr *warnResponseWrapper) flushBodyContents() error { + return nil +} + +func (wr *warnResponseWrapper) statusCode() int { + return wr.status +} + +func (wr *warnResponseWrapper) bodyContents() []byte { + return wr.body.Bytes() +} + +type strictResponseWrapper struct { + w http.ResponseWriter + headerWritten bool + status int + body bytes.Buffer +} + +// Write implements http.ResponseWriter. +func (wr *strictResponseWrapper) Write(b []byte) (int, error) { + if !wr.headerWritten { + wr.WriteHeader(http.StatusOK) + } + return wr.body.Write(b) +} + +// WriteHeader implements http.ResponseWriter. +func (wr *strictResponseWrapper) WriteHeader(status int) { + if !wr.headerWritten { + wr.status = status + wr.headerWritten = true + } +} + +// Header implements http.ResponseWriter. +func (wr *strictResponseWrapper) Header() http.Header { + return wr.w.Header() +} + +func (wr *strictResponseWrapper) flushBodyContents() error { + wr.w.WriteHeader(wr.status) + _, err := wr.w.Write(wr.body.Bytes()) + return err +} + +func (wr *strictResponseWrapper) statusCode() int { + return wr.status +} + +func (wr *strictResponseWrapper) bodyContents() []byte { + return wr.body.Bytes() +} diff --git a/openapi3filter/middleware_test.go b/openapi3filter/middleware_test.go new file mode 100644 index 000000000..ff6059c9d --- /dev/null +++ b/openapi3filter/middleware_test.go @@ -0,0 +1,533 @@ +package openapi3filter_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "path" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +const validatorSpec = ` +openapi: 3.0.0 +info: + title: 'Validator' + version: '0.0.0' +paths: + /test: + post: + operationId: newTest + description: create a new test + parameters: + - in: query + name: version + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/TestContents' } + responses: + '201': + description: 'created test' + content: + application/json: + schema: { $ref: '#/components/schemas/TestResource' } + '400': { $ref: '#/components/responses/ErrorResponse' } + '500': { $ref: '#/components/responses/ErrorResponse' } + /test/{id}: + get: + operationId: getTest + description: get a test + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: version + schema: + type: string + required: true + responses: + '200': + description: 'respond with test resource' + content: + application/json: + schema: { $ref: '#/components/schemas/TestResource' } + '400': { $ref: '#/components/responses/ErrorResponse' } + '404': { $ref: '#/components/responses/ErrorResponse' } + '500': { $ref: '#/components/responses/ErrorResponse' } +components: + schemas: + TestContents: + type: object + properties: + name: + type: string + expected: + type: number + actual: + type: number + required: [name, expected, actual] + additionalProperties: false + TestResource: + type: object + properties: + id: + type: string + contents: + { $ref: '#/components/schemas/TestContents' } + required: [id, contents] + additionalProperties: false + Error: + type: object + properties: + code: + type: string + message: + type: string + required: [code, message] + additionalProperties: false + responses: + ErrorResponse: + description: 'an error occurred' + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } +` + +type validatorTestHandler struct { + contentType string + getBody, postBody string + errBody string + errStatusCode int +} + +const validatorOkResponse = `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}}` + +func (h validatorTestHandler) withDefaults() validatorTestHandler { + if h.contentType == "" { + h.contentType = "application/json" + } + if h.getBody == "" { + h.getBody = validatorOkResponse + } + if h.postBody == "" { + h.postBody = validatorOkResponse + } + if h.errBody == "" { + h.errBody = `{"code":"bad","message":"bad things"}` + } + return h +} + +var testUrlRE = regexp.MustCompile(`^/test(/\d+)?$`) + +func (h *validatorTestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", h.contentType) + if h.errStatusCode != 0 { + w.WriteHeader(h.errStatusCode) + w.Write([]byte(h.errBody)) + return + } + if !testUrlRE.MatchString(r.URL.Path) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(h.errBody)) + return + } + switch r.Method { + case "GET": + w.WriteHeader(http.StatusOK) + w.Write([]byte(h.getBody)) + case "POST": + w.WriteHeader(http.StatusCreated) + w.Write([]byte(h.postBody)) + default: + http.Error(w, h.errBody, http.StatusMethodNotAllowed) + } +} + +func TestValidator(t *testing.T) { + doc, err := openapi3.NewLoader().LoadFromData([]byte(validatorSpec)) + require.NoError(t, err, "failed to load test fixture spec") + + ctx := context.Background() + err = doc.Validate(ctx) + require.NoError(t, err, "invalid test fixture spec") + + type testRequest struct { + method, path, body, contentType string + } + type testResponse struct { + statusCode int + body string + } + tests := []struct { + name string + handler validatorTestHandler + options []openapi3filter.ValidatorOption + request testRequest + response testResponse + strict bool + }{{ + name: "valid GET", + handler: validatorTestHandler{}.withDefaults(), + request: testRequest{ + method: "GET", + path: "/test/42?version=1", + }, + response: testResponse{ + 200, validatorOkResponse, + }, + strict: true, + }, { + name: "valid POST", + handler: validatorTestHandler{}.withDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10}`, + contentType: "application/json", + }, + response: testResponse{ + 201, validatorOkResponse, + }, + strict: true, + }, { + name: "not found; no GET operation for /test", + handler: validatorTestHandler{}.withDefaults(), + request: testRequest{ + method: "GET", + path: "/test?version=1", + }, + response: testResponse{ + 404, "not found\n", + }, + strict: true, + }, { + name: "not found; no POST operation for /test/42", + handler: validatorTestHandler{}.withDefaults(), + request: testRequest{ + method: "POST", + path: "/test/42?version=1", + }, + response: testResponse{ + 404, "not found\n", + }, + strict: true, + }, { + name: "invalid request; missing version", + handler: validatorTestHandler{}.withDefaults(), + request: testRequest{ + method: "GET", + path: "/test/42", + }, + response: testResponse{ + 400, "bad request\n", + }, + strict: true, + }, { + name: "invalid POST request; wrong property type", + handler: validatorTestHandler{}.withDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": "nine", "actual": "ten"}`, + contentType: "application/json", + }, + response: testResponse{ + 400, "bad request\n", + }, + strict: true, + }, { + name: "invalid POST request; missing property", + handler: validatorTestHandler{}.withDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9}`, + contentType: "application/json", + }, + response: testResponse{ + 400, "bad request\n", + }, + strict: true, + }, { + name: "invalid POST request; extra property", + handler: validatorTestHandler{}.withDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10, "ideal": 8}`, + contentType: "application/json", + }, + response: testResponse{ + 400, "bad request\n", + }, + strict: true, + }, { + name: "valid response; 404 error", + handler: validatorTestHandler{ + contentType: "application/json", + errBody: `{"code": "404", "message": "not found"}`, + errStatusCode: 404, + }.withDefaults(), + request: testRequest{ + method: "GET", + path: "/test/42?version=1", + }, + response: testResponse{ + 404, `{"code": "404", "message": "not found"}`, + }, + strict: true, + }, { + name: "invalid response; invalid error", + handler: validatorTestHandler{ + errBody: `"not found"`, + errStatusCode: 404, + }.withDefaults(), + request: testRequest{ + method: "GET", + path: "/test/42?version=1", + }, + response: testResponse{ + 500, "server error\n", + }, + strict: true, + }, { + name: "invalid POST response; not strict", + handler: validatorTestHandler{ + postBody: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`, + }.withDefaults(), + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10}`, + contentType: "application/json", + }, + response: testResponse{ + statusCode: 201, + body: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`, + }, + strict: false, + }, { + name: "POST response status code not in spec (return 200, spec only has 201)", + handler: validatorTestHandler{ + postBody: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`, + errStatusCode: 200, + errBody: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`, + }.withDefaults(), + options: []openapi3filter.ValidatorOption{openapi3filter.ValidationOptions(openapi3filter.Options{ + IncludeResponseStatus: true, + })}, + request: testRequest{ + method: "POST", + path: "/test?version=1", + body: `{"name": "foo", "expected": 9, "actual": 10}`, + contentType: "application/json", + }, + response: testResponse{ + statusCode: 200, + body: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`, + }, + strict: false, + }} + for i, test := range tests { + t.Logf("test#%d: %s", i, test.name) + t.Run(test.name, func(t *testing.T) { + // Set up a test HTTP server + var h http.Handler + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + })) + defer s.Close() + + // Update the OpenAPI servers section with the test server URL This is + // needed by the router which matches request routes for OpenAPI + // validation. + doc.Servers = []*openapi3.Server{{URL: s.URL}} + err = doc.Validate(ctx) + require.NoError(t, err, "failed to validate with test server") + + // Create the router and validator + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err, "failed to create router") + + // Now wrap the test handler with the validator middlware + v := openapi3filter.NewValidator(router, append(test.options, openapi3filter.Strict(test.strict))...) + h = v.Middleware(&test.handler) + + // Test: make a client request + var requestBody io.Reader + if test.request.body != "" { + requestBody = bytes.NewBufferString(test.request.body) + } + req, err := http.NewRequest(test.request.method, s.URL+test.request.path, requestBody) + require.NoError(t, err, "failed to create request") + + if test.request.contentType != "" { + req.Header.Set("Content-Type", test.request.contentType) + } + resp, err := s.Client().Do(req) + require.NoError(t, err, "request failed") + defer resp.Body.Close() + require.Equalf(t, test.response.statusCode, resp.StatusCode, + "response code expect %d got %d", test.response.statusCode, resp.StatusCode) + + body, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err, "failed to read response body") + require.Equalf(t, test.response.body, string(body), + "response body expect %q got %q", test.response.body, string(body)) + }) + } +} + +func ExampleValidator() { + // OpenAPI specification for a simple service that squares integers, with + // some limitations. + doc, err := openapi3.NewLoader().LoadFromData([]byte(` +openapi: 3.0.0 +info: + title: 'Validator - square example' + version: '0.0.0' +paths: + /square/{x}: + get: + description: square an integer + parameters: + - name: x + in: path + schema: + type: integer + required: true + responses: + '200': + description: squared integer response + content: + "application/json": + schema: + type: object + properties: + result: + type: integer + minimum: 0 + maximum: 1000000 + required: [result] + additionalProperties: false`[1:])) + if err != nil { + panic(err) + } + + // Square service handler sanity checks inputs, but just crashes on invalid + // requests. + squareHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + xParam := path.Base(r.URL.Path) + x, err := strconv.ParseInt(xParam, 10, 64) + if err != nil { + panic(err) + } + w.Header().Set("Content-Type", "application/json") + result := map[string]interface{}{"result": x * x} + if x == 42 { + // An easter egg. Unfortunately, the spec does not allow additional properties... + result["comment"] = "the answer to the ulitimate question of life, the universe, and everything" + } + if err = json.NewEncoder(w).Encode(&result); err != nil { + panic(err) + } + }) + + // Start an http server. + var mainHandler http.Handler + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Why are we wrapping the main server handler with a closure here? + // Validation matches request Host: to server URLs in the spec. With an + // httptest.Server, the URL is dynamic and we have to create it first! + // In a real configured service, this is less likely to be needed. + mainHandler.ServeHTTP(w, r) + })) + defer srv.Close() + + // Patch the OpenAPI spec to match the httptest.Server.URL. Only needed + // because the server URL is dynamic here. + doc.Servers = []*openapi3.Server{{URL: srv.URL}} + if err := doc.Validate(context.Background()); err != nil { // Assert our OpenAPI is valid! + panic(err) + } + // This router is used by the validator to match requests with the OpenAPI + // spec. It does not place restrictions on how the wrapped handler routes + // requests; use of gorilla/mux is just a validator implementation detail. + router, err := gorillamux.NewRouter(doc) + if err != nil { + panic(err) + } + // Strict validation will respond HTTP 500 if the service tries to emit a + // response that does not conform to the OpenAPI spec. Very useful for + // testing a service against its spec in development and CI. In production, + // availability may be more important than strictness. + v := openapi3filter.NewValidator(router, openapi3filter.Strict(true), + openapi3filter.OnErr(func(w http.ResponseWriter, status int, code openapi3filter.ErrCode, err error) { + // Customize validation error responses to use JSON + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": status, + "message": http.StatusText(status), + }) + })) + // Now we can finally set the main server handler. + mainHandler = v.Middleware(squareHandler) + + printResp := func(resp *http.Response, err error) { + if err != nil { + panic(err) + } + defer resp.Body.Close() + contents, err := ioutil.ReadAll(resp.Body) + if err != nil { + panic(err) + } + fmt.Println(resp.StatusCode, strings.TrimSpace(string(contents))) + } + // Valid requests to our sum service + printResp(srv.Client().Get(srv.URL + "/square/2")) + printResp(srv.Client().Get(srv.URL + "/square/789")) + // 404 Not found requests - method or path not found + printResp(srv.Client().Post(srv.URL+"/square/2", "application/json", bytes.NewBufferString(`{"result": 5}`))) + printResp(srv.Client().Get(srv.URL + "/sum/2")) + printResp(srv.Client().Get(srv.URL + "/square/circle/4")) // Handler would process this; validation rejects it + printResp(srv.Client().Get(srv.URL + "/square")) + // 400 Bad requests - note they never reach the wrapped square handler (which would panic) + printResp(srv.Client().Get(srv.URL + "/square/five")) + // 500 Invalid responses + printResp(srv.Client().Get(srv.URL + "/square/42")) // Our "easter egg" added a property which is not allowed + printResp(srv.Client().Get(srv.URL + "/square/65536")) // Answer overflows the maximum allowed value (1000000) + // Output: + // 200 {"result":4} + // 200 {"result":622521} + // 404 {"message":"Not Found","status":404} + // 404 {"message":"Not Found","status":404} + // 404 {"message":"Not Found","status":404} + // 404 {"message":"Not Found","status":404} + // 400 {"message":"Bad Request","status":400} + // 500 {"message":"Internal Server Error","status":500} + // 500 {"message":"Internal Server Error","status":500} +} diff --git a/openapi3filter/options.go b/openapi3filter/options.go index 510b77756..4ea9e9907 100644 --- a/openapi3filter/options.go +++ b/openapi3filter/options.go @@ -1,14 +1,47 @@ package openapi3filter -import ( - "context" -) +import "github.com/getkin/kin-openapi/openapi3" +// DefaultOptions do not set an AuthenticationFunc. +// A spec with security schemes defined will not pass validation +// unless an AuthenticationFunc is defined. var DefaultOptions = &Options{} +// Options used by ValidateRequest and ValidateResponse type Options struct { - ExcludeRequestBody bool - ExcludeResponseBody bool + // Set ExcludeRequestBody so ValidateRequest skips request body validation + ExcludeRequestBody bool + + // Set ExcludeResponseBody so ValidateResponse skips response body validation + ExcludeResponseBody bool + + // Set ExcludeReadOnlyValidations so ValidateRequest skips read-only validations + ExcludeReadOnlyValidations bool + + // Set ExcludeWriteOnlyValidations so ValidateResponse skips write-only validations + ExcludeWriteOnlyValidations bool + + // Set IncludeResponseStatus so ValidateResponse fails on response + // status not defined in OpenAPI spec IncludeResponseStatus bool - AuthenticationFunc func(c context.Context, input *AuthenticationInput) error + + MultiError bool + + // See NoopAuthenticationFunc + AuthenticationFunc AuthenticationFunc + + // Indicates whether default values are set in the + // request. If true, then they are not set + SkipSettingDefaults bool + + customSchemaErrorFunc CustomSchemaErrorFunc +} + +// CustomSchemaErrorFunc allows for custom the schema error message. +type CustomSchemaErrorFunc func(err *openapi3.SchemaError) string + +// WithCustomSchemaErrorFunc sets a function to override the schema error message. +// If the passed function returns an empty string, it returns to the previous Error() implementation. +func (o *Options) WithCustomSchemaErrorFunc(f CustomSchemaErrorFunc) { + o.customSchemaErrorFunc = f } diff --git a/openapi3filter/options_test.go b/openapi3filter/options_test.go new file mode 100644 index 000000000..fd19329ff --- /dev/null +++ b/openapi3filter/options_test.go @@ -0,0 +1,82 @@ +package openapi3filter_test + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func ExampleOptions_WithCustomSchemaErrorFunc() { + const spec = ` +openapi: 3.0.0 +info: + title: 'Validator' + version: 0.0.1 +paths: + /some: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + field: + title: Some field + type: integer + responses: + '200': + description: Created +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + if err != nil { + panic(err) + } + + if err = doc.Validate(loader.Context); err != nil { + panic(err) + } + + router, err := gorillamux.NewRouter(doc) + if err != nil { + panic(err) + } + + opts := &openapi3filter.Options{} + + opts.WithCustomSchemaErrorFunc(func(err *openapi3.SchemaError) string { + return fmt.Sprintf(`field "%s" must be an integer`, err.Schema.Title) + }) + + req, err := http.NewRequest(http.MethodPost, "/some", strings.NewReader(`{"field":"not integer"}`)) + if err != nil { + panic(err) + } + + req.Header.Add("Content-Type", "application/json") + + route, pathParams, err := router.FindRoute(req) + if err != nil { + panic(err) + } + + validationInput := &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + Options: opts, + } + err = openapi3filter.ValidateRequest(context.Background(), validationInput) + + fmt.Println(err.Error()) + + // Output: request body has an error: doesn't match schema: field "Some field" must be an integer +} diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go index 3b1de11f1..384b5122e 100644 --- a/openapi3filter/req_resp_decoder.go +++ b/openapi3filter/req_resp_decoder.go @@ -1,6 +1,9 @@ package openapi3filter import ( + "archive/zip" + "bytes" + "encoding/csv" "encoding/json" "errors" "fmt" @@ -14,6 +17,8 @@ import ( "strconv" "strings" + "gopkg.in/yaml.v3" + "github.com/getkin/kin-openapi/openapi3" ) @@ -41,6 +46,8 @@ type ParseError struct { path []interface{} } +var _ interface{ Unwrap() error } = ParseError{} + func (e *ParseError) Error() string { var msg []string if p := e.Path(); len(p) > 0 { @@ -80,6 +87,10 @@ func (e *ParseError) RootCause() error { return e.Cause } +func (e ParseError) Unwrap() error { + return e.Cause +} + // Path returns a path to the root cause. func (e *ParseError) Path() []interface{} { var path []interface{} @@ -102,10 +113,12 @@ func invalidSerializationMethodErr(sm *openapi3.SerializationMethod) error { // Decodes a parameter defined via the content property as an object. It uses // the user specified decoder, or our build-in decoder for application/json func decodeContentParameter(param *openapi3.Parameter, input *RequestValidationInput) ( - value interface{}, schema *openapi3.Schema, err error) { - + value interface{}, + schema *openapi3.Schema, + found bool, + err error, +) { var paramValues []string - var found bool switch param.In { case openapi3.ParameterInPath: var paramValue string @@ -115,9 +128,9 @@ func decodeContentParameter(param *openapi3.Parameter, input *RequestValidationI case openapi3.ParameterInQuery: paramValues, found = input.GetQueryParams()[param.Name] case openapi3.ParameterInHeader: - if paramValue := input.Request.Header.Get(http.CanonicalHeaderKey(param.Name)); paramValue != "" { - paramValues = []string{paramValue} - found = true + var headerValues []string + if headerValues, found = input.Request.Header[http.CanonicalHeaderKey(param.Name)]; found { + paramValues = headerValues } case openapi3.ParameterInCookie: var cookie *http.Cookie @@ -130,13 +143,13 @@ func decodeContentParameter(param *openapi3.Parameter, input *RequestValidationI found = true } default: - err = fmt.Errorf("unsupported parameter's 'in': %s", param.In) + err = fmt.Errorf("unsupported parameter.in: %q", param.In) return } if !found { if param.Required { - err = fmt.Errorf("parameter '%s' is required, but missing", param.Name) + err = fmt.Errorf("parameter %q is required, but missing", param.Name) } return } @@ -151,43 +164,54 @@ func decodeContentParameter(param *openapi3.Parameter, input *RequestValidationI } func defaultContentParameterDecoder(param *openapi3.Parameter, values []string) ( - outValue interface{}, outSchema *openapi3.Schema, err error) { + outValue interface{}, + outSchema *openapi3.Schema, + err error, +) { // Only query parameters can have multiple values. if len(values) > 1 && param.In != openapi3.ParameterInQuery { - err = fmt.Errorf("%s parameter '%s' can't have multiple values", param.In, param.Name) + err = fmt.Errorf("%s parameter %q cannot have multiple values", param.In, param.Name) return } content := param.Content if content == nil { - err = fmt.Errorf("parameter '%s' expected to have content", param.Name) + err = fmt.Errorf("parameter %q expected to have content", param.Name) return } - // We only know how to decode a parameter if it has one content, application/json if len(content) != 1 { - err = fmt.Errorf("multiple content types for parameter '%s'", param.Name) + err = fmt.Errorf("multiple content types for parameter %q", param.Name) return } mt := content.Get("application/json") if mt == nil { - err = fmt.Errorf("parameter '%s' has no json content schema", param.Name) + err = fmt.Errorf("parameter %q has no content schema", param.Name) return } outSchema = mt.Schema.Value + unmarshal := func(encoded string, paramSchema *openapi3.SchemaRef) (decoded interface{}, err error) { + if err = json.Unmarshal([]byte(encoded), &decoded); err != nil { + if paramSchema != nil && paramSchema.Value.Type != "object" { + decoded, err = encoded, nil + } + } + return + } + if len(values) == 1 { - if err = json.Unmarshal([]byte(values[0]), &outValue); err != nil { - err = fmt.Errorf("error unmarshaling parameter '%s' as json", param.Name) + if outValue, err = unmarshal(values[0], mt.Schema); err != nil { + err = fmt.Errorf("error unmarshaling parameter %q", param.Name) return } } else { outArray := make([]interface{}, 0, len(values)) for _, v := range values { var item interface{} - if err = json.Unmarshal([]byte(v), &item); err != nil { - err = fmt.Errorf("error unmarshaling parameter '%s' as json", param.Name) + if item, err = unmarshal(v, outSchema.Items); err != nil { + err = fmt.Errorf("error unmarshaling parameter %q", param.Name) return } outArray = append(outArray, item) @@ -198,30 +222,30 @@ func defaultContentParameterDecoder(param *openapi3.Parameter, values []string) } type valueDecoder interface { - DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, error) - DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, error) - DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, error) + DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, bool, error) + DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, bool, error) + DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, bool, error) } // decodeStyledParameter returns a value of an operation's parameter from HTTP request for -// parameters defined using the style format. +// parameters defined using the style format, and whether the parameter is supplied in the input. // The function returns ParseError when HTTP request contains an invalid value of a parameter. -func decodeStyledParameter(param *openapi3.Parameter, input *RequestValidationInput) (interface{}, error) { +func decodeStyledParameter(param *openapi3.Parameter, input *RequestValidationInput) (interface{}, bool, error) { sm, err := param.SerializationMethod() if err != nil { - return nil, err + return nil, false, err } var dec valueDecoder switch param.In { case openapi3.ParameterInPath: if len(input.PathParams) == 0 { - return nil, nil + return nil, false, nil } dec = &pathParamDecoder{pathParams: input.PathParams} case openapi3.ParameterInQuery: if len(input.GetQueryParams()) == 0 { - return nil, nil + return nil, false, nil } dec = &urlValuesDecoder{values: input.GetQueryParams()} case openapi3.ParameterInHeader: @@ -229,76 +253,79 @@ func decodeStyledParameter(param *openapi3.Parameter, input *RequestValidationIn case openapi3.ParameterInCookie: dec = &cookieParamDecoder{req: input.Request} default: - return nil, fmt.Errorf("unsupported parameter's 'in': %s", param.In) + return nil, false, fmt.Errorf("unsupported parameter's 'in': %s", param.In) } return decodeValue(dec, param.Name, sm, param.Schema, param.Required) } -func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef, required bool) (interface{}, error) { - var decodeFn func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, error) +func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef, required bool) (interface{}, bool, error) { + var found bool if len(schema.Value.AllOf) > 0 { var value interface{} var err error for _, sr := range schema.Value.AllOf { - value, err = decodeValue(dec, param, sm, sr, required) + var f bool + value, f, err = decodeValue(dec, param, sm, sr, required) + found = found || f if value == nil || err != nil { break } } - return value, err + return value, found, err } if len(schema.Value.AnyOf) > 0 { for _, sr := range schema.Value.AnyOf { - value, _ := decodeValue(dec, param, sm, sr, required) + value, f, _ := decodeValue(dec, param, sm, sr, required) + found = found || f if value != nil { - return value, nil + return value, found, nil } } - if required == true { - return nil, fmt.Errorf("decoding anyOf for parameter %q failed", param) - } else { - return nil, nil + if required { + return nil, found, fmt.Errorf("decoding anyOf for parameter %q failed", param) } - + return nil, found, nil } if len(schema.Value.OneOf) > 0 { isMatched := 0 var value interface{} for _, sr := range schema.Value.OneOf { - v, _ := decodeValue(dec, param, sm, sr, required) + v, f, _ := decodeValue(dec, param, sm, sr, required) + found = found || f if v != nil { value = v isMatched++ } } if isMatched == 1 { - return value, nil + return value, found, nil } else if isMatched > 1 { - return nil, fmt.Errorf("decoding oneOf failed: %d schemas matched", isMatched) + return nil, found, fmt.Errorf("decoding oneOf failed: %d schemas matched", isMatched) } - if required == true { - return nil, fmt.Errorf("decoding oneOf failed: %q is required", param) - } else { - return nil, nil + if required { + return nil, found, fmt.Errorf("decoding oneOf failed: %q is required", param) } + return nil, found, nil } + if schema.Value.Not != nil { // TODO(decode not): handle decoding "not" JSON Schema - return nil, errors.New("not implemented: decoding 'not'") + return nil, found, errors.New("not implemented: decoding 'not'") } if schema.Value.Type != "" { + var decodeFn func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, bool, error) switch schema.Value.Type { case "array": - decodeFn = func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, error) { + decodeFn = func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, bool, error) { return dec.DecodeArray(param, sm, schema) } case "object": - decodeFn = func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, error) { + decodeFn = func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, bool, error) { return dec.DecodeObject(param, sm, schema) } default: @@ -306,7 +333,23 @@ func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMetho } return decodeFn(param, sm, schema) } - return nil, nil + switch vDecoder := dec.(type) { + case *pathParamDecoder: + _, found = vDecoder.pathParams[param] + case *urlValuesDecoder: + if schema.Value.Pattern != "" { + return dec.DecodePrimitive(param, sm, schema) + } + _, found = vDecoder.values[param] + case *headerParamDecoder: + _, found = vDecoder.header[http.CanonicalHeaderKey(param)] + case *cookieParamDecoder: + _, err := vDecoder.req.Cookie(param) + found = err != http.ErrNoCookie + default: + return nil, found, errors.New("unsupported decoder") + } + return nil, found, nil } // pathParamDecoder decodes values of path parameters. @@ -314,7 +357,7 @@ type pathParamDecoder struct { pathParams map[string]string } -func (d *pathParamDecoder) DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, error) { +func (d *pathParamDecoder) DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, bool, error) { var prefix string switch sm.Style { case "simple": @@ -324,26 +367,27 @@ func (d *pathParamDecoder) DecodePrimitive(param string, sm *openapi3.Serializat case "matrix": prefix = ";" + param + "=" default: - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } if d.pathParams == nil { // HTTP request does not contains a value of the target path parameter. - return nil, nil + return nil, false, nil } - raw, ok := d.pathParams[paramKey(param, sm)] + raw, ok := d.pathParams[param] if !ok || raw == "" { // HTTP request does not contains a value of the target path parameter. - return nil, nil + return nil, false, nil } src, err := cutPrefix(raw, prefix) if err != nil { - return nil, err + return nil, ok, err } - return parsePrimitive(src, schema) + val, err := parsePrimitive(src, schema) + return val, ok, err } -func (d *pathParamDecoder) DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, error) { +func (d *pathParamDecoder) DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, bool, error) { var prefix, delim string switch { case sm.Style == "simple": @@ -361,84 +405,74 @@ func (d *pathParamDecoder) DecodeArray(param string, sm *openapi3.SerializationM prefix = ";" + param + "=" delim = ";" + param + "=" default: - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } if d.pathParams == nil { // HTTP request does not contains a value of the target path parameter. - return nil, nil + return nil, false, nil } - raw, ok := d.pathParams[paramKey(param, sm)] + raw, ok := d.pathParams[param] if !ok || raw == "" { // HTTP request does not contains a value of the target path parameter. - return nil, nil + return nil, false, nil } src, err := cutPrefix(raw, prefix) if err != nil { - return nil, err + return nil, ok, err } - return parseArray(strings.Split(src, delim), schema) + val, err := parseArray(strings.Split(src, delim), schema) + return val, ok, err } -func (d *pathParamDecoder) DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, error) { +func (d *pathParamDecoder) DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, bool, error) { var prefix, propsDelim, valueDelim string switch { - case sm.Style == "simple" && sm.Explode == false: + case sm.Style == "simple" && !sm.Explode: propsDelim = "," valueDelim = "," - case sm.Style == "simple" && sm.Explode == true: + case sm.Style == "simple" && sm.Explode: propsDelim = "," valueDelim = "=" - case sm.Style == "label" && sm.Explode == false: + case sm.Style == "label" && !sm.Explode: prefix = "." propsDelim = "," valueDelim = "," - case sm.Style == "label" && sm.Explode == true: + case sm.Style == "label" && sm.Explode: prefix = "." propsDelim = "." valueDelim = "=" - case sm.Style == "matrix" && sm.Explode == false: + case sm.Style == "matrix" && !sm.Explode: prefix = ";" + param + "=" propsDelim = "," valueDelim = "," - case sm.Style == "matrix" && sm.Explode == true: + case sm.Style == "matrix" && sm.Explode: prefix = ";" propsDelim = ";" valueDelim = "=" default: - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } if d.pathParams == nil { // HTTP request does not contains a value of the target path parameter. - return nil, nil + return nil, false, nil } - raw, ok := d.pathParams[paramKey(param, sm)] + raw, ok := d.pathParams[param] if !ok || raw == "" { // HTTP request does not contains a value of the target path parameter. - return nil, nil + return nil, false, nil } src, err := cutPrefix(raw, prefix) if err != nil { - return nil, err + return nil, ok, err } props, err := propsFromString(src, propsDelim, valueDelim) if err != nil { - return nil, err - } - return makeObject(props, schema) -} - -// paramKey returns a key to get a raw value of a path parameter. -func paramKey(param string, sm *openapi3.SerializationMethod) string { - switch sm.Style { - case "label": - return "." + param - case "matrix": - return ";" + param - default: - return param + return nil, ok, err } + val, err := makeObject(props, schema) + return val, ok, err } // cutPrefix validates that a raw value of a path parameter has the specified prefix, @@ -462,28 +496,33 @@ type urlValuesDecoder struct { values url.Values } -func (d *urlValuesDecoder) DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, error) { +func (d *urlValuesDecoder) DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, bool, error) { if sm.Style != "form" { - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } - values := d.values[param] + values, ok := d.values[param] if len(values) == 0 { // HTTP request does not contain a value of the target query parameter. - return nil, nil + return nil, ok, nil } - return parsePrimitive(values[0], schema) + + if schema.Value.Type == "" && schema.Value.Pattern != "" { + return values[0], ok, nil + } + val, err := parsePrimitive(values[0], schema) + return val, ok, err } -func (d *urlValuesDecoder) DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, error) { +func (d *urlValuesDecoder) DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, bool, error) { if sm.Style == "deepObject" { - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } - values := d.values[param] + values, ok := d.values[param] if len(values) == 0 { // HTTP request does not contain a value of the target query parameter. - return nil, nil + return nil, ok, nil } if !sm.Explode { var delim string @@ -497,10 +536,92 @@ func (d *urlValuesDecoder) DecodeArray(param string, sm *openapi3.SerializationM } values = strings.Split(values[0], delim) } - return parseArray(values, schema) + val, err := d.parseArray(values, sm, schema) + return val, ok, err +} + +// parseArray returns an array that contains items from a raw array. +// Every item is parsed as a primitive value. +// The function returns an error when an error happened while parse array's items. +func (d *urlValuesDecoder) parseArray(raw []string, sm *openapi3.SerializationMethod, schemaRef *openapi3.SchemaRef) ([]interface{}, error) { + var value []interface{} + + for i, v := range raw { + item, err := d.parseValue(v, schemaRef.Value.Items) + if err != nil { + if v, ok := err.(*ParseError); ok { + return nil, &ParseError{path: []interface{}{i}, Cause: v} + } + return nil, fmt.Errorf("item %d: %w", i, err) + } + + // If the items are nil, then the array is nil. There shouldn't be case where some values are actual primitive + // values and some are nil values. + if item == nil { + return nil, nil + } + value = append(value, item) + } + return value, nil +} + +func (d *urlValuesDecoder) parseValue(v string, schema *openapi3.SchemaRef) (interface{}, error) { + if len(schema.Value.AllOf) > 0 { + var value interface{} + var err error + for _, sr := range schema.Value.AllOf { + value, err = d.parseValue(v, sr) + if value == nil || err != nil { + break + } + } + return value, err + } + + if len(schema.Value.AnyOf) > 0 { + var value interface{} + var err error + for _, sr := range schema.Value.AnyOf { + if value, err = d.parseValue(v, sr); err == nil { + return value, nil + } + } + + return nil, err + } + + if len(schema.Value.OneOf) > 0 { + isMatched := 0 + var value interface{} + var err error + for _, sr := range schema.Value.OneOf { + result, err := d.parseValue(v, sr) + if err == nil { + value = result + isMatched++ + } + } + if isMatched == 1 { + return value, nil + } else if isMatched > 1 { + return nil, fmt.Errorf("decoding oneOf failed: %d schemas matched", isMatched) + } else if isMatched == 0 { + return nil, fmt.Errorf("decoding oneOf failed: %d schemas matched", isMatched) + } + + return nil, err + } + + if schema.Value.Not != nil { + // TODO(decode not): handle decoding "not" JSON Schema + return nil, errors.New("not implemented: decoding 'not'") + } + + return parsePrimitive(v, schema) + } -func (d *urlValuesDecoder) DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, error) { +func (d *urlValuesDecoder) DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, bool, error) { var propsFn func(url.Values) (map[string]string, error) switch sm.Style { case "form": @@ -541,17 +662,27 @@ func (d *urlValuesDecoder) DecodeObject(param string, sm *openapi3.Serialization return props, nil } default: - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } props, err := propsFn(d.values) if err != nil { - return nil, err + return nil, false, err } if props == nil { - return nil, nil + return nil, false, nil + } + + // check the props + found := false + for propName := range schema.Value.Properties { + if _, ok := props[propName]; ok { + found = true + break + } } - return makeObject(props, schema) + val, err := makeObject(props, schema) + return val, found, err } // headerParamDecoder decodes values of header parameters. @@ -559,47 +690,56 @@ type headerParamDecoder struct { header http.Header } -func (d *headerParamDecoder) DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, error) { +func (d *headerParamDecoder) DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, bool, error) { if sm.Style != "simple" { - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } - raw := d.header.Get(http.CanonicalHeaderKey(param)) - return parsePrimitive(raw, schema) + raw, ok := d.header[http.CanonicalHeaderKey(param)] + if !ok || len(raw) == 0 { + // HTTP request does not contains a corresponding header or has the empty value + return nil, ok, nil + } + + val, err := parsePrimitive(raw[0], schema) + return val, ok, err } -func (d *headerParamDecoder) DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, error) { +func (d *headerParamDecoder) DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, bool, error) { if sm.Style != "simple" { - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } - raw := d.header.Get(http.CanonicalHeaderKey(param)) - if raw == "" { + raw, ok := d.header[http.CanonicalHeaderKey(param)] + if !ok || len(raw) == 0 { // HTTP request does not contains a corresponding header - return nil, nil + return nil, ok, nil } - return parseArray(strings.Split(raw, ","), schema) + + val, err := parseArray(strings.Split(raw[0], ","), schema) + return val, ok, err } -func (d *headerParamDecoder) DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, error) { +func (d *headerParamDecoder) DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, bool, error) { if sm.Style != "simple" { - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } valueDelim := "," if sm.Explode { valueDelim = "=" } - raw := d.header.Get(http.CanonicalHeaderKey(param)) - if raw == "" { + raw, ok := d.header[http.CanonicalHeaderKey(param)] + if !ok || len(raw) == 0 { // HTTP request does not contain a corresponding header. - return nil, nil + return nil, ok, nil } - props, err := propsFromString(raw, ",", valueDelim) + props, err := propsFromString(raw[0], ",", valueDelim) if err != nil { - return nil, err + return nil, ok, err } - return makeObject(props, schema) + val, err := makeObject(props, schema) + return val, ok, err } // cookieParamDecoder decodes values of cookie parameters. @@ -607,56 +747,63 @@ type cookieParamDecoder struct { req *http.Request } -func (d *cookieParamDecoder) DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, error) { +func (d *cookieParamDecoder) DecodePrimitive(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (interface{}, bool, error) { if sm.Style != "form" { - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } cookie, err := d.req.Cookie(param) - if err == http.ErrNoCookie { + found := err != http.ErrNoCookie + if !found { // HTTP request does not contain a corresponding cookie. - return nil, nil + return nil, found, nil } if err != nil { - return nil, fmt.Errorf("decoding param %q: %s", param, err) + return nil, found, fmt.Errorf("decoding param %q: %w", param, err) } - return parsePrimitive(cookie.Value, schema) + + val, err := parsePrimitive(cookie.Value, schema) + return val, found, err } -func (d *cookieParamDecoder) DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, error) { +func (d *cookieParamDecoder) DecodeArray(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) ([]interface{}, bool, error) { if sm.Style != "form" || sm.Explode { - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } cookie, err := d.req.Cookie(param) - if err == http.ErrNoCookie { + found := err != http.ErrNoCookie + if !found { // HTTP request does not contain a corresponding cookie. - return nil, nil + return nil, found, nil } if err != nil { - return nil, fmt.Errorf("decoding param %q: %s", param, err) + return nil, found, fmt.Errorf("decoding param %q: %w", param, err) } - return parseArray(strings.Split(cookie.Value, ","), schema) + val, err := parseArray(strings.Split(cookie.Value, ","), schema) + return val, found, err } -func (d *cookieParamDecoder) DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, error) { +func (d *cookieParamDecoder) DecodeObject(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (map[string]interface{}, bool, error) { if sm.Style != "form" || sm.Explode { - return nil, invalidSerializationMethodErr(sm) + return nil, false, invalidSerializationMethodErr(sm) } cookie, err := d.req.Cookie(param) - if err == http.ErrNoCookie { + found := err != http.ErrNoCookie + if !found { // HTTP request does not contain a corresponding cookie. - return nil, nil + return nil, found, nil } if err != nil { - return nil, fmt.Errorf("decoding param %q: %s", param, err) + return nil, found, fmt.Errorf("decoding param %q: %w", param, err) } props, err := propsFromString(cookie.Value, ",", ",") if err != nil { - return nil, err + return nil, found, err } - return makeObject(props, schema) + val, err := makeObject(props, schema) + return val, found, err } // propsFromString returns a properties map that is created by splitting a source string by propDelim and valueDelim. @@ -711,7 +858,7 @@ func makeObject(props map[string]string, schema *openapi3.SchemaRef) (map[string if v, ok := err.(*ParseError); ok { return nil, &ParseError{path: []interface{}{propName}, Cause: v} } - return nil, fmt.Errorf("property %q: %s", propName, err) + return nil, fmt.Errorf("property %q: %w", propName, err) } obj[propName] = value } @@ -729,7 +876,13 @@ func parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]interface{}, err if v, ok := err.(*ParseError); ok { return nil, &ParseError{path: []interface{}{i}, Cause: v} } - return nil, fmt.Errorf("item %d: %s", i, err) + return nil, fmt.Errorf("item %d: %w", i, err) + } + + // If the items are nil, then the array is nil. There shouldn't be case where some values are actual primitive + // values and some are nil values. + if item == nil { + return nil, nil } value = append(value, item) } @@ -737,29 +890,36 @@ func parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]interface{}, err } // parsePrimitive returns a value that is created by parsing a source string to a primitive type -// that is specified by a JSON schema. The function returns nil when the source string is empty. -// The function panics when a JSON schema has a non primitive type. +// that is specified by a schema. The function returns nil when the source string is empty. +// The function panics when a schema has a non-primitive type. func parsePrimitive(raw string, schema *openapi3.SchemaRef) (interface{}, error) { if raw == "" { return nil, nil } switch schema.Value.Type { case "integer": - v, err := strconv.ParseFloat(raw, 64) + if schema.Value.Format == "int32" { + v, err := strconv.ParseInt(raw, 0, 32) + if err != nil { + return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid " + schema.Value.Type, Cause: err.(*strconv.NumError).Err} + } + return int32(v), nil + } + v, err := strconv.ParseInt(raw, 0, 64) if err != nil { - return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid integer", Cause: err} + return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid " + schema.Value.Type, Cause: err.(*strconv.NumError).Err} } return v, nil case "number": v, err := strconv.ParseFloat(raw, 64) if err != nil { - return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid number", Cause: err} + return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid " + schema.Value.Type, Cause: err.(*strconv.NumError).Err} } return v, nil case "boolean": v, err := strconv.ParseBool(raw) if err != nil { - return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid number", Cause: err} + return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid " + schema.Value.Type, Cause: err.(*strconv.NumError).Err} } return v, nil case "string": @@ -780,10 +940,19 @@ type BodyDecoder func(io.Reader, http.Header, *openapi3.SchemaRef, EncodingFn) ( // By default, there is content type "application/json" is supported only. var bodyDecoders = make(map[string]BodyDecoder) +// RegisteredBodyDecoder returns the registered body decoder for the given content type. +// +// If no decoder was registered for the given content type, nil is returned. +// This call is not thread-safe: body decoders should not be created/destroyed by multiple goroutines. +func RegisteredBodyDecoder(contentType string) BodyDecoder { + return bodyDecoders[contentType] +} + // RegisterBodyDecoder registers a request body's decoder for a content type. // // If a decoder for the specified content type already exists, the function replaces // it with the specified decoder. +// This call is not thread-safe: body decoders should not be created/destroyed by multiple goroutines. func RegisterBodyDecoder(contentType string, decoder BodyDecoder) { if contentType == "" { panic("contentType is empty") @@ -797,6 +966,7 @@ func RegisterBodyDecoder(contentType string, decoder BodyDecoder) { // UnregisterBodyDecoder dissociates a body decoder from a content type. // // Decoding this content type will result in an error. +// This call is not thread-safe: body decoders should not be created/destroyed by multiple goroutines. func UnregisterBodyDecoder(contentType string) { if contentType == "" { panic("contentType is empty") @@ -804,31 +974,50 @@ func UnregisterBodyDecoder(contentType string) { delete(bodyDecoders, contentType) } +var headerCT = http.CanonicalHeaderKey("Content-Type") + +const prefixUnsupportedCT = "unsupported content type" + // decodeBody returns a decoded body. // The function returns ParseError when a body is invalid. -func decodeBody(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { - contentType := header.Get(http.CanonicalHeaderKey("Content-Type")) +func decodeBody(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) ( + string, + interface{}, + error, +) { + contentType := header.Get(headerCT) + if contentType == "" { + if _, ok := body.(*multipart.Part); ok { + contentType = "text/plain" + } + } mediaType := parseMediaType(contentType) decoder, ok := bodyDecoders[mediaType] if !ok { - return nil, &ParseError{ + return "", nil, &ParseError{ Kind: KindUnsupportedFormat, - Reason: fmt.Sprintf("unsupported content type %q", mediaType), + Reason: fmt.Sprintf("%s %q", prefixUnsupportedCT, mediaType), } } value, err := decoder(body, header, schema, encFn) if err != nil { - return nil, err + return "", nil, err } - return value, nil + return mediaType, value, nil } func init() { - RegisterBodyDecoder("text/plain", plainBodyDecoder) RegisterBodyDecoder("application/json", jsonBodyDecoder) + RegisterBodyDecoder("application/json-patch+json", jsonBodyDecoder) + RegisterBodyDecoder("application/octet-stream", FileBodyDecoder) + RegisterBodyDecoder("application/problem+json", jsonBodyDecoder) RegisterBodyDecoder("application/x-www-form-urlencoded", urlencodedBodyDecoder) + RegisterBodyDecoder("application/x-yaml", yamlBodyDecoder) + RegisterBodyDecoder("application/yaml", yamlBodyDecoder) + RegisterBodyDecoder("application/zip", zipFileBodyDecoder) RegisterBodyDecoder("multipart/form-data", multipartBodyDecoder) - RegisterBodyDecoder("application/octet-stream", FileBodyDecoder) + RegisterBodyDecoder("text/csv", csvBodyDecoder) + RegisterBodyDecoder("text/plain", plainBodyDecoder) } func plainBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { @@ -841,27 +1030,37 @@ func plainBodyDecoder(body io.Reader, header http.Header, schema *openapi3.Schem func jsonBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { var value interface{} - if err := json.NewDecoder(body).Decode(&value); err != nil { + dec := json.NewDecoder(body) + dec.UseNumber() + if err := dec.Decode(&value); err != nil { + return nil, &ParseError{Kind: KindInvalidFormat, Cause: err} + } + return value, nil +} + +func yamlBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { + var value interface{} + if err := yaml.NewDecoder(body).Decode(&value); err != nil { return nil, &ParseError{Kind: KindInvalidFormat, Cause: err} } return value, nil } func urlencodedBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { - // Validate JSON schema of request body. + // Validate schema of request body. // By the OpenAPI 3 specification request body's schema must have type "object". // Properties of the schema describes individual parts of request body. if schema.Value.Type != "object" { - return nil, errors.New("unsupported JSON schema of request body") + return nil, errors.New("unsupported schema of request body") } for propName, propSchema := range schema.Value.Properties { switch propSchema.Value.Type { case "object": - return nil, fmt.Errorf("unsupported JSON schema of request body's property %q", propName) + return nil, fmt.Errorf("unsupported schema of request body's property %q", propName) case "array": items := propSchema.Value.Items.Value if items.Type != "string" && items.Type != "integer" && items.Type != "number" && items.Type != "boolean" { - return nil, fmt.Errorf("unsupported JSON schema of request body's property %q", propName) + return nil, fmt.Errorf("unsupported schema of request body's property %q", propName) } } } @@ -889,7 +1088,7 @@ func urlencodedBodyDecoder(body io.Reader, header http.Header, schema *openapi3. } sm := enc.SerializationMethod() - if value, err = decodeValue(dec, name, sm, prop, false); err != nil { + if value, _, err = decodeValue(dec, name, sm, prop, false); err != nil { return nil, err } obj[name] = value @@ -900,12 +1099,12 @@ func urlencodedBodyDecoder(body io.Reader, header http.Header, schema *openapi3. func multipartBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { if schema.Value.Type != "object" { - return nil, errors.New("unsupported JSON schema of request body") + return nil, errors.New("unsupported schema of request body") } // Parse form. values := make(map[string][]interface{}) - contentType := header.Get(http.CanonicalHeaderKey("Content-Type")) + contentType := header.Get(headerCT) _, params, err := mime.ParseMediaType(contentType) if err != nil { return nil, err @@ -928,54 +1127,78 @@ func multipartBodyDecoder(body io.Reader, header http.Header, schema *openapi3.S enc = encFn(name) } subEncFn := func(string) *openapi3.Encoding { return enc } - // If the property's schema has type "array" it is means that the form contains a few parts with the same name. - // Every such part has a type that is defined by an items schema in the property's schema. + var valueSchema *openapi3.SchemaRef - var exists bool - valueSchema, exists = schema.Value.Properties[name] - if !exists { - anyProperties := schema.Value.AdditionalPropertiesAllowed - if anyProperties != nil { - switch *anyProperties { - case true: - //additionalProperties: true - continue - default: - //additionalProperties: false - return nil, &ParseError{Kind: KindOther, Cause: fmt.Errorf("part %s: undefined", name)} + if len(schema.Value.AllOf) > 0 { + var exists bool + for _, sr := range schema.Value.AllOf { + if valueSchema, exists = sr.Value.Properties[name]; exists { + break } } - if schema.Value.AdditionalProperties == nil { - return nil, &ParseError{Kind: KindOther, Cause: fmt.Errorf("part %s: undefined", name)} - } - valueSchema, exists = schema.Value.AdditionalProperties.Value.Properties[name] if !exists { return nil, &ParseError{Kind: KindOther, Cause: fmt.Errorf("part %s: undefined", name)} } - } - if valueSchema.Value.Type == "array" { - valueSchema = valueSchema.Value.Items + } else { + // If the property's schema has type "array" it is means that the form contains a few parts with the same name. + // Every such part has a type that is defined by an items schema in the property's schema. + var exists bool + if valueSchema, exists = schema.Value.Properties[name]; !exists { + if anyProperties := schema.Value.AdditionalProperties.Has; anyProperties != nil { + switch *anyProperties { + case true: + //additionalProperties: true + continue + default: + //additionalProperties: false + return nil, &ParseError{Kind: KindOther, Cause: fmt.Errorf("part %s: undefined", name)} + } + } + if schema.Value.AdditionalProperties.Schema == nil { + return nil, &ParseError{Kind: KindOther, Cause: fmt.Errorf("part %s: undefined", name)} + } + if valueSchema, exists = schema.Value.AdditionalProperties.Schema.Value.Properties[name]; !exists { + return nil, &ParseError{Kind: KindOther, Cause: fmt.Errorf("part %s: undefined", name)} + } + } + if valueSchema.Value.Type == "array" { + valueSchema = valueSchema.Value.Items + } } var value interface{} - if value, err = decodeBody(part, http.Header(part.Header), valueSchema, subEncFn); err != nil { + if _, value, err = decodeBody(part, http.Header(part.Header), valueSchema, subEncFn); err != nil { if v, ok := err.(*ParseError); ok { return nil, &ParseError{path: []interface{}{name}, Cause: v} } - return nil, fmt.Errorf("part %s: %s", name, err) + return nil, fmt.Errorf("part %s: %w", name, err) } values[name] = append(values[name], value) } allTheProperties := make(map[string]*openapi3.SchemaRef) - for k, v := range schema.Value.Properties { - allTheProperties[k] = v - } - if schema.Value.AdditionalProperties != nil { - for k, v := range schema.Value.AdditionalProperties.Value.Properties { + if len(schema.Value.AllOf) > 0 { + for _, sr := range schema.Value.AllOf { + for k, v := range sr.Value.Properties { + allTheProperties[k] = v + } + if addProps := sr.Value.AdditionalProperties.Schema; addProps != nil { + for k, v := range addProps.Value.Properties { + allTheProperties[k] = v + } + } + } + } else { + for k, v := range schema.Value.Properties { allTheProperties[k] = v } + if addProps := schema.Value.AdditionalProperties.Schema; addProps != nil { + for k, v := range addProps.Value.Properties { + allTheProperties[k] = v + } + } } + // Make an object value from form values. obj := make(map[string]interface{}) for name, prop := range allTheProperties { @@ -1001,3 +1224,74 @@ func FileBodyDecoder(body io.Reader, header http.Header, schema *openapi3.Schema } return string(data), nil } + +// zipFileBodyDecoder is a body decoder that decodes a zip file body to a string. +func zipFileBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { + buff := bytes.NewBuffer([]byte{}) + size, err := io.Copy(buff, body) + if err != nil { + return nil, err + } + + zr, err := zip.NewReader(bytes.NewReader(buff.Bytes()), size) + if err != nil { + return nil, err + } + + const bufferSize = 256 + content := make([]byte, 0, bufferSize*len(zr.File)) + buffer := make([]byte /*0,*/, bufferSize) + + for _, f := range zr.File { + err := func() error { + rc, err := f.Open() + if err != nil { + return err + } + defer func() { + _ = rc.Close() + }() + + for { + n, err := rc.Read(buffer) + if 0 < n { + content = append(content, buffer...) + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + + return nil + }() + + if err != nil { + return nil, err + } + } + + return string(content), nil +} + +// csvBodyDecoder is a body decoder that decodes a csv body to a string. +func csvBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { + r := csv.NewReader(body) + + var content string + for { + record, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + content += strings.Join(record, ",") + "\n" + } + + return content, nil +} diff --git a/openapi3filter/req_resp_decoder_test.go b/openapi3filter/req_resp_decoder_test.go index 4a5cefc5e..449ba0e3a 100644 --- a/openapi3filter/req_resp_decoder_test.go +++ b/openapi3filter/req_resp_decoder_test.go @@ -2,6 +2,7 @@ package openapi3filter import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -14,8 +15,10 @@ import ( "strings" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" ) func TestDecodeParameter(t *testing.T) { @@ -29,7 +32,7 @@ func TestDecodeParameter(t *testing.T) { objectOf = func(args ...interface{}) *openapi3.SchemaRef { s := &openapi3.SchemaRef{Value: &openapi3.Schema{Type: "object", Properties: make(map[string]*openapi3.SchemaRef)}} if len(args)%2 != 0 { - panic("invalid arguments. must be an odd number of arguments") + panic("invalid arguments. must be an even number of arguments") } for i := 0; i < len(args)/2; i++ { propName := args[i*2].(string) @@ -73,6 +76,7 @@ func TestDecodeParameter(t *testing.T) { header string cookie string want interface{} + found bool err error } @@ -88,23 +92,27 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "simple", Explode: noExplode, Schema: stringSchema}, path: "/foo", want: "foo", + found: true, }, { name: "simple explode", param: &openapi3.Parameter{Name: "param", In: "path", Style: "simple", Explode: explode, Schema: stringSchema}, path: "/foo", want: "foo", + found: true, }, { name: "label", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: noExplode, Schema: stringSchema}, path: "/.foo", want: "foo", + found: true, }, { name: "label invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: noExplode, Schema: stringSchema}, path: "/foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -112,11 +120,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: explode, Schema: stringSchema}, path: "/.foo", want: "foo", + found: true, }, { name: "label explode invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: explode, Schema: stringSchema}, path: "/foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -124,11 +134,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: noExplode, Schema: stringSchema}, path: "/;param=foo", want: "foo", + found: true, }, { name: "matrix invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: noExplode, Schema: stringSchema}, path: "/foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -136,11 +148,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: explode, Schema: stringSchema}, path: "/;param=foo", want: "foo", + found: true, }, { name: "matrix explode invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: explode, Schema: stringSchema}, path: "/foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -148,23 +162,27 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Schema: stringSchema}, path: "/foo", want: "foo", + found: true, }, { name: "string", param: &openapi3.Parameter{Name: "param", In: "path", Schema: stringSchema}, path: "/foo", want: "foo", + found: true, }, { name: "integer", param: &openapi3.Parameter{Name: "param", In: "path", Schema: integerSchema}, path: "/1", - want: float64(1), + want: int64(1), + found: true, }, { name: "integer invalid", param: &openapi3.Parameter{Name: "param", In: "path", Schema: integerSchema}, path: "/foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -172,11 +190,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Schema: numberSchema}, path: "/1.1", want: 1.1, + found: true, }, { name: "number invalid", param: &openapi3.Parameter{Name: "param", In: "path", Schema: numberSchema}, path: "/foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -184,11 +204,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Schema: booleanSchema}, path: "/true", want: true, + found: true, }, { name: "boolean invalid", param: &openapi3.Parameter{Name: "param", In: "path", Schema: booleanSchema}, path: "/foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, }, @@ -201,23 +223,27 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "simple", Explode: noExplode, Schema: arraySchema}, path: "/foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "simple explode", param: &openapi3.Parameter{Name: "param", In: "path", Style: "simple", Explode: explode, Schema: arraySchema}, path: "/foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "label", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: noExplode, Schema: arraySchema}, path: "/.foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "label invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: noExplode, Schema: arraySchema}, path: "/foo,bar", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo,bar"}, }, { @@ -225,11 +251,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: explode, Schema: arraySchema}, path: "/.foo.bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "label explode invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: explode, Schema: arraySchema}, path: "/foo.bar", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo.bar"}, }, { @@ -237,11 +265,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: noExplode, Schema: arraySchema}, path: "/;param=foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "matrix invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: noExplode, Schema: arraySchema}, path: "/foo,bar", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo,bar"}, }, { @@ -249,11 +279,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: explode, Schema: arraySchema}, path: "/;param=foo;param=bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "matrix explode invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: explode, Schema: arraySchema}, path: "/foo,bar", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo,bar"}, }, { @@ -261,23 +293,27 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Schema: arraySchema}, path: "/foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "invalid integer items", param: &openapi3.Parameter{Name: "param", In: "path", Schema: arrayOf(integerSchema)}, path: "/1,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, { name: "invalid number items", param: &openapi3.Parameter{Name: "param", In: "path", Schema: arrayOf(numberSchema)}, path: "/1.1,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, { name: "invalid boolean items", param: &openapi3.Parameter{Name: "param", In: "path", Schema: arrayOf(booleanSchema)}, path: "/true,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, }, @@ -290,23 +326,27 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "simple", Explode: noExplode, Schema: objectSchema}, path: "/id,foo,name,bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "simple explode", param: &openapi3.Parameter{Name: "param", In: "path", Style: "simple", Explode: explode, Schema: objectSchema}, path: "/id=foo,name=bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "label", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: noExplode, Schema: objectSchema}, path: "/.id,foo,name,bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "label invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: noExplode, Schema: objectSchema}, path: "/id,foo,name,bar", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "id,foo,name,bar"}, }, { @@ -314,11 +354,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: explode, Schema: objectSchema}, path: "/.id=foo.name=bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "label explode invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "label", Explode: explode, Schema: objectSchema}, path: "/id=foo.name=bar", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "id=foo.name=bar"}, }, { @@ -326,11 +368,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: noExplode, Schema: objectSchema}, path: "/;param=id,foo,name,bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "matrix invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: noExplode, Schema: objectSchema}, path: "/id,foo,name,bar", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "id,foo,name,bar"}, }, { @@ -338,11 +382,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: explode, Schema: objectSchema}, path: "/;id=foo;name=bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "matrix explode invalid", param: &openapi3.Parameter{Name: "param", In: "path", Style: "matrix", Explode: explode, Schema: objectSchema}, path: "/id=foo;name=bar", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "id=foo;name=bar"}, }, { @@ -350,23 +396,27 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "path", Schema: objectSchema}, path: "/id,foo,name,bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "invalid integer prop", param: &openapi3.Parameter{Name: "param", In: "path", Schema: objectOf("foo", integerSchema)}, path: "/foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, { name: "invalid number prop", param: &openapi3.Parameter{Name: "param", In: "path", Schema: objectOf("foo", numberSchema)}, path: "/foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, { name: "invalid boolean prop", param: &openapi3.Parameter{Name: "param", In: "path", Schema: objectOf("foo", booleanSchema)}, path: "/foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, }, @@ -379,35 +429,41 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "query", Style: "form", Explode: noExplode, Schema: stringSchema}, query: "param=foo", want: "foo", + found: true, }, { name: "form explode", param: &openapi3.Parameter{Name: "param", In: "query", Style: "form", Explode: explode, Schema: stringSchema}, query: "param=foo", want: "foo", + found: true, }, { name: "default", param: &openapi3.Parameter{Name: "param", In: "query", Schema: stringSchema}, query: "param=foo", want: "foo", + found: true, }, { name: "string", param: &openapi3.Parameter{Name: "param", In: "query", Schema: stringSchema}, query: "param=foo", want: "foo", + found: true, }, { name: "integer", param: &openapi3.Parameter{Name: "param", In: "query", Schema: integerSchema}, query: "param=1", - want: float64(1), + want: int64(1), + found: true, }, { name: "integer invalid", param: &openapi3.Parameter{Name: "param", In: "query", Schema: integerSchema}, query: "param=foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -415,11 +471,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "query", Schema: numberSchema}, query: "param=1.1", want: 1.1, + found: true, }, { name: "number invalid", param: &openapi3.Parameter{Name: "param", In: "query", Schema: numberSchema}, query: "param=foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -427,11 +485,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "query", Schema: booleanSchema}, query: "param=true", want: true, + found: true, }, { name: "boolean invalid", param: &openapi3.Parameter{Name: "param", In: "query", Schema: booleanSchema}, query: "param=foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, }, @@ -444,11 +504,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "query", Schema: allofSchema}, query: "param=1", want: float64(1), + found: true, }, { name: "allofSchema string", param: &openapi3.Parameter{Name: "param", In: "query", Schema: allofSchema}, query: "param=abdf", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "abdf"}, }, }, @@ -460,13 +522,15 @@ func TestDecodeParameter(t *testing.T) { name: "anyofSchema integer", param: &openapi3.Parameter{Name: "param", In: "query", Schema: anyofSchema}, query: "param=1", - want: float64(1), + want: int64(1), + found: true, }, { name: "anyofSchema string", param: &openapi3.Parameter{Name: "param", In: "query", Schema: anyofSchema}, query: "param=abdf", want: "abdf", + found: true, }, }, }, @@ -478,18 +542,21 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "query", Schema: oneofSchema}, query: "param=true", want: true, + found: true, }, { name: "oneofSchema int", param: &openapi3.Parameter{Name: "param", In: "query", Schema: oneofSchema}, query: "param=1122", - want: float64(1122), + want: int64(1122), + found: true, }, { name: "oneofSchema string", param: &openapi3.Parameter{Name: "param", In: "query", Schema: oneofSchema}, query: "param=abcd", want: nil, + found: true, }, }, }, @@ -501,59 +568,69 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "query", Style: "form", Explode: noExplode, Schema: arraySchema}, query: "param=foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "form explode", param: &openapi3.Parameter{Name: "param", In: "query", Style: "form", Explode: explode, Schema: arraySchema}, query: "param=foo¶m=bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "spaceDelimited", param: &openapi3.Parameter{Name: "param", In: "query", Style: "spaceDelimited", Explode: noExplode, Schema: arraySchema}, query: "param=foo bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "spaceDelimited explode", param: &openapi3.Parameter{Name: "param", In: "query", Style: "spaceDelimited", Explode: explode, Schema: arraySchema}, query: "param=foo¶m=bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "pipeDelimited", param: &openapi3.Parameter{Name: "param", In: "query", Style: "pipeDelimited", Explode: noExplode, Schema: arraySchema}, query: "param=foo|bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "pipeDelimited explode", param: &openapi3.Parameter{Name: "param", In: "query", Style: "pipeDelimited", Explode: explode, Schema: arraySchema}, query: "param=foo¶m=bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "default", param: &openapi3.Parameter{Name: "param", In: "query", Schema: arraySchema}, query: "param=foo¶m=bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "invalid integer items", param: &openapi3.Parameter{Name: "param", In: "query", Schema: arrayOf(integerSchema)}, query: "param=1¶m=foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, { name: "invalid number items", param: &openapi3.Parameter{Name: "param", In: "query", Schema: arrayOf(numberSchema)}, query: "param=1.1¶m=foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, { name: "invalid boolean items", param: &openapi3.Parameter{Name: "param", In: "query", Schema: arrayOf(booleanSchema)}, query: "param=true¶m=foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, }, @@ -566,41 +643,48 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "param", In: "query", Style: "form", Explode: noExplode, Schema: objectSchema}, query: "param=id,foo,name,bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "form explode", param: &openapi3.Parameter{Name: "param", In: "query", Style: "form", Explode: explode, Schema: objectSchema}, query: "id=foo&name=bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "deepObject explode", param: &openapi3.Parameter{Name: "param", In: "query", Style: "deepObject", Explode: explode, Schema: objectSchema}, query: "param[id]=foo¶m[name]=bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "default", param: &openapi3.Parameter{Name: "param", In: "query", Schema: objectSchema}, query: "id=foo&name=bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "invalid integer prop", param: &openapi3.Parameter{Name: "param", In: "query", Schema: objectOf("foo", integerSchema)}, query: "foo=bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, { name: "invalid number prop", param: &openapi3.Parameter{Name: "param", In: "query", Schema: objectOf("foo", numberSchema)}, query: "foo=bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, { name: "invalid boolean prop", param: &openapi3.Parameter{Name: "param", In: "query", Schema: objectOf("foo", booleanSchema)}, query: "foo=bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, }, @@ -613,35 +697,41 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "header", Style: "simple", Explode: noExplode, Schema: stringSchema}, header: "X-Param:foo", want: "foo", + found: true, }, { name: "simple explode", param: &openapi3.Parameter{Name: "X-Param", In: "header", Style: "simple", Explode: explode, Schema: stringSchema}, header: "X-Param:foo", want: "foo", + found: true, }, { name: "default", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: stringSchema}, header: "X-Param:foo", want: "foo", + found: true, }, { name: "string", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: stringSchema}, header: "X-Param:foo", want: "foo", + found: true, }, { name: "integer", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: integerSchema}, header: "X-Param:1", - want: float64(1), + want: int64(1), + found: true, }, { name: "integer invalid", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: integerSchema}, header: "X-Param:foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -649,11 +739,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: numberSchema}, header: "X-Param:1.1", want: 1.1, + found: true, }, { name: "number invalid", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: numberSchema}, header: "X-Param:foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -661,11 +753,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: booleanSchema}, header: "X-Param:true", want: true, + found: true, }, { name: "boolean invalid", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: booleanSchema}, header: "X-Param:foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, }, @@ -678,35 +772,41 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "header", Style: "simple", Explode: noExplode, Schema: arraySchema}, header: "X-Param:foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "simple explode", param: &openapi3.Parameter{Name: "X-Param", In: "header", Style: "simple", Explode: explode, Schema: arraySchema}, header: "X-Param:foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "default", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: arraySchema}, header: "X-Param:foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "invalid integer items", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: arrayOf(integerSchema)}, header: "X-Param:1,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, { name: "invalid number items", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: arrayOf(numberSchema)}, header: "X-Param:1.1,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, { name: "invalid boolean items", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: arrayOf(booleanSchema)}, header: "X-Param:true,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, }, @@ -719,35 +819,48 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "header", Style: "simple", Explode: noExplode, Schema: objectSchema}, header: "X-Param:id,foo,name,bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "simple explode", param: &openapi3.Parameter{Name: "X-Param", In: "header", Style: "simple", Explode: explode, Schema: objectSchema}, header: "X-Param:id=foo,name=bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "default", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: objectSchema}, header: "X-Param:id,foo,name,bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, + }, + { + name: "valid integer prop", + param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: integerSchema}, + header: "X-Param:88", + found: true, + want: int64(88), }, { name: "invalid integer prop", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: objectOf("foo", integerSchema)}, header: "X-Param:foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, { name: "invalid number prop", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: objectOf("foo", numberSchema)}, header: "X-Param:foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, { name: "invalid boolean prop", param: &openapi3.Parameter{Name: "X-Param", In: "header", Schema: objectOf("foo", booleanSchema)}, header: "X-Param:foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, }, @@ -760,35 +873,41 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: stringSchema}, cookie: "X-Param:foo", want: "foo", + found: true, }, { name: "form explode", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: explode, Schema: stringSchema}, cookie: "X-Param:foo", want: "foo", + found: true, }, { name: "default", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Schema: stringSchema}, cookie: "X-Param:foo", want: "foo", + found: true, }, { name: "string", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Schema: stringSchema}, cookie: "X-Param:foo", want: "foo", + found: true, }, { name: "integer", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Schema: integerSchema}, cookie: "X-Param:1", - want: float64(1), + want: int64(1), + found: true, }, { name: "integer invalid", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Schema: integerSchema}, cookie: "X-Param:foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -796,11 +915,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Schema: numberSchema}, cookie: "X-Param:1.1", want: 1.1, + found: true, }, { name: "number invalid", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Schema: numberSchema}, cookie: "X-Param:foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, { @@ -808,11 +929,13 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Schema: booleanSchema}, cookie: "X-Param:true", want: true, + found: true, }, { name: "boolean invalid", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Schema: booleanSchema}, cookie: "X-Param:foo", + found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, }, @@ -825,23 +948,27 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: arraySchema}, cookie: "X-Param:foo,bar", want: []interface{}{"foo", "bar"}, + found: true, }, { name: "invalid integer items", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: arrayOf(integerSchema)}, cookie: "X-Param:1,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, { name: "invalid number items", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: arrayOf(numberSchema)}, cookie: "X-Param:1.1,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, { name: "invalid boolean items", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: arrayOf(booleanSchema)}, cookie: "X-Param:true,foo", + found: true, err: &ParseError{path: []interface{}{1}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "foo"}}, }, }, @@ -854,23 +981,27 @@ func TestDecodeParameter(t *testing.T) { param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: objectSchema}, cookie: "X-Param:id,foo,name,bar", want: map[string]interface{}{"id": "foo", "name": "bar"}, + found: true, }, { name: "invalid integer prop", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: objectOf("foo", integerSchema)}, cookie: "X-Param:foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, { name: "invalid number prop", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: objectOf("foo", numberSchema)}, cookie: "X-Param:foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, { name: "invalid boolean prop", param: &openapi3.Parameter{Name: "X-Param", In: "cookie", Style: "form", Explode: noExplode, Schema: objectOf("foo", booleanSchema)}, cookie: "X-Param:foo,bar", + found: true, err: &ParseError{path: []interface{}{"foo"}, Cause: &ParseError{Kind: KindInvalidFormat, Value: "bar"}}, }, }, @@ -903,37 +1034,35 @@ func TestDecodeParameter(t *testing.T) { req.AddCookie(&http.Cookie{Name: v[0], Value: v[1]}) } - var path string + path := "/test" if tc.path != "" { - switch tc.param.Style { - case "label": - path = "." + tc.param.Name - case "matrix": - path = ";" + tc.param.Name - default: - path = tc.param.Name - } - if tc.param.Explode != nil && *tc.param.Explode { - path += "*" - } - path = "/{" + path + "}" + path += "/{" + tc.param.Name + "}" + tc.param.Required = true } info := &openapi3.Info{ Title: "MyAPI", Version: "0.1", } - spec := &openapi3.Swagger{OpenAPI: "3.0.0", Info: info} - op := &openapi3.Operation{OperationID: "test", Parameters: []*openapi3.ParameterRef{{Value: tc.param}}, Responses: make(openapi3.Responses)} - spec.AddOperation("/test"+path, http.MethodGet, op) - router := NewRouter() - require.NoError(t, router.AddSwagger(spec), "failed to create a router") + spec := &openapi3.T{OpenAPI: "3.0.0", Info: info} + op := &openapi3.Operation{ + OperationID: "test", + Parameters: []*openapi3.ParameterRef{{Value: tc.param}}, + Responses: openapi3.NewResponses(), + } + spec.AddOperation(path, http.MethodGet, op) + err = spec.Validate(context.Background()) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(spec) + require.NoError(t, err) - route, pathParams, err := router.FindRoute(req.Method, req.URL) - require.NoError(t, err, "failed to find a route") + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) input := &RequestValidationInput{Request: req, PathParams: pathParams, Route: route} - got, err := decodeStyledParameter(tc.param, input) + got, found, err := decodeStyledParameter(tc.param, input) + + require.Truef(t, found == tc.found, "got found: %t, want found: %t", found, tc.found) if tc.err != nil { require.Error(t, err) @@ -977,6 +1106,7 @@ func TestDecodeBody(t *testing.T) { {name: "c", contentType: "text/plain", data: strings.NewReader("c2")}, {name: "d", contentType: "application/json", data: bytes.NewReader(d)}, {name: "f", contentType: "application/octet-stream", data: strings.NewReader("foo"), filename: "f1"}, + {name: "g", data: strings.NewReader("g1")}, }) require.NoError(t, err) @@ -990,10 +1120,14 @@ func TestDecodeBody(t *testing.T) { {name: "a", contentType: "text/plain", data: strings.NewReader("a1")}, {name: "x", contentType: "text/plain", data: strings.NewReader("x1")}, }) + require.NoError(t, err) + multipartAdditionalProps, multipartMimeAdditionalProps, err := newTestMultipartForm([]*testFormPart{ {name: "a", contentType: "text/plain", data: strings.NewReader("a1")}, {name: "x", contentType: "text/plain", data: strings.NewReader("x1")}, }) + require.NoError(t, err) + multipartAdditionalPropsErr, multipartMimeAdditionalPropsErr, err := newTestMultipartForm([]*testFormPart{ {name: "a", contentType: "text/plain", data: strings.NewReader("a1")}, {name: "x", contentType: "text/plain", data: strings.NewReader("x1")}, @@ -1011,7 +1145,7 @@ func TestDecodeBody(t *testing.T) { wantErr error }{ { - name: "unsupported content type", + name: prefixUnsupportedCT, mime: "application/xml", wantErr: &ParseError{Kind: KindUnsupportedFormat}, }, @@ -1033,6 +1167,18 @@ func TestDecodeBody(t *testing.T) { body: strings.NewReader("\"foo\""), want: "foo", }, + { + name: "x-yaml", + mime: "application/x-yaml", + body: strings.NewReader("foo"), + want: "foo", + }, + { + name: "yaml", + mime: "application/yaml", + body: strings.NewReader("foo"), + want: "foo", + }, { name: "urlencoded form", mime: "application/x-www-form-urlencoded", @@ -1041,7 +1187,7 @@ func TestDecodeBody(t *testing.T) { WithProperty("a", openapi3.NewStringSchema()). WithProperty("b", openapi3.NewIntegerSchema()). WithProperty("c", openapi3.NewArraySchema().WithItems(openapi3.NewStringSchema())), - want: map[string]interface{}{"a": "a1", "b": float64(10), "c": []interface{}{"c1", "c2"}}, + want: map[string]interface{}{"a": "a1", "b": int64(10), "c": []interface{}{"c1", "c2"}}, }, { name: "urlencoded space delimited", @@ -1054,7 +1200,7 @@ func TestDecodeBody(t *testing.T) { encoding: map[string]*openapi3.Encoding{ "c": {Style: openapi3.SerializationSpaceDelimited, Explode: boolPtr(false)}, }, - want: map[string]interface{}{"a": "a1", "b": float64(10), "c": []interface{}{"c1", "c2"}}, + want: map[string]interface{}{"a": "a1", "b": int64(10), "c": []interface{}{"c1", "c2"}}, }, { name: "urlencoded pipe delimited", @@ -1067,7 +1213,7 @@ func TestDecodeBody(t *testing.T) { encoding: map[string]*openapi3.Encoding{ "c": {Style: openapi3.SerializationPipeDelimited, Explode: boolPtr(false)}, }, - want: map[string]interface{}{"a": "a1", "b": float64(10), "c": []interface{}{"c1", "c2"}}, + want: map[string]interface{}{"a": "a1", "b": int64(10), "c": []interface{}{"c1", "c2"}}, }, { name: "multipart", @@ -1078,8 +1224,9 @@ func TestDecodeBody(t *testing.T) { WithProperty("b", openapi3.NewIntegerSchema()). WithProperty("c", openapi3.NewArraySchema().WithItems(openapi3.NewStringSchema())). WithProperty("d", openapi3.NewObjectSchema().WithProperty("d1", openapi3.NewStringSchema())). - WithProperty("f", openapi3.NewStringSchema().WithFormat("binary")), - want: map[string]interface{}{"a": "a1", "b": float64(10), "c": []interface{}{"c1", "c2"}, "d": map[string]interface{}{"d1": "d1"}, "f": "foo"}, + WithProperty("f", openapi3.NewStringSchema().WithFormat("binary")). + WithProperty("g", openapi3.NewStringSchema()), + want: map[string]interface{}{"a": "a1", "b": json.Number("10"), "c": []interface{}{"c1", "c2"}, "d": map[string]interface{}{"d1": "d1"}, "f": "foo", "g": "g1"}, }, { name: "multipartExtraPart", @@ -1130,7 +1277,7 @@ func TestDecodeBody(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { h := make(http.Header) - h.Set(http.CanonicalHeaderKey("Content-Type"), tc.mime) + h.Set(headerCT, tc.mime) var schemaRef *openapi3.SchemaRef if tc.schema != nil { schemaRef = tc.schema.NewRef() @@ -1141,7 +1288,7 @@ func TestDecodeBody(t *testing.T) { } return tc.encoding[name] } - got, err := decodeBody(tc.body, h, schemaRef, encFn) + _, got, err := decodeBody(tc.body, h, schemaRef, encFn) if tc.wantErr != nil { require.Error(t, err) @@ -1176,7 +1323,7 @@ func newTestMultipartForm(parts []*testFormPart) (io.Reader, string, error) { } h := make(textproto.MIMEHeader) - h.Set("Content-Type", p.contentType) + h.Set(headerCT, p.contentType) h.Set("Content-Disposition", disp) pw, err := w.CreatePart(h) if err != nil { @@ -1190,39 +1337,42 @@ func newTestMultipartForm(parts []*testFormPart) (io.Reader, string, error) { } func TestRegisterAndUnregisterBodyDecoder(t *testing.T) { - var ( - contentType = "text/csv" - decoder = func(body io.Reader, h http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (interface{}, error) { - data, err := ioutil.ReadAll(body) - if err != nil { - return nil, err - } - var vv []interface{} - for _, v := range strings.Split(string(data), ",") { - vv = append(vv, v) - } - return vv, nil + var decoder BodyDecoder + decoder = func(body io.Reader, h http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (decoded interface{}, err error) { + var data []byte + if data, err = ioutil.ReadAll(body); err != nil { + return } - schema = openapi3.NewArraySchema().WithItems(openapi3.NewStringSchema()).NewRef() - encFn = func(string) *openapi3.Encoding { return nil } - body = strings.NewReader("foo,bar") - want = []interface{}{"foo", "bar"} - wantErr = &ParseError{Kind: KindUnsupportedFormat} - ) + return strings.Split(string(data), ","), nil + } + contentType := "application/csv" h := make(http.Header) - h.Set(http.CanonicalHeaderKey("Content-Type"), contentType) + h.Set(headerCT, contentType) + + originalDecoder := RegisteredBodyDecoder(contentType) + require.Nil(t, originalDecoder) RegisterBodyDecoder(contentType, decoder) - got, err := decodeBody(body, h, schema, encFn) + require.Equal(t, fmt.Sprintf("%v", decoder), fmt.Sprintf("%v", RegisteredBodyDecoder(contentType))) + + body := strings.NewReader("foo,bar") + schema := openapi3.NewArraySchema().WithItems(openapi3.NewStringSchema()).NewRef() + encFn := func(string) *openapi3.Encoding { return nil } + _, got, err := decodeBody(body, h, schema, encFn) require.NoError(t, err) - require.Truef(t, reflect.DeepEqual(got, want), "got %v, want %v", got, want) + require.Equal(t, []string{"foo", "bar"}, got) UnregisterBodyDecoder(contentType) - _, err = decodeBody(body, h, schema, encFn) - require.Error(t, err) - require.Truef(t, matchParseError(err, wantErr), "got error:\n%v\nwant error:\n%v", err, wantErr) + originalDecoder = RegisteredBodyDecoder(contentType) + require.Nil(t, originalDecoder) + + _, _, err = decodeBody(body, h, schema, encFn) + require.Equal(t, &ParseError{ + Kind: KindUnsupportedFormat, + Reason: prefixUnsupportedCT + ` "application/csv"`, + }, err) } func matchParseError(got, want error) bool { diff --git a/openapi3filter/req_resp_encoder.go b/openapi3filter/req_resp_encoder.go new file mode 100644 index 000000000..36b7db6fd --- /dev/null +++ b/openapi3filter/req_resp_encoder.go @@ -0,0 +1,49 @@ +package openapi3filter + +import ( + "encoding/json" + "fmt" +) + +func encodeBody(body interface{}, mediaType string) ([]byte, error) { + encoder, ok := bodyEncoders[mediaType] + if !ok { + return nil, &ParseError{ + Kind: KindUnsupportedFormat, + Reason: fmt.Sprintf("%s %q", prefixUnsupportedCT, mediaType), + } + } + return encoder(body) +} + +type BodyEncoder func(body interface{}) ([]byte, error) + +var bodyEncoders = map[string]BodyEncoder{ + "application/json": json.Marshal, +} + +func RegisterBodyEncoder(contentType string, encoder BodyEncoder) { + if contentType == "" { + panic("contentType is empty") + } + if encoder == nil { + panic("encoder is not defined") + } + bodyEncoders[contentType] = encoder +} + +// This call is not thread-safe: body encoders should not be created/destroyed by multiple goroutines. +func UnregisterBodyEncoder(contentType string) { + if contentType == "" { + panic("contentType is empty") + } + delete(bodyEncoders, contentType) +} + +// RegisteredBodyEncoder returns the registered body encoder for the given content type. +// +// If no encoder was registered for the given content type, nil is returned. +// This call is not thread-safe: body encoders should not be created/destroyed by multiple goroutines. +func RegisteredBodyEncoder(contentType string) BodyEncoder { + return bodyEncoders[contentType] +} diff --git a/openapi3filter/req_resp_encoder_test.go b/openapi3filter/req_resp_encoder_test.go new file mode 100644 index 000000000..11fe2afa9 --- /dev/null +++ b/openapi3filter/req_resp_encoder_test.go @@ -0,0 +1,43 @@ +package openapi3filter + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRegisterAndUnregisterBodyEncoder(t *testing.T) { + var encoder BodyEncoder + encoder = func(body interface{}) (data []byte, err error) { + return []byte(strings.Join(body.([]string), ",")), nil + } + contentType := "text/csv" + h := make(http.Header) + h.Set(headerCT, contentType) + + originalEncoder := RegisteredBodyEncoder(contentType) + require.Nil(t, originalEncoder) + + RegisterBodyEncoder(contentType, encoder) + require.Equal(t, fmt.Sprintf("%v", encoder), fmt.Sprintf("%v", RegisteredBodyEncoder(contentType))) + + body := []string{"foo", "bar"} + got, err := encodeBody(body, contentType) + + require.NoError(t, err) + require.Equal(t, []byte("foo,bar"), got) + + UnregisterBodyEncoder(contentType) + + originalEncoder = RegisteredBodyEncoder(contentType) + require.Nil(t, originalEncoder) + + _, err = encodeBody(body, contentType) + require.Equal(t, &ParseError{ + Kind: KindUnsupportedFormat, + Reason: prefixUnsupportedCT + ` "text/csv"`, + }, err) +} diff --git a/openapi3filter/router.go b/openapi3filter/router.go deleted file mode 100644 index 3904750eb..000000000 --- a/openapi3filter/router.go +++ /dev/null @@ -1,217 +0,0 @@ -package openapi3filter - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/pathpattern" -) - -type Route struct { - Swagger *openapi3.Swagger - Server *openapi3.Server - Path string - PathItem *openapi3.PathItem - Method string - Operation *openapi3.Operation - - // For developers who want use the router for handling too - Handler http.Handler -} - -// Routers maps a HTTP request to a Router. -type Routers []*Router - -func (routers Routers) FindRoute(method string, url *url.URL) (*Router, *Route, map[string]string, error) { - for _, router := range routers { - // Skip routers that have DO NOT have servers - if len(router.swagger.Servers) == 0 { - continue - } - route, pathParams, err := router.FindRoute(method, url) - if err == nil { - return router, route, pathParams, nil - } - } - for _, router := range routers { - // Skip routers that DO have servers - if len(router.swagger.Servers) > 0 { - continue - } - route, pathParams, err := router.FindRoute(method, url) - if err == nil { - return router, route, pathParams, nil - } - } - return nil, nil, nil, &RouteError{ - Reason: "None of the routers matches", - } -} - -// Router maps a HTTP request to an OpenAPI operation. -type Router struct { - swagger *openapi3.Swagger - pathNode *pathpattern.Node -} - -// NewRouter creates a new router. -// -// If the given Swagger has servers, router will use them. -// All operations of the Swagger will be added to the router. -func NewRouter() *Router { - return &Router{} -} - -// WithSwaggerFromFile loads the Swagger file and adds it using WithSwagger. -// Panics on any error. -func (router *Router) WithSwaggerFromFile(path string) *Router { - if err := router.AddSwaggerFromFile(path); err != nil { - panic(err) - } - return router -} - -// WithSwagger adds all operations in the OpenAPI specification. -// Panics on any error. -func (router *Router) WithSwagger(swagger *openapi3.Swagger) *Router { - if err := router.AddSwagger(swagger); err != nil { - panic(err) - } - return router -} - -// AddSwaggerFromFile loads the Swagger file and adds it using AddSwagger. -func (router *Router) AddSwaggerFromFile(path string) error { - swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile(path) - if err != nil { - return err - } - return router.AddSwagger(swagger) -} - -// AddSwagger adds all operations in the OpenAPI specification. -func (router *Router) AddSwagger(swagger *openapi3.Swagger) error { - if err := swagger.Validate(context.TODO()); err != nil { - return fmt.Errorf("Validating Swagger failed: %v", err) - } - router.swagger = swagger - root := router.node() - for path, pathItem := range swagger.Paths { - for method, operation := range pathItem.Operations() { - method = strings.ToUpper(method) - if err := root.Add(method+" "+path, &Route{ - Swagger: swagger, - Path: path, - PathItem: pathItem, - Method: method, - Operation: operation, - }, nil); err != nil { - return err - } - } - } - return nil -} - -// AddRoute adds a route in the router. -func (router *Router) AddRoute(route *Route) error { - method := route.Method - if method == "" { - return errors.New("Route is missing method") - } - method = strings.ToUpper(method) - path := route.Path - if path == "" { - return errors.New("Route is missing path") - } - return router.node().Add(method+" "+path, router, nil) -} - -func (router *Router) node() *pathpattern.Node { - root := router.pathNode - if root == nil { - root = &pathpattern.Node{} - router.pathNode = root - } - return root -} - -func (router *Router) FindRoute(method string, url *url.URL) (*Route, map[string]string, error) { - swagger := router.swagger - - // Get server - servers := swagger.Servers - var server *openapi3.Server - var remainingPath string - var pathParams map[string]string - if len(servers) == 0 { - remainingPath = url.Path - } else { - var paramValues []string - server, paramValues, remainingPath = servers.MatchURL(url) - if server == nil { - return nil, nil, &RouteError{ - Route: Route{ - Swagger: swagger, - }, - Reason: "Does not match any server", - } - } - pathParams = make(map[string]string, 8) - paramNames, _ := server.ParameterNames() - for i, value := range paramValues { - name := paramNames[i] - pathParams[name] = value - } - } - - // Get PathItem - root := router.node() - var route *Route - node, paramValues := root.Match(method + " " + remainingPath) - if node != nil { - route, _ = node.Value.(*Route) - } - if route == nil { - pathItem := swagger.Paths[remainingPath] - if pathItem == nil { - return nil, nil, &RouteError{ - Route: Route{ - Swagger: swagger, - Server: server, - }, - Reason: "Path was not found", - } - } - - // Get operation - if pathItem.GetOperation(method) == nil { - return nil, nil, &RouteError{ - Route: Route{ - Swagger: swagger, - Server: server, - }, - Reason: "Path doesn't support the HTTP method", - } - } - - } - - if pathParams == nil { - pathParams = make(map[string]string, len(paramValues)) - } - paramKeys := node.VariableNames - for i, value := range paramValues { - key := paramKeys[i] - if strings.HasSuffix(key, "*") { - key = key[:len(key)-1] - } - pathParams[key] = value - } - return route, pathParams, nil -} diff --git a/openapi3filter/router_test.go b/openapi3filter/router_test.go deleted file mode 100644 index 4eba2c368..000000000 --- a/openapi3filter/router_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package openapi3filter_test - -import ( - "net/http" - "sort" - "testing" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" - "github.com/stretchr/testify/require" -) - -func TestRouter(t *testing.T) { - var ( - pathNotFound = "Path was not found" - methodNotAllowed = "Path doesn't support the HTTP method" - doesNotMatchAnyServer = "Does not match any server" - ) - - // Build swagger - helloCONNECT := &openapi3.Operation{Responses: make(openapi3.Responses)} - helloDELETE := &openapi3.Operation{Responses: make(openapi3.Responses)} - helloGET := &openapi3.Operation{Responses: make(openapi3.Responses)} - helloHEAD := &openapi3.Operation{Responses: make(openapi3.Responses)} - helloOPTIONS := &openapi3.Operation{Responses: make(openapi3.Responses)} - helloPATCH := &openapi3.Operation{Responses: make(openapi3.Responses)} - helloPOST := &openapi3.Operation{Responses: make(openapi3.Responses)} - helloPUT := &openapi3.Operation{Responses: make(openapi3.Responses)} - helloTRACE := &openapi3.Operation{Responses: make(openapi3.Responses)} - paramsGET := &openapi3.Operation{Responses: make(openapi3.Responses)} - partialGET := &openapi3.Operation{Responses: make(openapi3.Responses)} - swagger := &openapi3.Swagger{ - OpenAPI: "3.0.0", - Info: &openapi3.Info{ - Title: "MyAPI", - Version: "0.1", - }, - Paths: openapi3.Paths{ - "/hello": &openapi3.PathItem{ - Connect: helloCONNECT, - Delete: helloDELETE, - Get: helloGET, - Head: helloHEAD, - Options: helloOPTIONS, - Patch: helloPATCH, - Post: helloPOST, - Put: helloPUT, - Trace: helloTRACE, - }, - "/onlyGET": &openapi3.PathItem{ - Get: helloGET, - }, - "/params/{x}/{y}/{z*}": &openapi3.PathItem{ - Get: paramsGET, - }, - "/partial": &openapi3.PathItem{ - Get: partialGET, - }, - }, - } - - // Build router - router := openapi3filter.NewRouter().WithSwagger(swagger) - - // Declare a helper function - expect := func(method string, uri string, operation *openapi3.Operation, params map[string]string) { - req, err := http.NewRequest(method, uri, nil) - require.NoError(t, err) - route, pathParams, err := router.FindRoute(req.Method, req.URL) - if err != nil { - if operation == nil { - if err.Error() == doesNotMatchAnyServer { - return - } - - pathItem := swagger.Paths[uri] - if pathItem == nil { - if err.Error() != pathNotFound { - t.Fatalf("'%s %s': should have returned '%s', but it returned an error: %v", - method, uri, pathNotFound, err) - } - return - } - if pathItem.GetOperation(method) == nil { - if err.Error() != methodNotAllowed { - t.Fatalf("'%s %s': should have returned '%s', but it returned an error: %v", - method, uri, methodNotAllowed, err) - } - } - } else { - t.Fatalf("'%s %s': should have returned an operation, but it returned an error: %v", - method, uri, err) - } - } - if operation == nil && err == nil { - t.Fatalf("'%s %s': should have returned an error, but didn't", - method, uri) - } - if route == nil { - return - } - if route.Operation != operation { - t.Fatalf("'%s %s': Returned wrong operation (%v)", - method, uri, route.Operation) - } - if params == nil { - if len(pathParams) != 0 { - t.Fatalf("'%s %s': should return no path arguments, but found some", - method, uri) - } - } else { - names := make([]string, 0, len(params)) - for name := range params { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - expected := params[name] - actual, exists := pathParams[name] - if !exists { - t.Fatalf("'%s %s': path parameter '%s' should be '%s', but it's not defined.", - method, uri, name, expected) - } - if actual != expected { - t.Fatalf("'%s %s': path parameter '%s' should be '%s', but it's '%s'", - method, uri, name, expected, actual) - } - } - } - } - expect(http.MethodGet, "/not_existing", nil, nil) - expect(http.MethodDelete, "/hello", helloDELETE, nil) - expect(http.MethodGet, "/hello", helloGET, nil) - expect(http.MethodHead, "/hello", helloHEAD, nil) - expect(http.MethodPatch, "/hello", helloPATCH, nil) - expect(http.MethodPost, "/hello", helloPOST, nil) - expect(http.MethodPut, "/hello", helloPUT, nil) - expect(http.MethodGet, "/params/a/b/c/d", paramsGET, map[string]string{ - "x": "a", - "y": "b", - "z": "c/d", - }) - expect(http.MethodPost, "/partial", nil, nil) - swagger.Servers = append(swagger.Servers, &openapi3.Server{ - URL: "https://www.example.com/api/v1/", - }, &openapi3.Server{ - URL: "https://{d0}.{d1}.com/api/v1/", - }) - expect(http.MethodGet, "/hello", nil, nil) - expect(http.MethodGet, "/api/v1/hello", nil, nil) - expect(http.MethodGet, "www.example.com/api/v1/hello", nil, nil) - expect(http.MethodGet, "https:///api/v1/hello", nil, nil) - expect(http.MethodGet, "https://www.example.com/hello", nil, nil) - expect(http.MethodGet, "https://www.example.com/api/v1/hello", helloGET, map[string]string{}) - expect(http.MethodGet, "https://domain0.domain1.com/api/v1/hello", helloGET, map[string]string{ - "d0": "domain0", - "d1": "domain1", - }) - - { - uri := "https://www.example.com/api/v1/onlyGET" - expect(http.MethodGet, uri, helloGET, nil) - req, err := http.NewRequest(http.MethodDelete, uri, nil) - require.NoError(t, err) - require.NotNil(t, req) - route, pathParams, err := router.FindRoute(req.Method, req.URL) - require.Error(t, err) - require.Equal(t, err.(*openapi3filter.RouteError).Reason, "Path doesn't support the HTTP method") - require.Nil(t, route) - require.Nil(t, pathParams) - } -} diff --git a/openapi3filter/fixtures/petstore.json b/openapi3filter/testdata/fixtures/petstore.json similarity index 89% rename from openapi3filter/fixtures/petstore.json rename to openapi3filter/testdata/fixtures/petstore.json index 6c229a672..88c3c6a85 100644 --- a/openapi3filter/fixtures/petstore.json +++ b/openapi3filter/testdata/fixtures/petstore.json @@ -67,7 +67,21 @@ ], "requestBody": { "$ref": "#/components/requestBodies/PetWithRequired" - } + }, + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "demo", + "prod" + ] + }, + "in": "header", + "name": "x-environment", + "description": "Where to send the data for processing" + } + ] }, "patch": { "tags": [ @@ -107,7 +121,11 @@ ], "summary": "Add a new pet to the store", "description": "", +<<<<<<< HEAD:openapi3filter/fixtures/petstore.json "operationId": "addPet", +======= + "operationId": "addPet2", +>>>>>>> upstream/master:openapi3filter/testdata/fixtures/petstore.json "responses": { "405": { "description": "Invalid input" @@ -178,6 +196,114 @@ {"$ref": "#/components/schemas/Pet"}, {"$ref": "#/components/schemas/PetRequiredProperties"} ] +<<<<<<< HEAD:openapi3filter/fixtures/petstore.json +======= + } + } + } + } + }, + "400": { + "description": "Invalid status value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pets/": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pets by the specified filters", + "description": "Returns a list of pets that comply with the specified filters", + "operationId": "findPets", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": false, + "explode": true, + "allowEmptyValue": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "available", + "pending", + "sold" + ], + "default": "available" + } + } + }, + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": false, + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "kind", + "in": "query", + "description": "Kinds to filter by", + "required": false, + "explode": false, + "style": "pipeDelimited", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "dog", + "cat", + "turtle", + "bird,with,commas" + ] + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "type": "array", + "items": { + "allOf": [ + {"$ref": "#/components/schemas/Pet"}, + {"$ref": "#/components/schemas/PetRequiredProperties"} + ] + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "allOf": [ + {"$ref": "#/components/schemas/Pet"}, + {"$ref": "#/components/schemas/PetRequiredProperties"} + ] +>>>>>>> upstream/master:openapi3filter/testdata/fixtures/petstore.json } } } @@ -1136,7 +1262,11 @@ }, "name": { "type": "string", +<<<<<<< HEAD:openapi3filter/fixtures/petstore.json "example": "doggie", +======= + "example": "doggie" +>>>>>>> upstream/master:openapi3filter/testdata/fixtures/petstore.json }, "photoUrls": { "type": "array", diff --git a/openapi3filter/testdata/petstore.yaml b/openapi3filter/testdata/petstore.yaml new file mode 100644 index 000000000..026c37e27 --- /dev/null +++ b/openapi3filter/testdata/petstore.yaml @@ -0,0 +1,106 @@ +openapi: "3.0.0" +info: + description: "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters." + version: "1.0.0" + title: "Swagger Petstore" + termsOfService: "http://swagger.io/terms/" + contact: + email: "apiteam@swagger.io" + license: + name: "Apache 2.0" + url: "http://www.apache.org/licenses/LICENSE-2.0.html" +tags: +- name: "pet" + description: "Everything about your Pets" + externalDocs: + description: "Find out more" + url: "http://swagger.io" +- name: "store" + description: "Access to Petstore orders" +- name: "user" + description: "Operations about user" + externalDocs: + description: "Find out more about our store" + url: "http://swagger.io" +paths: + /pet: + post: + tags: + - "pet" + summary: "Add a new pet to the store" + description: "" + operationId: "addPet" + parameters: + - name: num + in: query + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + 'application/json': + schema: + $ref: '#/components/schemas/Pet' + responses: + "405": + description: "Invalid input" +components: + schemas: + Category: + type: "object" + properties: + id: + type: "integer" + format: "int64" + name: + type: "string" + xml: + name: "Category" + Tag: + type: "object" + properties: + id: + type: "integer" + format: "int64" + name: + type: "string" + xml: + name: "Tag" + Pet: + type: "object" + required: + - "name" + - "photoUrls" + properties: + id: + type: "integer" + format: "int64" + category: + $ref: "#/components/schemas/Category" + name: + type: "string" + example: "doggie" + photoUrls: + type: "array" + xml: + name: "photoUrl" + wrapped: true + items: + type: "string" + tags: + type: "array" + xml: + name: "tag" + wrapped: true + items: + $ref: "#/components/schemas/Tag" + status: + type: "string" + description: "pet status in the store" + enum: + - "available" + - "pending" + - "sold" + xml: + name: "Pet" diff --git a/openapi3filter/unpack_errors_test.go b/openapi3filter/unpack_errors_test.go new file mode 100644 index 000000000..befff1054 --- /dev/null +++ b/openapi3filter/unpack_errors_test.go @@ -0,0 +1,166 @@ +package openapi3filter_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func Example() { + doc, err := openapi3.NewLoader().LoadFromFile("./testdata/petstore.yaml") + if err != nil { + panic(err) + } + + router, err := gorillamux.NewRouter(doc) + if err != nil { + panic(err) + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + route, pathParams, err := router.FindRoute(r) + if err != nil { + fmt.Println(err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + Options: &openapi3filter.Options{ + MultiError: true, + }, + }) + switch err := err.(type) { + case nil: + case openapi3.MultiError: + issues := convertError(err) + names := make([]string, 0, len(issues)) + for k := range issues { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + msgs := issues[k] + fmt.Println("===== Start New Error =====") + fmt.Println(k + ":") + for _, msg := range msgs { + fmt.Printf("\t%s\n", msg) + } + } + w.WriteHeader(http.StatusBadRequest) + default: + fmt.Println(err.Error()) + w.WriteHeader(http.StatusBadRequest) + } + })) + defer ts.Close() + + // (note invalid type for name and invalid status) + body := strings.NewReader(`{"name": 100, "photoUrls": [], "status": "invalidStatus"}`) + req, err := http.NewRequest("POST", ts.URL+"/pet?num=0", body) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + fmt.Printf("response: %d %s\n", resp.StatusCode, resp.Body) + + // Output: + // ===== Start New Error ===== + // @body.name: + // Error at "/name": value must be a string + // Schema: + // { + // "example": "doggie", + // "type": "string" + // } + // + // Value: + // 100 + // + // ===== Start New Error ===== + // @body.status: + // Error at "/status": value is not one of the allowed values ["available","pending","sold"] + // Schema: + // { + // "description": "pet status in the store", + // "enum": [ + // "available", + // "pending", + // "sold" + // ], + // "type": "string" + // } + // + // Value: + // "invalidStatus" + // + // ===== Start New Error ===== + // query.num: + // parameter "num" in query has an error: number must be at least 1 + // Schema: + // { + // "minimum": 1, + // "type": "integer" + // } + // + // Value: + // 0 + // + // response: 400 {} +} + +func convertError(me openapi3.MultiError) map[string][]string { + issues := make(map[string][]string) + for _, err := range me { + const prefixBody = "@body" + switch err := err.(type) { + case *openapi3.SchemaError: + // Can inspect schema validation errors here, e.g. err.Value + field := prefixBody + if path := err.JSONPointer(); len(path) > 0 { + field = fmt.Sprintf("%s.%s", field, strings.Join(path, ".")) + } + issues[field] = append(issues[field], err.Error()) + case *openapi3filter.RequestError: // possible there were multiple issues that failed validation + + // check if invalid HTTP parameter + if err.Parameter != nil { + prefix := err.Parameter.In + name := fmt.Sprintf("%s.%s", prefix, err.Parameter.Name) + issues[name] = append(issues[name], err.Error()) + continue + } + + if err, ok := err.Err.(openapi3.MultiError); ok { + for k, v := range convertError(err) { + issues[k] = append(issues[k], v...) + } + continue + } + + // check if requestBody + if err.RequestBody != nil { + issues[prefixBody] = append(issues[prefixBody], err.Error()) + continue + } + default: + const unknown = "@unknown" + issues[unknown] = append(issues[unknown], err.Error()) + } + } + return issues +} diff --git a/openapi3filter/validate_readonly_test.go b/openapi3filter/validate_readonly_test.go new file mode 100644 index 000000000..bad6c961a --- /dev/null +++ b/openapi3filter/validate_readonly_test.go @@ -0,0 +1,230 @@ +package openapi3filter + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" +) + +func TestReadOnlyWriteOnlyPropertiesValidation(t *testing.T) { + type testCase struct { + name string + requestSchema string + responseSchema string + requestBody string + responseBody string + responseErrContains string + requestErrContains string + } + + testCases := []testCase{ + { + name: "valid_readonly_in_response_and_valid_writeonly_in_request", + requestSchema: ` + "schema":{ + "type": "object", + "required": ["_id"], + "properties": { + "_id": { + "type": "string", + "writeOnly": true + } + } + }`, + responseSchema: ` + "schema":{ + "type": "object", + "required": ["access_token"], + "properties": { + "access_token": { + "type": "string", + "readOnly": true + } + } + }`, + requestBody: `{"_id": "bt6kdc3d0cvp6u8u3ft0"}`, + responseBody: `{"access_token": "abcd"}`, + }, + { + name: "valid_readonly_in_response_and_invalid_readonly_in_request", + requestSchema: ` + "schema":{ + "type": "object", + "required": ["_id"], + "properties": { + "_id": { + "type": "string", + "readOnly": true + } + } + }`, + responseSchema: ` + "schema":{ + "type": "object", + "required": ["access_token"], + "properties": { + "access_token": { + "type": "string", + "readOnly": true + } + } + }`, + requestBody: `{"_id": "bt6kdc3d0cvp6u8u3ft0"}`, + responseBody: `{"access_token": "abcd"}`, + requestErrContains: `readOnly property "_id" in request`, + }, + { + name: "invalid_writeonly_in_response_and_valid_writeonly_in_request", + requestSchema: ` + "schema":{ + "type": "object", + "required": ["_id"], + "properties": { + "_id": { + "type": "string", + "writeOnly": true + } + } + }`, + responseSchema: ` + "schema":{ + "type": "object", + "required": ["access_token"], + "properties": { + "access_token": { + "type": "string", + "writeOnly": true + } + } + }`, + requestBody: `{"_id": "bt6kdc3d0cvp6u8u3ft0"}`, + responseBody: `{"access_token": "abcd"}`, + responseErrContains: `writeOnly property "access_token" in response`, + }, + { + name: "invalid_writeonly_in_response_and_invalid_readonly_in_request", + requestSchema: ` + "schema":{ + "type": "object", + "required": ["_id"], + "properties": { + "_id": { + "type": "string", + "readOnly": true + } + } + }`, + responseSchema: ` + "schema":{ + "type": "object", + "required": ["access_token"], + "properties": { + "access_token": { + "type": "string", + "writeOnly": true + } + } + }`, + requestBody: `{"_id": "bt6kdc3d0cvp6u8u3ft0"}`, + responseBody: `{"access_token": "abcd"}`, + responseErrContains: `writeOnly property "access_token" in response`, + requestErrContains: `readOnly property "_id" in request`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + spec := bytes.Buffer{} + spec.WriteString(`{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "title" + }, + "paths": { + "/accounts": { + "post": { + "description": "Create a new account", + "requestBody": { + "required": true, + "content": { + "application/json": {`) + spec.WriteString(tc.requestSchema) + spec.WriteString(`} + } + }, + "responses": { + "201": { + "description": "Successfully created a new account", + "content": { + "application/json": {`) + spec.WriteString(tc.responseSchema) + spec.WriteString(`} + } + }, + "400": { + "description": "The server could not understand the request due to invalid syntax", + } + } + } + } + } + }`) + + sl := openapi3.NewLoader() + doc, err := sl.LoadFromData(spec.Bytes()) + require.NoError(t, err) + err = doc.Validate(sl.Context) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) + + httpReq, err := http.NewRequest(http.MethodPost, "/accounts", strings.NewReader(tc.requestBody)) + require.NoError(t, err) + httpReq.Header.Add(headerCT, "application/json") + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + reqValidationInput := &RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + } + + if tc.requestSchema != "" { + err = ValidateRequest(sl.Context, reqValidationInput) + + if tc.requestErrContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.requestErrContains) + } else { + require.NoError(t, err) + } + } + + if tc.responseSchema != "" { + err = ValidateResponse(sl.Context, &ResponseValidationInput{ + RequestValidationInput: reqValidationInput, + Status: 201, + Header: httpReq.Header, + Body: io.NopCloser(strings.NewReader(tc.responseBody)), + }) + + if tc.responseErrContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.responseErrContains) + } else { + require.NoError(t, err) + } + } + }) + } +} diff --git a/openapi3filter/validate_request.go b/openapi3filter/validate_request.go index f22e71efb..a8106a7c8 100644 --- a/openapi3filter/validate_request.go +++ b/openapi3filter/validate_request.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "io/ioutil" "net/http" "sort" @@ -12,8 +13,15 @@ import ( "github.com/getkin/kin-openapi/openapi3" ) -// ErrInvalidRequired is an error that happens when a required value of a parameter or request's body is not defined. -var ErrInvalidRequired = errors.New("must have a value") +// ErrAuthenticationServiceMissing is returned when no authentication service +// is defined for the request validator +var ErrAuthenticationServiceMissing = errors.New("missing AuthenticationFunc") + +// ErrInvalidRequired is returned when a required value of a parameter or request body is not defined. +var ErrInvalidRequired = errors.New("value is required but missing") + +// ErrInvalidEmptyValue is returned when a value of a parameter or request body is empty while it's not allowed. +var ErrInvalidEmptyValue = errors.New("empty value is not allowed") // ValidateRequest is used to validate the given input according to previous // loaded OpenAPIv3 spec. If the input does not match the OpenAPIv3 spec, a @@ -21,22 +29,34 @@ var ErrInvalidRequired = errors.New("must have a value") // // Note: One can tune the behavior of uniqueItems: true verification // by registering a custom function with openapi3.RegisterArrayUniqueItemsChecker -func ValidateRequest(c context.Context, input *RequestValidationInput) error { +func ValidateRequest(ctx context.Context, input *RequestValidationInput) (err error) { + var me openapi3.MultiError + options := input.Options if options == nil { options = DefaultOptions } route := input.Route - if route == nil { - return errors.New("invalid route") - } operation := route.Operation - if operation == nil { - return errRouteMissingOperation - } operationParameters := operation.Parameters pathItemParameters := route.PathItem.Parameters + // Security + security := operation.Security + // If there aren't any security requirements for the operation + if security == nil { + // Use the global security requirements. + security = &route.Spec.Security + } + if security != nil { + if err = ValidateSecurityRequirements(ctx, input, *security); err != nil && !options.MultiError { + return + } + if err != nil { + me = append(me, err) + } + } + // For each parameter of the PathItem for _, parameterRef := range pathItemParameters { parameter := parameterRef.Value @@ -45,75 +65,107 @@ func ValidateRequest(c context.Context, input *RequestValidationInput) error { continue } } - if err := ValidateParameter(c, input, parameter); err != nil { - return err + + if err = ValidateParameter(ctx, input, parameter); err != nil && !options.MultiError { + return + } + if err != nil { + me = append(me, err) } } // For each parameter of the Operation for _, parameter := range operationParameters { - if err := ValidateParameter(c, input, parameter.Value); err != nil { - return err + if err = ValidateParameter(ctx, input, parameter.Value); err != nil && !options.MultiError { + return + } + if err != nil { + me = append(me, err) } } // RequestBody requestBody := operation.RequestBody if requestBody != nil && !options.ExcludeRequestBody { - if err := ValidateRequestBody(c, input, requestBody.Value); err != nil { - return err + if err = ValidateRequestBody(ctx, input, requestBody.Value); err != nil && !options.MultiError { + return } - } - - // Security - security := operation.Security - // If there aren't any security requirements for the operation - if security == nil { - if route.Swagger == nil { - return errRouteMissingSwagger + if err != nil { + me = append(me, err) } - // Use the global security requirements. - security = &route.Swagger.Security } - if security != nil { - if err := ValidateSecurityRequirements(c, input, *security); err != nil { - return err - } + + if len(me) > 0 { + return me } - return nil + return } // ValidateParameter validates a parameter's value by JSON schema. // The function returns RequestError with a ParseError cause when unable to parse a value. // The function returns RequestError with ErrInvalidRequired cause when a value of a required parameter is not defined. +// The function returns RequestError with ErrInvalidEmptyValue cause when a value of a required parameter is not defined. // The function returns RequestError with a openapi3.SchemaError cause when a value is invalid by JSON schema. -func ValidateParameter(c context.Context, input *RequestValidationInput, parameter *openapi3.Parameter) error { +func ValidateParameter(ctx context.Context, input *RequestValidationInput, parameter *openapi3.Parameter) error { if parameter.Schema == nil && parameter.Content == nil { // We have no schema for the parameter. Assume that everything passes - // a schema-less check, but this could also be an error. The Swagger + // a schema-less check, but this could also be an error. The OpenAPI // validation allows this to happen. return nil } + options := input.Options + if options == nil { + options = DefaultOptions + } + var value interface{} var err error + var found bool var schema *openapi3.Schema // Validation will ensure that we either have content or schema. if parameter.Content != nil { - if value, schema, err = decodeContentParameter(parameter, input); err != nil { + if value, schema, found, err = decodeContentParameter(parameter, input); err != nil { return &RequestError{Input: input, Parameter: parameter, Err: err} } } else { - if value, err = decodeStyledParameter(parameter, input); err != nil { + if value, found, err = decodeStyledParameter(parameter, input); err != nil { return &RequestError{Input: input, Parameter: parameter, Err: err} } schema = parameter.Schema.Value } - // Validate a parameter's value. - if value == nil { - if parameter.Required { - return &RequestError{Input: input, Parameter: parameter, Reason: "must have a value", Err: ErrInvalidRequired} + + // Set default value if needed + if !options.SkipSettingDefaults && value == nil && schema != nil && schema.Default != nil { + value = schema.Default + req := input.Request + switch parameter.In { + case openapi3.ParameterInPath: + // Path parameters are required. + // Next check `parameter.Required && !found` will catch this. + case openapi3.ParameterInQuery: + q := req.URL.Query() + q.Add(parameter.Name, fmt.Sprintf("%v", value)) + req.URL.RawQuery = q.Encode() + case openapi3.ParameterInHeader: + req.Header.Add(parameter.Name, fmt.Sprintf("%v", value)) + case openapi3.ParameterInCookie: + req.AddCookie(&http.Cookie{ + Name: parameter.Name, + Value: fmt.Sprintf("%v", value), + }) + } + } + + // Validate a parameter's value and presence. + if parameter.Required && !found { + return &RequestError{Input: input, Parameter: parameter, Reason: ErrInvalidRequired.Error(), Err: ErrInvalidRequired} + } + + if isNilValue(value) { + if !parameter.AllowEmptyValue && found { + return &RequestError{Input: input, Parameter: parameter, Reason: ErrInvalidEmptyValue.Error(), Err: ErrInvalidEmptyValue} } return nil } @@ -121,22 +173,38 @@ func ValidateParameter(c context.Context, input *RequestValidationInput, paramet // A parameter's schema is not defined so skip validation of a parameter's value. return nil } - if err = schema.VisitJSON(value); err != nil { + + var opts []openapi3.SchemaValidationOption + if options.MultiError { + opts = make([]openapi3.SchemaValidationOption, 0, 1) + opts = append(opts, openapi3.MultiErrors()) + } + if options.customSchemaErrorFunc != nil { + opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc)) + } + if err = schema.VisitJSON(value, opts...); err != nil { return &RequestError{Input: input, Parameter: parameter, Err: err} } return nil } +const prefixInvalidCT = "header Content-Type has unexpected value" + // ValidateRequestBody validates data of a request's body. // // The function returns RequestError with ErrInvalidRequired cause when a value is required but not defined. // The function returns RequestError with a openapi3.SchemaError cause when a value is invalid by JSON schema. -func ValidateRequestBody(c context.Context, input *RequestValidationInput, requestBody *openapi3.RequestBody) error { +func ValidateRequestBody(ctx context.Context, input *RequestValidationInput, requestBody *openapi3.RequestBody) error { var ( req = input.Request data []byte ) + options := input.Options + if options == nil { + options = DefaultOptions + } + if req.Body != http.NoBody && req.Body != nil { defer req.Body.Close() var err error @@ -149,7 +217,19 @@ func ValidateRequestBody(c context.Context, input *RequestValidationInput, reque } } // Put the data back into the input - req.Body = ioutil.NopCloser(bytes.NewReader(data)) + req.Body = nil + if req.GetBody != nil { + if req.Body, err = req.GetBody(); err != nil { + req.Body = nil + } + } + if req.Body == nil { + req.ContentLength = int64(len(data)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + } + req.Body, _ = req.GetBody() // no error return + } } if len(data) == 0 { @@ -165,13 +245,13 @@ func ValidateRequestBody(c context.Context, input *RequestValidationInput, reque return nil } - inputMIME := req.Header.Get("Content-Type") + inputMIME := req.Header.Get(headerCT) contentType := requestBody.Content.Get(inputMIME) if contentType == nil { return &RequestError{ Input: input, RequestBody: requestBody, - Reason: fmt.Sprintf("header 'Content-Type' has unexpected value: %q", inputMIME), + Reason: fmt.Sprintf("%s %q", prefixInvalidCT, inputMIME), } } @@ -181,7 +261,7 @@ func ValidateRequestBody(c context.Context, input *RequestValidationInput, reque } encFn := func(name string) *openapi3.Encoding { return contentType.Encoding[name] } - value, err := decodeBody(bytes.NewReader(data), req.Header, contentType.Schema, encFn) + mediaType, value, err := decodeBody(bytes.NewReader(data), req.Header, contentType.Schema, encFn) if err != nil { return &RequestError{ Input: input, @@ -191,28 +271,68 @@ func ValidateRequestBody(c context.Context, input *RequestValidationInput, reque } } + defaultsSet := false + opts := make([]openapi3.SchemaValidationOption, 0, 4) // 4 potential opts here + opts = append(opts, openapi3.VisitAsRequest()) + if !options.SkipSettingDefaults { + opts = append(opts, openapi3.DefaultsSet(func() { defaultsSet = true })) + } + if options.MultiError { + opts = append(opts, openapi3.MultiErrors()) + } + if options.customSchemaErrorFunc != nil { + opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc)) + } + if options.ExcludeReadOnlyValidations { + opts = append(opts, openapi3.DisableReadOnlyValidation()) + } + // Validate JSON with the schema - if err := contentType.Schema.Value.VisitJSON(value); err != nil { + if err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil { + schemaId := getSchemaIdentifier(contentType.Schema) + schemaId = prependSpaceIfNeeded(schemaId) return &RequestError{ Input: input, RequestBody: requestBody, - Reason: "doesn't match the schema", + Reason: fmt.Sprintf("doesn't match schema%s", schemaId), Err: err, } } + + if defaultsSet { + var err error + if data, err = encodeBody(value, mediaType); err != nil { + return &RequestError{ + Input: input, + RequestBody: requestBody, + Reason: "rewriting failed", + Err: err, + } + } + // Put the data back into the input + if req.Body != nil { + req.Body.Close() + } + req.ContentLength = int64(len(data)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + } + req.Body, _ = req.GetBody() // no error return + } + return nil } // ValidateSecurityRequirements goes through multiple OpenAPI 3 security // requirements in order and returns nil on the first valid requirement. // If no requirement is met, errors are returned in order. -func ValidateSecurityRequirements(c context.Context, input *RequestValidationInput, srs openapi3.SecurityRequirements) error { +func ValidateSecurityRequirements(ctx context.Context, input *RequestValidationInput, srs openapi3.SecurityRequirements) error { if len(srs) == 0 { return nil } var errs []error for _, sr := range srs { - if err := validateSecurityRequirement(c, input, sr); err != nil { + if err := validateSecurityRequirement(ctx, input, sr); err != nil { if len(errs) == 0 { errs = make([]error, 0, len(srs)) } @@ -228,14 +348,7 @@ func ValidateSecurityRequirements(c context.Context, input *RequestValidationInp } // validateSecurityRequirement validates a single OpenAPI 3 security requirement -func validateSecurityRequirement(c context.Context, input *RequestValidationInput, securityRequirement openapi3.SecurityRequirement) error { - swagger := input.Route.Swagger - if swagger == nil { - return errRouteMissingSwagger - } - securitySchemes := swagger.Components.SecuritySchemes - - // Ensure deterministic order +func validateSecurityRequirement(ctx context.Context, input *RequestValidationInput, securityRequirement openapi3.SecurityRequirement) error { names := make([]string, 0, len(securityRequirement)) for name := range securityRequirement { names = append(names, name) @@ -252,6 +365,11 @@ func validateSecurityRequirement(c context.Context, input *RequestValidationInpu return ErrAuthenticationServiceMissing } + var securitySchemes openapi3.SecuritySchemes + if components := input.Route.Spec.Components; components != nil { + securitySchemes = components.SecuritySchemes + } + // For each scheme for the requirement for _, name := range names { var securityScheme *openapi3.SecurityScheme @@ -263,11 +381,11 @@ func validateSecurityRequirement(c context.Context, input *RequestValidationInpu if securityScheme == nil { return &RequestError{ Input: input, - Err: fmt.Errorf("Security scheme '%s' is not declared", name), + Err: fmt.Errorf("security scheme %q is not declared", name), } } scopes := securityRequirement[name] - if err := f(c, &AuthenticationInput{ + if err := f(ctx, &AuthenticationInput{ RequestValidationInput: input, SecuritySchemeName: name, SecurityScheme: securityScheme, diff --git a/openapi3filter/validate_request_input.go b/openapi3filter/validate_request_input.go index 44bc8579a..91dd102b6 100644 --- a/openapi3filter/validate_request_input.go +++ b/openapi3filter/validate_request_input.go @@ -5,9 +5,10 @@ import ( "net/url" "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" ) -// A ContentParameterDecoder takes a parameter definition from the swagger spec, +// A ContentParameterDecoder takes a parameter definition from the OpenAPI spec, // and the value which we received for it. It is expected to return the // value unmarshaled into an interface which can be traversed for // validation, it should also return the schema to be used for validating the @@ -22,7 +23,7 @@ type RequestValidationInput struct { Request *http.Request PathParams map[string]string QueryParams url.Values - Route *Route + Route *routers.Route Options *Options ParamDecoder ContentParameterDecoder } diff --git a/openapi3filter/validate_request_test.go b/openapi3filter/validate_request_test.go new file mode 100644 index 000000000..8da550ce0 --- /dev/null +++ b/openapi3filter/validate_request_test.go @@ -0,0 +1,223 @@ +package openapi3filter + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func setupTestRouter(t *testing.T, spec string) routers.Router { + t.Helper() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + return router +} + +func TestValidateRequest(t *testing.T) { + const spec = ` +openapi: 3.0.0 +info: + title: 'Validator' + version: 0.0.1 +paths: + /category: + post: + parameters: + - name: category + in: query + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - subCategory + properties: + subCategory: + type: string + category: + type: string + default: Sweets + responses: + '201': + description: Created + security: + - apiKey: [] +components: + securitySchemes: + apiKey: + type: apiKey + name: Api-Key + in: header +` + + router := setupTestRouter(t, spec) + + verifyAPIKeyPresence := func(c context.Context, input *AuthenticationInput) error { + if input.SecurityScheme.Type == "apiKey" { + var found bool + switch input.SecurityScheme.In { + case "query": + _, found = input.RequestValidationInput.GetQueryParams()[input.SecurityScheme.Name] + case "header": + _, found = input.RequestValidationInput.Request.Header[http.CanonicalHeaderKey(input.SecurityScheme.Name)] + case "cookie": + _, err := input.RequestValidationInput.Request.Cookie(input.SecurityScheme.Name) + found = !errors.Is(err, http.ErrNoCookie) + } + if !found { + return fmt.Errorf("%v not found in %v", input.SecurityScheme.Name, input.SecurityScheme.In) + } + } + return nil + } + + type testRequestBody struct { + SubCategory string `json:"subCategory"` + Category string `json:"category,omitempty"` + } + type args struct { + requestBody *testRequestBody + url string + apiKey string + } + tests := []struct { + name string + args args + expectedModification bool + expectedErr error + }{ + { + name: "Valid request with all fields set", + args: args{ + requestBody: &testRequestBody{SubCategory: "Chocolate", Category: "Food"}, + url: "/category?category=cookies", + apiKey: "SomeKey", + }, + expectedModification: false, + expectedErr: nil, + }, + { + name: "Valid request without certain fields", + args: args{ + requestBody: &testRequestBody{SubCategory: "Chocolate"}, + url: "/category?category=cookies", + apiKey: "SomeKey", + }, + expectedModification: true, + expectedErr: nil, + }, + { + name: "Invalid operation params", + args: args{ + requestBody: &testRequestBody{SubCategory: "Chocolate"}, + url: "/category?invalidCategory=badCookie", + apiKey: "SomeKey", + }, + expectedModification: false, + expectedErr: &RequestError{}, + }, + { + name: "Invalid request body", + args: args{ + requestBody: nil, + url: "/category?category=cookies", + apiKey: "SomeKey", + }, + expectedModification: false, + expectedErr: &RequestError{}, + }, + { + name: "Invalid security", + args: args{ + requestBody: &testRequestBody{SubCategory: "Chocolate"}, + url: "/category?category=cookies", + apiKey: "", + }, + expectedModification: false, + expectedErr: &SecurityRequirementsError{}, + }, + { + name: "Invalid request body and security", + args: args{ + requestBody: nil, + url: "/category?category=cookies", + apiKey: "", + }, + expectedModification: false, + expectedErr: &SecurityRequirementsError{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var requestBody io.Reader + var originalBodySize int + if tc.args.requestBody != nil { + testingBody, err := json.Marshal(tc.args.requestBody) + require.NoError(t, err) + requestBody = bytes.NewReader(testingBody) + originalBodySize = len(testingBody) + } + req, err := http.NewRequest(http.MethodPost, tc.args.url, requestBody) + require.NoError(t, err) + req.Header.Add("Content-Type", "application/json") + if tc.args.apiKey != "" { + req.Header.Add("Api-Key", tc.args.apiKey) + } + + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + validationInput := &RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + Options: &Options{ + AuthenticationFunc: verifyAPIKeyPresence, + }, + } + err = ValidateRequest(context.Background(), validationInput) + assert.IsType(t, tc.expectedErr, err, "ValidateRequest(): error = %v, expectedError %v", err, tc.expectedErr) + if tc.expectedErr != nil { + return + } + body, err := io.ReadAll(validationInput.Request.Body) + contentLen := int(validationInput.Request.ContentLength) + bodySize := len(body) + assert.NoError(t, err, "unable to read request body: %v", err) + assert.Equal(t, contentLen, bodySize, "expect ContentLength %d to equal body size %d", contentLen, bodySize) + bodyModified := originalBodySize != bodySize + assert.Equal(t, bodyModified, tc.expectedModification, "expect request body modification happened: %t, expected %t", bodyModified, tc.expectedModification) + + validationInput.Request.Body, err = validationInput.Request.GetBody() + assert.NoError(t, err, "unable to re-generate body by GetBody(): %v", err) + body2, err := io.ReadAll(validationInput.Request.Body) + assert.NoError(t, err, "unable to read request body: %v", err) + assert.Equal(t, body, body2, "body by GetBody() is not matched") + }) + } +} diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index dad0864d2..08ea4e19d 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -7,6 +7,8 @@ import ( "fmt" "io/ioutil" "net/http" + "sort" + "strings" "github.com/getkin/kin-openapi/openapi3" ) @@ -17,20 +19,13 @@ import ( // // Note: One can tune the behavior of uniqueItems: true verification // by registering a custom function with openapi3.RegisterArrayUniqueItemsChecker -func ValidateResponse(c context.Context, input *ResponseValidationInput) error { +func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error { req := input.RequestValidationInput.Request switch req.Method { case "HEAD": return nil } status := input.Status - if status < 100 { - return &ResponseError{ - Input: input, - Reason: "illegal status code", - Err: fmt.Errorf("Status %d", status), - } - } // These status codes will never be validated. // TODO: The list is probably missing some. @@ -61,7 +56,6 @@ func ValidateResponse(c context.Context, input *ResponseValidationInput) error { if !options.IncludeResponseStatus { return nil } - return &ResponseError{Input: input, Reason: "status is not supported"} } response := responseRef.Value @@ -69,6 +63,31 @@ func ValidateResponse(c context.Context, input *ResponseValidationInput) error { return &ResponseError{Input: input, Reason: "response has not been resolved"} } + opts := make([]openapi3.SchemaValidationOption, 0, 3) // 3 potential options here + if options.MultiError { + opts = append(opts, openapi3.MultiErrors()) + } + if options.customSchemaErrorFunc != nil { + opts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc)) + } + if options.ExcludeWriteOnlyValidations { + opts = append(opts, openapi3.DisableWriteOnlyValidation()) + } + + headers := make([]string, 0, len(response.Headers)) + for k := range response.Headers { + if k != headerCT { + headers = append(headers, k) + } + } + sort.Strings(headers) + for _, headerName := range headers { + headerRef := response.Headers[headerName] + if err := validateResponseHeader(headerName, headerRef, input, opts); err != nil { + return err + } + } + if options.ExcludeResponseBody { // A user turned off validation of a response's body. return nil @@ -80,12 +99,12 @@ func ValidateResponse(c context.Context, input *ResponseValidationInput) error { return nil } - inputMIME := input.Header.Get("Content-Type") + inputMIME := input.Header.Get(headerCT) contentType := content.Get(inputMIME) if contentType == nil { return &ResponseError{ Input: input, - Reason: fmt.Sprintf("input header 'Content-Type' has unexpected value: %q", inputMIME), + Reason: fmt.Sprintf("response header Content-Type has unexpected value: %q", inputMIME), } } @@ -119,7 +138,7 @@ func ValidateResponse(c context.Context, input *ResponseValidationInput) error { input.SetBodyBytes(data) encFn := func(name string) *openapi3.Encoding { return contentType.Encoding[name] } - value, err := decodeBody(bytes.NewBuffer(data), input.Header, contentType.Schema, encFn) + _, value, err := decodeBody(bytes.NewBuffer(data), input.Header, contentType.Schema, encFn) if err != nil { return &ResponseError{ Input: input, @@ -129,12 +148,77 @@ func ValidateResponse(c context.Context, input *ResponseValidationInput) error { } // Validate data with the schema. - if err := contentType.Schema.Value.VisitJSON(value); err != nil { + if err := contentType.Schema.Value.VisitJSON(value, append(opts, openapi3.VisitAsResponse())...); err != nil { + schemaId := getSchemaIdentifier(contentType.Schema) + schemaId = prependSpaceIfNeeded(schemaId) + return &ResponseError{ + Input: input, + Reason: fmt.Sprintf("response body doesn't match schema%s", schemaId), + Err: err, + } + } + return nil +} + +func validateResponseHeader(headerName string, headerRef *openapi3.HeaderRef, input *ResponseValidationInput, opts []openapi3.SchemaValidationOption) error { + var err error + var decodedValue interface{} + var found bool + var sm *openapi3.SerializationMethod + dec := &headerParamDecoder{header: input.Header} + + if sm, err = headerRef.Value.SerializationMethod(); err != nil { return &ResponseError{ Input: input, - Reason: "response body doesn't match the schema", + Reason: fmt.Sprintf("unable to get header %q serialization method", headerName), Err: err, } } + + if decodedValue, found, err = decodeValue(dec, headerName, sm, headerRef.Value.Schema, headerRef.Value.Required); err != nil { + return &ResponseError{ + Input: input, + Reason: fmt.Sprintf("unable to decode header %q value", headerName), + Err: err, + } + } + + if found { + if err = headerRef.Value.Schema.Value.VisitJSON(decodedValue, opts...); err != nil { + return &ResponseError{ + Input: input, + Reason: fmt.Sprintf("response header %q doesn't match schema", headerName), + Err: err, + } + } + } else if headerRef.Value.Required { + return &ResponseError{ + Input: input, + Reason: fmt.Sprintf("response header %q missing", headerName), + } + } return nil } + +// getSchemaIdentifier gets something by which a schema could be identified. +// A schema by itself doesn't have a true identity field. This function makes +// a best effort to get a value that can fill that void. +func getSchemaIdentifier(schema *openapi3.SchemaRef) string { + var id string + + if schema != nil { + id = strings.TrimSpace(schema.Ref) + } + if id == "" && schema.Value != nil { + id = strings.TrimSpace(schema.Value.Title) + } + + return id +} + +func prependSpaceIfNeeded(value string) string { + if len(value) > 0 { + value = " " + value + } + return value +} diff --git a/openapi3filter/validate_response_test.go b/openapi3filter/validate_response_test.go new file mode 100644 index 000000000..5ce657b0b --- /dev/null +++ b/openapi3filter/validate_response_test.go @@ -0,0 +1,215 @@ +package openapi3filter + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func Test_validateResponseHeader(t *testing.T) { + type args struct { + headerName string + headerRef *openapi3.HeaderRef + } + tests := []struct { + name string + args args + isHeaderPresent bool + headerVals []string + wantErr bool + wantErrMsg string + }{ + { + name: "test required string header with single string value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewStringSchema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"blab"}, + wantErr: false, + }, + { + name: "test required string header with single, empty string value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewStringSchema(), true), + }, + isHeaderPresent: true, + headerVals: []string{""}, + wantErr: true, + wantErrMsg: `response header "X-Blab" doesn't match schema: Value is not nullable`, + }, + { + name: "test optional string header with single string value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewStringSchema(), false), + }, + isHeaderPresent: false, + headerVals: []string{"blab"}, + wantErr: false, + }, + { + name: "test required, but missing string header", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewStringSchema(), true), + }, + isHeaderPresent: false, + headerVals: nil, + wantErr: true, + wantErrMsg: `response header "X-Blab" missing`, + }, + { + name: "test integer header with single integer value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewIntegerSchema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"88"}, + wantErr: false, + }, + { + name: "test integer header with single string value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewIntegerSchema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"blab"}, + wantErr: true, + wantErrMsg: `unable to decode header "X-Blab" value: value blab: an invalid integer: invalid syntax`, + }, + { + name: "test int64 header with single int64 value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewInt64Schema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"88"}, + wantErr: false, + }, + { + name: "test int32 header with single int32 value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewInt32Schema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"88"}, + wantErr: false, + }, + { + name: "test float64 header with single float64 value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewFloat64Schema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"88.87"}, + wantErr: false, + }, + { + name: "test integer header with multiple csv integer values", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(newArraySchema(openapi3.NewIntegerSchema()), true), + }, + isHeaderPresent: true, + headerVals: []string{"87,88"}, + wantErr: false, + }, + { + name: "test integer header with multiple integer values", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(newArraySchema(openapi3.NewIntegerSchema()), true), + }, + isHeaderPresent: true, + headerVals: []string{"87", "88"}, + wantErr: false, + }, + { + name: "test non-typed, nullable header with single string value", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(&openapi3.Schema{Nullable: true}, true), + }, + isHeaderPresent: true, + headerVals: []string{"blab"}, + wantErr: false, + }, + { + name: "test required non-typed, nullable header not present", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(&openapi3.Schema{Nullable: true}, true), + }, + isHeaderPresent: false, + headerVals: []string{"blab"}, + wantErr: true, + wantErrMsg: `response header "X-blab" missing`, + }, + { + name: "test non-typed, non-nullable header with single string value", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(&openapi3.Schema{Nullable: false}, true), + }, + isHeaderPresent: true, + headerVals: []string{"blab"}, + wantErr: true, + wantErrMsg: `response header "X-blab" doesn't match schema: Value is not nullable`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := newInputDefault() + opts := []openapi3.SchemaValidationOption(nil) + if tt.isHeaderPresent { + input.Header = map[string][]string{http.CanonicalHeaderKey(tt.args.headerName): tt.headerVals} + } + + err := validateResponseHeader(tt.args.headerName, tt.args.headerRef, input, opts) + if tt.wantErr { + require.NotEmpty(t, tt.wantErrMsg, "wanted error message is not populated") + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func newInputDefault() *ResponseValidationInput { + return &ResponseValidationInput{ + RequestValidationInput: &RequestValidationInput{ + Request: nil, + PathParams: nil, + Route: nil, + }, + Status: 200, + Header: nil, + Body: io.NopCloser(strings.NewReader(`{}`)), + } +} + +func newHeaderRef(schema *openapi3.Schema, required bool) *openapi3.HeaderRef { + return &openapi3.HeaderRef{Value: &openapi3.Header{Parameter: openapi3.Parameter{Schema: &openapi3.SchemaRef{Value: schema}, Required: required}}} +} + +func newArraySchema(schema *openapi3.Schema) *openapi3.Schema { + arraySchema := openapi3.NewArraySchema() + arraySchema.Items = openapi3.NewSchemaRef("", schema) + + return arraySchema +} diff --git a/openapi3filter/validate_set_default_test.go b/openapi3filter/validate_set_default_test.go new file mode 100644 index 000000000..731cbbdca --- /dev/null +++ b/openapi3filter/validate_set_default_test.go @@ -0,0 +1,803 @@ +package openapi3filter + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" +) + +func TestValidatingRequestParameterAndSetDefault(t *testing.T) { + const spec = `{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "title", + "description": "desc", + "contact": { + "email": "email" + } + }, + "paths": { + "/accounts": { + "get": { + "description": "Create a new account", + "parameters": [ + { + "in": "query", + "name": "q1", + "schema": { + "type": "string", + "default": "Q" + } + }, + { + "in": "query", + "name": "q2", + "schema": { + "type": "string", + "default": "Q" + } + }, + { + "in": "query", + "name": "q3", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "h1", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "in": "header", + "name": "h2", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "in": "header", + "name": "h3", + "schema": { + "type": "boolean" + } + }, + { + "in": "cookie", + "name": "c1", + "schema": { + "type": "integer", + "default": 128 + } + }, + { + "in": "cookie", + "name": "c2", + "schema": { + "type": "integer", + "default": 128 + } + }, + { + "in": "cookie", + "name": "c3", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "201": { + "description": "Successfully created a new account" + }, + "400": { + "description": "The server could not understand the request due to invalid syntax", + } + } + } + } + } +} +` + + sl := openapi3.NewLoader() + doc, err := sl.LoadFromData([]byte(spec)) + require.NoError(t, err) + err = doc.Validate(sl.Context) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) + + httpReq, err := http.NewRequest(http.MethodGet, "/accounts", nil) + require.NoError(t, err) + + params := &url.Values{ + "q2": []string{"from_request"}, + } + httpReq.URL.RawQuery = params.Encode() + httpReq.Header.Set("h2", "false") + httpReq.AddCookie(&http.Cookie{Name: "c2", Value: "1024"}) + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + err = ValidateRequest(sl.Context, &RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + }) + require.NoError(t, err) + + // Unset default values in URL were set + require.Equal(t, "Q", httpReq.URL.Query().Get("q1")) + // Unset default values in headers were set + require.Equal(t, "true", httpReq.Header.Get("h1")) + // Unset default values in cookies were set + cookie, err := httpReq.Cookie("c1") + require.NoError(t, err) + require.Equal(t, "128", cookie.Value) + + // All values from request were retained + require.Equal(t, "from_request", httpReq.URL.Query().Get("q2")) + require.Equal(t, "false", httpReq.Header.Get("h2")) + cookie, err = httpReq.Cookie("c2") + require.NoError(t, err) + require.Equal(t, "1024", cookie.Value) + + // Not set value to parameters without default value + require.Equal(t, "", httpReq.URL.Query().Get("q3")) + require.Equal(t, "", httpReq.Header.Get("h3")) + _, err = httpReq.Cookie("c3") + require.Equal(t, http.ErrNoCookie, err) +} + +func TestValidateRequestBodyAndSetDefault(t *testing.T) { + const spec = `{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "title", + "description": "desc", + "contact": { + "email": "email" + } + }, + "paths": { + "/accounts": { + "post": { + "description": "Create a new account", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "pattern": "[0-9a-v]+$", + "minLength": 20, + "maxLength": 20 + }, + "name": { + "type": "string", + "default": "default" + }, + "code": { + "type": "integer", + "default": 123 + }, + "all": { + "type": "boolean", + "default": false + }, + "page": { + "type": "object", + "properties": { + "num": { + "type": "integer", + "default": 1 + }, + "size": { + "type": "integer", + "default": 10 + }, + "order": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + }, + "filters": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "default": "name" + }, + "op": { + "type": "string", + "enum": ["eq", "ne"], + "default": "eq" + }, + "value": { + "type": "integer", + "default": 123 + } + } + } + }, + "social_network": { + "oneOf": [ + { + "type": "object", + "required": ["platform"], + "properties": { + "platform": { + "type": "string", + "enum": [ + "twitter" + ] + }, + "tw_link": { + "type": "string", + "default": "www.twitter.com" + } + } + }, + { + "type": "object", + "required": ["platform"], + "properties": { + "platform": { + "type": "string", + "enum": [ + "facebook" + ] + }, + "fb_link": { + "type": "string", + "default": "www.facebook.com" + } + } + } + ] + }, + "social_network_2": { + "anyOf": [ + { + "type": "object", + "required": ["platform"], + "properties": { + "platform": { + "type": "string", + "enum": [ + "twitter" + ] + }, + "tw_link": { + "type": "string", + "default": "www.twitter.com" + } + } + }, + { + "type": "object", + "required": ["platform"], + "properties": { + "platform": { + "type": "string", + "enum": [ + "facebook" + ] + }, + "fb_link": { + "type": "string", + "default": "www.facebook.com" + } + } + } + ] + }, + "contact": { + "oneOf": [ + { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string" + }, + "allow_image": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["phone"], + "properties": { + "phone": { + "type": "string" + }, + "allow_text": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + ] + }, + "contact2": { + "anyOf": [ + { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string" + }, + "allow_image": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["phone"], + "properties": { + "phone": { + "type": "string" + }, + "allow_text": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + ] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Successfully created a new account" + }, + "400": { + "description": "The server could not understand the request due to invalid syntax", + } + } + } + } + } +}` + sl := openapi3.NewLoader() + doc, err := sl.LoadFromData([]byte(spec)) + require.NoError(t, err) + err = doc.Validate(sl.Context) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) + + type page struct { + Num int `json:"num,omitempty"` + Size int `json:"size,omitempty"` + Order string `json:"order,omitempty"` + } + type filter struct { + Field string `json:"field,omitempty"` + OP string `json:"op,omitempty"` + Value int `json:"value,omitempty"` + } + type socialNetwork struct { + Platform string `json:"platform,omitempty"` + FBLink string `json:"fb_link,omitempty"` + TWLink string `json:"tw_link,omitempty"` + } + type contact struct { + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + } + type body struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Code int `json:"code,omitempty"` + All bool `json:"all,omitempty"` + Page *page `json:"page,omitempty"` + Filters []filter `json:"filters,omitempty"` + SocialNetwork *socialNetwork `json:"social_network,omitempty"` + SocialNetwork2 *socialNetwork `json:"social_network_2,omitempty"` + Contact *contact `json:"contact,omitempty"` + Contact2 *contact `json:"contact2,omitempty"` + } + + testCases := []struct { + name string + body body + bodyAssertion func(t *testing.T, body string) + }{ + { + name: "only id", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, `{"id":"bt6kdc3d0cvp6u8u3ft0", "name": "default", "code": 123, "all": false}`, body) + }, + }, + { + name: "id & name", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Name: "non-default", + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, `{"id":"bt6kdc3d0cvp6u8u3ft0", "name": "non-default", "code": 123, "all": false}`, body) + }, + }, + { + name: "id & name & code", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Name: "non-default", + Code: 456, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, `{"id":"bt6kdc3d0cvp6u8u3ft0", "name": "non-default", "code": 456, "all": false}`, body) + }, + }, + { + name: "id & name & code & all", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Name: "non-default", + Code: 456, + All: true, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, `{"id":"bt6kdc3d0cvp6u8u3ft0", "name": "non-default", "code": 456, "all": true}`, body) + }, + }, + { + name: "id & page(num)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Page: &page{ + Num: 10, + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "page": { + "num": 10, + "size": 10, + "order": "desc" + } +} + `, body) + }, + }, + { + name: "id & page(num & order)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Page: &page{ + Num: 10, + Order: "asc", + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "page": { + "num": 10, + "size": 10, + "order": "asc" + } +} + `, body) + }, + }, + { + name: "id & page & filters(one element and contains field)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Page: &page{ + Num: 10, + Order: "asc", + }, + Filters: []filter{ + { + Field: "code", + }, + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "page": { + "num": 10, + "size": 10, + "order": "asc" + }, + "filters": [ + { + "field": "code", + "op": "eq", + "value": 123 + } + ] +} + `, body) + }, + }, + { + name: "id & page & filters(one element and contains field & op & value)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Page: &page{ + Num: 10, + Order: "asc", + }, + Filters: []filter{ + { + Field: "code", + OP: "ne", + Value: 456, + }, + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "page": { + "num": 10, + "size": 10, + "order": "asc" + }, + "filters": [ + { + "field": "code", + "op": "ne", + "value": 456 + } + ] +} + `, body) + }, + }, + { + name: "id & page & filters(multiple elements)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Page: &page{ + Num: 10, + Order: "asc", + }, + Filters: []filter{ + { + Value: 456, + }, + { + OP: "ne", + }, + { + Field: "code", + Value: 456, + }, + { + OP: "ne", + Value: 789, + }, + { + Field: "code", + OP: "ne", + Value: 456, + }, + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "page": { + "num": 10, + "size": 10, + "order": "asc" + }, + "filters": [ + { + "field": "name", + "op": "eq", + "value": 456 + }, + { + "field": "name", + "op": "ne", + "value": 123 + }, + { + "field": "code", + "op": "eq", + "value": 456 + }, + { + "field": "name", + "op": "ne", + "value": 789 + }, + { + "field": "code", + "op": "ne", + "value": 456 + } + ] +} + `, body) + }, + }, + { + name: "social_network(oneOf)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + SocialNetwork: &socialNetwork{ + Platform: "facebook", + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "social_network": { + "platform": "facebook", + "fb_link": "www.facebook.com" + } +} + `, body) + }, + }, + { + name: "social_network_2(anyOf)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + SocialNetwork2: &socialNetwork{ + Platform: "facebook", + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "social_network_2": { + "platform": "facebook", + "fb_link": "www.facebook.com" + } +} + `, body) + }, + }, + { + name: "contact(oneOf)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Contact: &contact{ + Phone: "123456", + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "contact": { + "phone": "123456", + "allow_text": false + } +} + `, body) + }, + }, + { + name: "contact(anyOf)", + body: body{ + ID: "bt6kdc3d0cvp6u8u3ft0", + Contact2: &contact{ + Phone: "123456", + }, + }, + bodyAssertion: func(t *testing.T, body string) { + require.JSONEq(t, ` +{ + "id": "bt6kdc3d0cvp6u8u3ft0", + "name": "default", + "code": 123, + "all": false, + "contact2": { + "phone": "123456", + "allow_text": false + } +} + `, body) + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + b, err := json.Marshal(tc.body) + require.NoError(t, err) + httpReq, err := http.NewRequest(http.MethodPost, "/accounts", bytes.NewReader(b)) + require.NoError(t, err) + httpReq.Header.Add(headerCT, "application/json") + + route, pathParams, err := router.FindRoute(httpReq) + require.NoError(t, err) + + err = ValidateRequest(sl.Context, &RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + }) + require.NoError(t, err) + + validatedReqBody, err := ioutil.ReadAll(httpReq.Body) + require.NoError(t, err) + tc.bodyAssertion(t, string(validatedReqBody)) + }) + } +} diff --git a/openapi3filter/validation_discriminator_test.go b/openapi3filter/validation_discriminator_test.go new file mode 100644 index 000000000..adabf409d --- /dev/null +++ b/openapi3filter/validation_discriminator_test.go @@ -0,0 +1,101 @@ +package openapi3filter + +import ( + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" +) + +func TestValidationWithDiscriminatorSelection(t *testing.T) { + const spec = ` +openapi: 3.0.0 +info: + version: 0.2.0 + title: yaAPI + +paths: + + /blob: + put: + operationId: SetObj + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/blob' + responses: + '200': + description: Ok + +components: + schemas: + blob: + oneOf: + - $ref: '#/components/schemas/objA' + - $ref: '#/components/schemas/objB' + discriminator: + propertyName: discr + mapping: + objA: '#/components/schemas/objA' + objB: '#/components/schemas/objB' + genericObj: + type: object + required: + - discr + properties: + discr: + type: string + enum: + - objA + - objB + discriminator: + propertyName: discr + mapping: + objA: '#/components/schemas/objA' + objB: '#/components/schemas/objB' + objA: + allOf: + - $ref: '#/components/schemas/genericObj' + - type: object + properties: + base64: + type: string + + objB: + allOf: + - $ref: '#/components/schemas/genericObj' + - type: object + properties: + value: + type: integer +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) + + body := bytes.NewReader([]byte(`{"discr": "objA", "base64": "S25vY2sgS25vY2ssIE5lbyAuLi4="}`)) + req, err := http.NewRequest("PUT", "/blob", body) + require.NoError(t, err) + req.Header.Add(headerCT, "application/json") + + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + requestValidationInput := &RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + } + err = ValidateRequest(loader.Context, requestValidationInput) + require.NoError(t, err) +} diff --git a/openapi3filter/validation_enum_test.go b/openapi3filter/validation_enum_test.go new file mode 100644 index 000000000..898c4027a --- /dev/null +++ b/openapi3filter/validation_enum_test.go @@ -0,0 +1,175 @@ +package openapi3filter + +import ( + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" +) + +func TestValidationWithIntegerEnum(t *testing.T) { + const spec = ` +openapi: 3.0.0 +info: + title: Example integer enum + version: '0.1' +paths: + /sample: + put: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + exenum: + type: integer + enum: + - 0 + - 1 + - 2 + - 3 + example: 0 + nullable: true + responses: + '200': + description: Ok +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) + + tests := []struct { + data []byte + wantErr bool + }{ + { + []byte(`{"exenum": 1}`), + false, + }, + { + []byte(`{"exenum": "1"}`), + true, + }, + { + []byte(`{"exenum": null}`), + false, + }, + { + []byte(`{}`), + false, + }, + } + + for _, tt := range tests { + body := bytes.NewReader(tt.data) + req, err := http.NewRequest("PUT", "/sample", body) + require.NoError(t, err) + req.Header.Add(headerCT, "application/json") + + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + requestValidationInput := &RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + } + err = ValidateRequest(loader.Context, requestValidationInput) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } +} + +func TestValidationWithStringEnum(t *testing.T) { + const spec = ` +openapi: 3.0.0 +info: + title: Example string enum + version: '0.1' +paths: + /sample: + put: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + exenum: + type: string + enum: + - "0" + - "1" + - "2" + - "3" + example: "0" + responses: + '200': + description: Ok +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) + + tests := []struct { + data []byte + wantErr bool + }{ + { + []byte(`{"exenum": "1"}`), + false, + }, + { + []byte(`{"exenum": 1}`), + true, + }, + { + []byte(`{"exenum": null}`), + true, + }, + { + []byte(`{}`), + false, + }, + } + + for _, tt := range tests { + body := bytes.NewReader(tt.data) + req, err := http.NewRequest("PUT", "/sample", body) + require.NoError(t, err) + req.Header.Add(headerCT, "application/json") + + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + requestValidationInput := &RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + } + err = ValidateRequest(loader.Context, requestValidationInput) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } +} diff --git a/openapi3filter/validation_error.go b/openapi3filter/validation_error.go index bfeeaa7da..7e685cdef 100644 --- a/openapi3filter/validation_error.go +++ b/openapi3filter/validation_error.go @@ -10,25 +10,25 @@ import ( // Based on https://jsonapi.org/format/#error-objects type ValidationError struct { // A unique identifier for this particular occurrence of the problem. - Id string `json:"id,omitempty"` + Id string `json:"id,omitempty" yaml:"id,omitempty"` // The HTTP status code applicable to this problem. - Status int `json:"status,omitempty"` + Status int `json:"status,omitempty" yaml:"status,omitempty"` // An application-specific error code, expressed as a string value. - Code string `json:"code,omitempty"` + Code string `json:"code,omitempty" yaml:"code,omitempty"` // A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization. - Title string `json:"title,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` // A human-readable explanation specific to this occurrence of the problem. - Detail string `json:"detail,omitempty"` + Detail string `json:"detail,omitempty" yaml:"detail,omitempty"` // An object containing references to the source of the error - Source *ValidationErrorSource `json:"source,omitempty"` + Source *ValidationErrorSource `json:"source,omitempty" yaml:"source,omitempty"` } // ValidationErrorSource struct type ValidationErrorSource struct { // A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute]. - Pointer string `json:"pointer,omitempty"` + Pointer string `json:"pointer,omitempty" yaml:"pointer,omitempty"` // A string indicating which query parameter caused the error. - Parameter string `json:"parameter,omitempty"` + Parameter string `json:"parameter,omitempty" yaml:"parameter,omitempty"` } var _ error = &ValidationError{} diff --git a/openapi3filter/validation_error_encoder.go b/openapi3filter/validation_error_encoder.go index 49459ef9c..779887db0 100644 --- a/openapi3filter/validation_error_encoder.go +++ b/openapi3filter/validation_error_encoder.go @@ -4,10 +4,10 @@ import ( "context" "fmt" "net/http" - "regexp" "strings" "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" ) // ValidationErrorEncoder wraps a base ErrorEncoder to handle ValidationErrors @@ -17,10 +17,8 @@ type ValidationErrorEncoder struct { // Encode implements the ErrorEncoder interface for encoding ValidationErrors func (enc *ValidationErrorEncoder) Encode(ctx context.Context, err error, w http.ResponseWriter) { - var cErr *ValidationError - - if e, ok := err.(*RouteError); ok { - cErr = convertRouteError(e) + if e, ok := err.(*routers.RouteError); ok { + cErr := convertRouteError(e) enc.Encoder(ctx, cErr, w) return } @@ -31,10 +29,13 @@ func (enc *ValidationErrorEncoder) Encode(ctx context.Context, err error, w http return } + var cErr *ValidationError if e.Err == nil { cErr = convertBasicRequestError(e) } else if e.Err == ErrInvalidRequired { cErr = convertErrInvalidRequired(e) + } else if e.Err == ErrInvalidEmptyValue { + cErr = convertErrInvalidEmptyValue(e) } else if innerErr, ok := e.Err.(*ParseError); ok { cErr = convertParseError(e, innerErr) } else if innerErr, ok := e.Err.(*openapi3.SchemaError); ok { @@ -43,95 +44,93 @@ func (enc *ValidationErrorEncoder) Encode(ctx context.Context, err error, w http if cErr != nil { enc.Encoder(ctx, cErr, w) - } else { - enc.Encoder(ctx, err, w) + return } + enc.Encoder(ctx, err, w) } -func convertRouteError(e *RouteError) *ValidationError { - var cErr *ValidationError - switch e.Reason { - case "Path doesn't support the HTTP method": - cErr = &ValidationError{Status: http.StatusMethodNotAllowed, Title: e.Reason} - default: - cErr = &ValidationError{Status: http.StatusNotFound, Title: e.Reason} +func convertRouteError(e *routers.RouteError) *ValidationError { + status := http.StatusNotFound + if e.Error() == routers.ErrMethodNotAllowed.Error() { + status = http.StatusMethodNotAllowed } - return cErr + return &ValidationError{Status: status, Title: e.Error()} } func convertBasicRequestError(e *RequestError) *ValidationError { - var cErr *ValidationError - unsupportedContentType := "header 'Content-Type' has unexpected value: " - if strings.HasPrefix(e.Reason, unsupportedContentType) { - if strings.HasSuffix(e.Reason, `: ""`) { - cErr = &ValidationError{ + if strings.HasPrefix(e.Reason, prefixInvalidCT) { + if strings.HasSuffix(e.Reason, `""`) { + return &ValidationError{ Status: http.StatusUnsupportedMediaType, - Title: "header 'Content-Type' is required", - } - } else { - cErr = &ValidationError{ - Status: http.StatusUnsupportedMediaType, - Title: "unsupported content type " + strings.TrimPrefix(e.Reason, unsupportedContentType), + Title: "header Content-Type is required", } } - } else { - cErr = &ValidationError{ - Status: http.StatusBadRequest, - Title: e.Error(), + return &ValidationError{ + Status: http.StatusUnsupportedMediaType, + Title: prefixUnsupportedCT + strings.TrimPrefix(e.Reason, prefixInvalidCT), } } - return cErr + return &ValidationError{ + Status: http.StatusBadRequest, + Title: e.Error(), + } } func convertErrInvalidRequired(e *RequestError) *ValidationError { - var cErr *ValidationError - if e.Reason == ErrInvalidRequired.Error() && e.Parameter != nil { - cErr = &ValidationError{ + if e.Err == ErrInvalidRequired && e.Parameter != nil { + return &ValidationError{ Status: http.StatusBadRequest, - Title: fmt.Sprintf("Parameter '%s' in %s is required", e.Parameter.Name, e.Parameter.In), + Title: fmt.Sprintf("parameter %q in %s is required", e.Parameter.Name, e.Parameter.In), } - } else { - cErr = &ValidationError{ + } + return &ValidationError{ + Status: http.StatusBadRequest, + Title: e.Error(), + } +} + +func convertErrInvalidEmptyValue(e *RequestError) *ValidationError { + if e.Err == ErrInvalidEmptyValue && e.Parameter != nil { + return &ValidationError{ Status: http.StatusBadRequest, - Title: e.Error(), + Title: fmt.Sprintf("parameter %q in %s is not allowed to be empty", e.Parameter.Name, e.Parameter.In), } } - return cErr + return &ValidationError{ + Status: http.StatusBadRequest, + Title: e.Error(), + } } func convertParseError(e *RequestError, innerErr *ParseError) *ValidationError { - var cErr *ValidationError // We treat path params of the wrong type like a 404 instead of a 400 if innerErr.Kind == KindInvalidFormat && e.Parameter != nil && e.Parameter.In == "path" { - cErr = &ValidationError{ + return &ValidationError{ Status: http.StatusNotFound, - Title: fmt.Sprintf("Resource not found with '%s' value: %v", e.Parameter.Name, innerErr.Value), + Title: fmt.Sprintf("resource not found with %q value: %v", e.Parameter.Name, innerErr.Value), } - } else if strings.HasPrefix(innerErr.Reason, "unsupported content type") { - cErr = &ValidationError{ + } else if strings.HasPrefix(innerErr.Reason, prefixUnsupportedCT) { + return &ValidationError{ Status: http.StatusUnsupportedMediaType, Title: innerErr.Reason, } } else if innerErr.RootCause() != nil { if rootErr, ok := innerErr.Cause.(*ParseError); ok && rootErr.Kind == KindInvalidFormat && e.Parameter.In == "query" { - cErr = &ValidationError{ + return &ValidationError{ Status: http.StatusBadRequest, - Title: fmt.Sprintf("Parameter '%s' in %s is invalid: %v is %s", + Title: fmt.Sprintf("parameter %q in %s is invalid: %v is %s", e.Parameter.Name, e.Parameter.In, rootErr.Value, rootErr.Reason), } - } else { - cErr = &ValidationError{ - Status: http.StatusBadRequest, - Title: innerErr.Reason, - } + } + return &ValidationError{ + Status: http.StatusBadRequest, + Title: innerErr.Reason, } } - return cErr + return nil } -var propertyMissingNameRE = regexp.MustCompile(`Property '(?P[^']*)' is missing`) - func convertSchemaError(e *RequestError, innerErr *openapi3.SchemaError) *ValidationError { cErr := &ValidationError{Title: innerErr.Reason} @@ -148,36 +147,34 @@ func convertSchemaError(e *RequestError, innerErr *openapi3.SchemaError) *Valida } // Add error source - if e.Parameter != nil && e.Parameter.In == "query" { + if e.Parameter != nil { // We have a JSONPointer in the query param too so need to // make sure 'Parameter' check takes priority over 'Pointer' - cErr.Source = &ValidationErrorSource{ - Parameter: e.Parameter.Name, - } - } else if innerErr.JSONPointer() != nil { - pointer := innerErr.JSONPointer() - - cErr.Source = &ValidationErrorSource{ - Pointer: toJSONPointer(pointer), - } + cErr.Source = &ValidationErrorSource{Parameter: e.Parameter.Name} + } else if ptr := innerErr.JSONPointer(); ptr != nil { + cErr.Source = &ValidationErrorSource{Pointer: toJSONPointer(ptr)} } // Add details on allowed values for enums - if innerErr.SchemaField == "enum" && - innerErr.Reason == "JSON value is not one of the allowed values" { + if innerErr.SchemaField == "enum" { enums := make([]string, 0, len(innerErr.Schema.Enum)) for _, enum := range innerErr.Schema.Enum { enums = append(enums, fmt.Sprintf("%v", enum)) } - cErr.Detail = fmt.Sprintf("Value '%v' at %s must be one of: %s", - innerErr.Value, toJSONPointer(innerErr.JSONPointer()), strings.Join(enums, ", ")) + cErr.Detail = fmt.Sprintf("value %v at %s must be one of: %s", + innerErr.Value, + toJSONPointer(innerErr.JSONPointer()), + strings.Join(enums, ", ")) value := fmt.Sprintf("%v", innerErr.Value) - if (e.Parameter.Explode == nil || *e.Parameter.Explode == true) && + if e.Parameter != nil && + (e.Parameter.Explode == nil || *e.Parameter.Explode) && (e.Parameter.Style == "" || e.Parameter.Style == "form") && strings.Contains(value, ",") { parts := strings.Split(value, ",") - cErr.Detail = cErr.Detail+"; "+ fmt.Sprintf("perhaps you intended '?%s=%s'", - e.Parameter.Name, strings.Join(parts, "&"+e.Parameter.Name+"=")) + cErr.Detail = fmt.Sprintf("%s; perhaps you intended '?%s=%s'", + cErr.Detail, + e.Parameter.Name, + strings.Join(parts, "&"+e.Parameter.Name+"=")) } } return cErr diff --git a/openapi3filter/validation_error_test.go b/openapi3filter/validation_error_test.go index f67826f72..18368afe7 100644 --- a/openapi3filter/validation_error_test.go +++ b/openapi3filter/validation_error_test.go @@ -10,8 +10,10 @@ import ( "net/http/httptest" "testing" - "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" ) func newPetstoreRequest(t *testing.T, method, path string, body io.Reader) *http.Request { @@ -19,7 +21,7 @@ func newPetstoreRequest(t *testing.T, method, path string, body io.Reader) *http pathPrefix := "v2" r, err := http.NewRequest(method, fmt.Sprintf("http://%s/%s%s", host, pathPrefix, path), body) require.NoError(t, err) - r.Header.Set("Content-Type", "application/json") + r.Header.Set(headerCT, "application/json") r.Header.Set("Authorization", "Bearer magicstring") r.Host = host return r @@ -27,7 +29,7 @@ func newPetstoreRequest(t *testing.T, method, path string, body io.Reader) *http type validationFields struct { Handler http.Handler - SwaggerFile string + File string ErrorEncoder ErrorEncoder } type validationArgs struct { @@ -55,7 +57,8 @@ type validationTest struct { } func getValidationTests(t *testing.T) []*validationTest { - badHost, _ := http.NewRequest(http.MethodGet, "http://unknown-host.com/v2/pet", nil) + badHost, err := http.NewRequest(http.MethodGet, "http://unknown-host.com/v2/pet", nil) + require.NoError(t, err) badPath := newPetstoreRequest(t, http.MethodGet, "/watdis", nil) badMethod := newPetstoreRequest(t, http.MethodTrace, "/pet", nil) @@ -63,16 +66,19 @@ func getValidationTests(t *testing.T) []*validationTest { missingBody2 := newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(``)) noContentType := newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{}`)) - noContentType.Header.Del("Content-Type") + noContentType.Header.Del(headerCT) noContentTypeNeeded := newPetstoreRequest(t, http.MethodGet, "/pet/findByStatus?status=sold", nil) - noContentTypeNeeded.Header.Del("Content-Type") + noContentTypeNeeded.Header.Del(headerCT) unknownContentType := newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{}`)) - unknownContentType.Header.Set("Content-Type", "application/xml") + unknownContentType.Header.Set(headerCT, "application/xml") unsupportedContentType := newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{}`)) - unsupportedContentType.Header.Set("Content-Type", "text/plain") + unsupportedContentType.Header.Set(headerCT, "text/plain") + + unsupportedHeaderValue := newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{}`)) + unsupportedHeaderValue.Header.Set("x-environment", "watdis") return []*validationTest{ // @@ -84,45 +90,45 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: badHost, }, - wantErrReason: "Does not match any server", - wantErrResponse: &ValidationError{Status: http.StatusNotFound, Title: "Does not match any server"}, + wantErrReason: routers.ErrPathNotFound.Error(), + wantErrResponse: &ValidationError{Status: http.StatusNotFound, Title: routers.ErrPathNotFound.Error()}, }, { name: "error - unknown path", args: validationArgs{ r: badPath, }, - wantErrReason: "Path was not found", - wantErrResponse: &ValidationError{Status: http.StatusNotFound, Title: "Path was not found"}, + wantErrReason: routers.ErrPathNotFound.Error(), + wantErrResponse: &ValidationError{Status: http.StatusNotFound, Title: routers.ErrPathNotFound.Error()}, }, { name: "error - unknown method", args: validationArgs{ r: badMethod, }, - wantErrReason: "Path doesn't support the HTTP method", + wantErrReason: routers.ErrMethodNotAllowed.Error(), // TODO: By HTTP spec, this should have an Allow header with what is allowed // but kin-openapi doesn't provide us the requested method or path, so impossible to provide details wantErrResponse: &ValidationError{Status: http.StatusMethodNotAllowed, - Title: "Path doesn't support the HTTP method"}, + Title: routers.ErrMethodNotAllowed.Error()}, }, { name: "error - missing body on POST", args: validationArgs{ r: missingBody1, }, - wantErrBody: "Request body has an error: must have a value", + wantErrBody: "request body has an error: " + ErrInvalidRequired.Error(), wantErrResponse: &ValidationError{Status: http.StatusBadRequest, - Title: "Request body has an error: must have a value"}, + Title: "request body has an error: " + ErrInvalidRequired.Error()}, }, { name: "error - empty body on POST", args: validationArgs{ r: missingBody2, }, - wantErrBody: "Request body has an error: must have a value", + wantErrBody: "request body has an error: " + ErrInvalidRequired.Error(), wantErrResponse: &ValidationError{Status: http.StatusBadRequest, - Title: "Request body has an error: must have a value"}, + Title: "request body has an error: " + ErrInvalidRequired.Error()}, }, // @@ -134,9 +140,9 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: noContentType, }, - wantErrReason: "header 'Content-Type' has unexpected value: \"\"", + wantErrReason: prefixInvalidCT + ` ""`, wantErrResponse: &ValidationError{Status: http.StatusUnsupportedMediaType, - Title: "header 'Content-Type' is required"}, + Title: "header Content-Type is required"}, }, { name: "error - unknown content-type on POST", @@ -145,18 +151,18 @@ func getValidationTests(t *testing.T) []*validationTest { }, wantErrReason: "failed to decode request body", wantErrParseKind: KindUnsupportedFormat, - wantErrParseReason: "unsupported content type \"application/xml\"", + wantErrParseReason: prefixUnsupportedCT + ` "application/xml"`, wantErrResponse: &ValidationError{Status: http.StatusUnsupportedMediaType, - Title: "unsupported content type \"application/xml\""}, + Title: prefixUnsupportedCT + ` "application/xml"`}, }, { name: "error - unsupported content-type on POST", args: validationArgs{ r: unsupportedContentType, }, - wantErrReason: "header 'Content-Type' has unexpected value: \"text/plain\"", + wantErrReason: prefixInvalidCT + ` "text/plain"`, wantErrResponse: &ValidationError{Status: http.StatusUnsupportedMediaType, - Title: "unsupported content type \"text/plain\""}, + Title: prefixUnsupportedCT + ` "text/plain"`}, }, { name: "success - no content-type header required on GET", @@ -176,12 +182,13 @@ func getValidationTests(t *testing.T) []*validationTest { }, wantErrParam: "status", wantErrParamIn: "query", - wantErrReason: "must have a value", + wantErrBody: `parameter "status" in query has an error: value is required but missing`, + wantErrReason: "value is required but missing", wantErrResponse: &ValidationError{Status: http.StatusBadRequest, - Title: "Parameter 'status' in query is required"}, + Title: `parameter "status" in query is required`}, }, { - name: "error - wrong query string parameter type", + name: "error - wrong query string parameter type as integer", args: validationArgs{ r: newPetstoreRequest(t, http.MethodGet, "/pet/findByIds?ids=1,notAnInt", nil), }, @@ -189,16 +196,33 @@ func getValidationTests(t *testing.T) []*validationTest { wantErrParamIn: "query", // This is a nested ParseError. The outer error is a KindOther with no details. // So we'd need to look at the inner one which is a KindInvalidFormat. So just check the error body. - wantErrBody: "Parameter 'ids' in query has an error: path 1: value notAnInt: an invalid integer: " + - "strconv.ParseFloat: parsing \"notAnInt\": invalid syntax", + wantErrBody: `parameter "ids" in query has an error: path 1: value notAnInt: an invalid integer: invalid syntax`, // TODO: Should we treat query params of the wrong type like a 404 instead of a 400? wantErrResponse: &ValidationError{Status: http.StatusBadRequest, - Title: "Parameter 'ids' in query is invalid: notAnInt is an invalid integer"}, + Title: `parameter "ids" in query is invalid: notAnInt is an invalid integer`}, }, { name: "success - ignores unknown query string parameter", args: validationArgs{ - r: newPetstoreRequest(t, http.MethodGet, "/pet/findByStatus?wat=isdis", nil), + r: newPetstoreRequest(t, http.MethodGet, "/pet/findByStatus?status=available&wat=isdis", nil), + }, + }, + { + name: "error - non required query string has empty value", + args: validationArgs{ + r: newPetstoreRequest(t, http.MethodGet, "/pets/?tags=", nil), + }, + wantErrParam: "tags", + wantErrParamIn: "query", + wantErrBody: `parameter "tags" in query has an error: empty value is not allowed`, + wantErrReason: "empty value is not allowed", + wantErrResponse: &ValidationError{Status: http.StatusBadRequest, + Title: `parameter "tags" in query is not allowed to be empty`}, + }, + { + name: "success - non required query string has empty value, but has AllowEmptyValue", + args: validationArgs{ + r: newPetstoreRequest(t, http.MethodGet, "/pets/?status=", nil), }, }, { @@ -220,12 +244,12 @@ func getValidationTests(t *testing.T) []*validationTest { }, wantErrParam: "status", wantErrParamIn: "query", - wantErrSchemaReason: "JSON value is not one of the allowed values", + wantErrSchemaReason: "value is not one of the allowed values [\"available\",\"pending\",\"sold\"]", wantErrSchemaPath: "/0", wantErrSchemaValue: "available,sold", wantErrResponse: &ValidationError{Status: http.StatusBadRequest, - Title: "JSON value is not one of the allowed values", - Detail: "Value 'available,sold' at /0 must be one of: available, pending, sold; " + + Title: "value is not one of the allowed values [\"available\",\"pending\",\"sold\"]", + Detail: "value available,sold at /0 must be one of: available, pending, sold; " + // TODO: do we really want to use this heuristic to guess // that they're using the wrong serialization? "perhaps you intended '?status=available&status=sold'", @@ -238,12 +262,12 @@ func getValidationTests(t *testing.T) []*validationTest { }, wantErrParam: "status", wantErrParamIn: "query", - wantErrSchemaReason: "JSON value is not one of the allowed values", + wantErrSchemaReason: "value is not one of the allowed values [\"available\",\"pending\",\"sold\"]", wantErrSchemaPath: "/1", wantErrSchemaValue: "watdis", wantErrResponse: &ValidationError{Status: http.StatusBadRequest, - Title: "JSON value is not one of the allowed values", - Detail: "Value 'watdis' at /1 must be one of: available, pending, sold", + Title: "value is not one of the allowed values [\"available\",\"pending\",\"sold\"]", + Detail: "value watdis at /1 must be one of: available, pending, sold", Source: &ValidationErrorSource{Parameter: "status"}}, }, { @@ -254,12 +278,12 @@ func getValidationTests(t *testing.T) []*validationTest { }, wantErrParam: "kind", wantErrParamIn: "query", - wantErrSchemaReason: "JSON value is not one of the allowed values", + wantErrSchemaReason: "value is not one of the allowed values [\"dog\",\"cat\",\"turtle\",\"bird,with,commas\"]", wantErrSchemaPath: "/1", wantErrSchemaValue: "fish,with,commas", wantErrResponse: &ValidationError{Status: http.StatusBadRequest, - Title: "JSON value is not one of the allowed values", - Detail: "Value 'fish,with,commas' at /1 must be one of: dog, cat, turtle, bird,with,commas", + Title: "value is not one of the allowed values [\"dog\",\"cat\",\"turtle\",\"bird,with,commas\"]", + Detail: "value fish,with,commas at /1 must be one of: dog, cat, turtle, bird,with,commas", // No 'perhaps you intended' because its the right serialization format Source: &ValidationErrorSource{Parameter: "kind"}}, }, @@ -270,21 +294,54 @@ func getValidationTests(t *testing.T) []*validationTest { }, }, + // + // Request header params + // + { + name: "error - invalid enum value for header string parameter", + args: validationArgs{ + r: unsupportedHeaderValue, + }, + wantErrParam: "x-environment", + wantErrParamIn: "header", + wantErrSchemaReason: "value is not one of the allowed values [\"demo\",\"prod\"]", + wantErrSchemaPath: "/", + wantErrSchemaValue: "watdis", + wantErrResponse: &ValidationError{Status: http.StatusBadRequest, + Title: "value is not one of the allowed values [\"demo\",\"prod\"]", + Detail: "value watdis at / must be one of: demo, prod", + Source: &ValidationErrorSource{Parameter: "x-environment"}}, + }, + // // Request bodies // + { + name: "error - invalid enum value for header object attribute", + args: validationArgs{ + r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"status":"watdis"}`)), + }, + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", + wantErrSchemaReason: "value is not one of the allowed values [\"available\",\"pending\",\"sold\"]", + wantErrSchemaValue: "watdis", + wantErrSchemaPath: "/status", + wantErrResponse: &ValidationError{Status: http.StatusUnprocessableEntity, + Title: "value is not one of the allowed values [\"available\",\"pending\",\"sold\"]", + Detail: "value watdis at /status must be one of: available, pending, sold", + Source: &ValidationErrorSource{Pointer: "/status"}}, + }, { name: "error - missing required object attribute", args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama"}`)), }, - wantErrReason: "doesn't match the schema", - wantErrSchemaReason: "Property 'photoUrls' is missing", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", + wantErrSchemaReason: `property "photoUrls" is missing`, wantErrSchemaValue: map[string]string{"name": "Bahama"}, wantErrSchemaPath: "/photoUrls", wantErrResponse: &ValidationError{Status: http.StatusUnprocessableEntity, - Title: "Property 'photoUrls' is missing", + Title: `property "photoUrls" is missing`, Source: &ValidationErrorSource{Pointer: "/photoUrls"}}, }, { @@ -293,12 +350,12 @@ func getValidationTests(t *testing.T) []*validationTest { r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":[],"category":{}}`)), }, - wantErrReason: "doesn't match the schema", - wantErrSchemaReason: "Property 'name' is missing", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", + wantErrSchemaReason: `property "name" is missing`, wantErrSchemaValue: map[string]string{}, wantErrSchemaPath: "/category/name", wantErrResponse: &ValidationError{Status: http.StatusUnprocessableEntity, - Title: "Property 'name' is missing", + Title: `property "name" is missing`, Source: &ValidationErrorSource{Pointer: "/category/name"}}, }, { @@ -307,37 +364,28 @@ func getValidationTests(t *testing.T) []*validationTest { r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":[],"category":{"tags": [{}]}}`)), }, - wantErrReason: "doesn't match the schema", - wantErrSchemaReason: "Property 'name' is missing", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", + wantErrSchemaReason: `property "name" is missing`, wantErrSchemaValue: map[string]string{}, wantErrSchemaPath: "/category/tags/0/name", wantErrResponse: &ValidationError{Status: http.StatusUnprocessableEntity, - Title: "Property 'name' is missing", + Title: `property "name" is missing`, Source: &ValidationErrorSource{Pointer: "/category/tags/0/name"}}, }, - { - // TODO: Add support for validating readonly properties to upstream validator. - name: "error - readonly object attribute", - args: validationArgs{ - r: newPetstoreRequest(t, http.MethodPost, "/pet", - bytes.NewBufferString(`{"id":213,"name":"Bahama","photoUrls":[]}}`)), - }, - //wantErr: true, - }, { name: "error - wrong attribute type", args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet", bytes.NewBufferString(`{"name":"Bahama","photoUrls":"http://cat"}`)), }, - wantErrReason: "doesn't match the schema", - wantErrSchemaReason: "Field must be set to array or not be present", + wantErrReason: "doesn't match schema #/components/schemas/PetWithRequired", + wantErrSchemaReason: "value must be an array", wantErrSchemaPath: "/photoUrls", - wantErrSchemaValue: "string", + wantErrSchemaValue: "http://cat", // TODO: this shouldn't say "or not be present", but this requires recursively resolving // innerErr.JSONPointer() against e.RequestBody.Content["application/json"].Schema.Value (.Required, .Properties) wantErrResponse: &ValidationError{Status: http.StatusUnprocessableEntity, - Title: "Field must be set to array or not be present", + Title: "value must be an array", Source: &ValidationErrorSource{Pointer: "/photoUrls"}}, }, { @@ -345,14 +393,15 @@ func getValidationTests(t *testing.T) []*validationTest { args: validationArgs{ r: newPetstoreRequest(t, http.MethodPost, "/pet2", bytes.NewBufferString(`{"name":"Bahama"}`)), }, - wantErrReason: "doesn't match the schema", + wantErrReason: "doesn't match schema", wantErrSchemaPath: "/", wantErrSchemaValue: map[string]string{"name": "Bahama"}, - wantErrSchemaOriginReason: "Property 'photoUrls' is missing", + wantErrSchemaReason: `doesn't match all schemas from "allOf"`, + wantErrSchemaOriginReason: `property "photoUrls" is missing`, wantErrSchemaOriginValue: map[string]string{"name": "Bahama"}, wantErrSchemaOriginPath: "/photoUrls", wantErrResponse: &ValidationError{Status: http.StatusUnprocessableEntity, - Title: "Property 'photoUrls' is missing", + Title: `property "photoUrls" is missing`, Source: &ValidationErrorSource{Pointer: "/photoUrls"}}, }, { @@ -387,9 +436,10 @@ func getValidationTests(t *testing.T) []*validationTest { }, wantErrParam: "petId", wantErrParamIn: "path", - wantErrReason: "must have a value", + wantErrBody: `parameter "petId" in path has an error: value is required but missing`, + wantErrReason: "value is required but missing", wantErrResponse: &ValidationError{Status: http.StatusBadRequest, - Title: "Parameter 'petId' in path is required"}, + Title: `parameter "petId" in path is required`}, }, { name: "error - wrong path param type", @@ -402,7 +452,7 @@ func getValidationTests(t *testing.T) []*validationTest { wantErrParseValue: "NotAnInt", wantErrParseReason: "an invalid integer", wantErrResponse: &ValidationError{Status: http.StatusNotFound, - Title: "Resource not found with 'petId' value: NotAnInt"}, + Title: `resource not found with "petId" value: NotAnInt`}, }, { name: "success - normal case, with path params", @@ -430,13 +480,13 @@ func TestValidationHandler_validateRequest(t *testing.T) { req.Equal(tt.wantErrBody, err.Error()) } - if e, ok := err.(*RouteError); ok { - req.Equal(tt.wantErrReason, e.Reason) + if e, ok := err.(*routers.RouteError); ok { + req.Equal(tt.wantErrReason, e.Error()) return } e, ok := err.(*RequestError) - req.True(ok, "error = %v, not a RequestError -- %#v", err, err) + req.True(ok, "not a RequestError: %T -- %#v", err, err) req.Equal(tt.wantErrReason, e.Reason) @@ -506,12 +556,12 @@ func TestValidationErrorEncoder(t *testing.T) { } func buildValidationHandler(tt *validationTest) (*ValidationHandler, error) { - if tt.fields.SwaggerFile == "" { - tt.fields.SwaggerFile = "fixtures/petstore.json" + if tt.fields.File == "" { + tt.fields.File = "testdata/fixtures/petstore.json" } h := &ValidationHandler{ Handler: tt.fields.Handler, - SwaggerFile: tt.fields.SwaggerFile, + File: tt.fields.File, ErrorEncoder: tt.fields.ErrorEncoder, } tt.wantErr = tt.wantErr || @@ -551,7 +601,7 @@ func runTest_ServeHTTP(t *testing.T, handler http.Handler, encoder ErrorEncoder, h := &ValidationHandler{ Handler: handler, ErrorEncoder: encoder, - SwaggerFile: "fixtures/petstore.json", + File: "testdata/fixtures/petstore.json", } err := h.Load() require.NoError(t, err) @@ -563,7 +613,7 @@ func runTest_ServeHTTP(t *testing.T, handler http.Handler, encoder ErrorEncoder, func runTest_Middleware(t *testing.T, handler http.Handler, encoder ErrorEncoder, req *http.Request) *http.Response { h := &ValidationHandler{ ErrorEncoder: encoder, - SwaggerFile: "fixtures/petstore.json", + File: "testdata/fixtures/petstore.json", } err := h.Load() require.NoError(t, err) @@ -610,8 +660,30 @@ func TestValidationHandler_ServeHTTP(t *testing.T) { body, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) - require.Equal(t, "[422][][] Field must be set to array or not be present [source pointer=/photoUrls]", string(body)) + require.Equal(t, "[422][][] value must be an array [source pointer=/photoUrls]", string(body)) }) + + //t.Run("ignores requests not matching openapi servers if requested", func(t *testing.T) { + // r := newPetstoreRequest(t, http.MethodGet, "/pet/findByStatus?status=sold", nil) + // r.URL.Host = "notaserver" + // + // handler := &testHandler{} + // encoder := &mockErrorEncoder{} + // + // h := &ValidationHandler{ + // Handler: handler, + // ErrorEncoder: encoder.Encode, + // SwaggerFile: "fixtures/petstore.json", + // IgnoreServerErrors: true, + // } + // err := h.Load() + // require.NoError(t, err) + // w := httptest.NewRecorder() + // h.ServeHTTP(w, r) + // + // require.True(t, handler.Called) + // require.False(t, encoder.Called) + //}) } func TestValidationHandler_Middleware(t *testing.T) { @@ -652,28 +724,6 @@ func TestValidationHandler_Middleware(t *testing.T) { body, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) - require.Equal(t, "[422][][] Field must be set to array or not be present [source pointer=/photoUrls]", string(body)) - }) - - t.Run("ignores requests not matching openapi servers if requested", func(t *testing.T) { - r := newPetstoreRequest(t, http.MethodGet, "/pet/findByStatus?status=sold", nil) - r.URL.Host = "notaserver" - - handler := &testHandler{} - encoder := &mockErrorEncoder{} - - h := &ValidationHandler{ - Handler: handler, - ErrorEncoder: encoder.Encode, - SwaggerFile: "fixtures/petstore.json", - IgnoreServerErrors: true, - } - err := h.Load() - require.NoError(t, err) - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - - require.True(t, handler.Called) - require.False(t, encoder.Called) + require.Equal(t, "[422][][] value must be an array [source pointer=/photoUrls]", string(body)) }) } diff --git a/openapi3filter/validation_handler.go b/openapi3filter/validation_handler.go index 88a7eeb18..abd03bba9 100644 --- a/openapi3filter/validation_handler.go +++ b/openapi3filter/validation_handler.go @@ -3,13 +3,20 @@ package openapi3filter import ( "context" "fmt" - "github.com/getkin/kin-openapi/openapi3" "net/http" "net/url" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" ) +// AuthenticationFunc allows for custom security requirement validation. +// A non-nil error fails authentication according to https://spec.openapis.org/oas/v3.1.0#security-requirement-object +// See ValidateSecurityRequirements type AuthenticationFunc func(context.Context, *AuthenticationInput) error +// NoopAuthenticationFunc is an AuthenticationFunc func NoopAuthenticationFunc(context.Context, *AuthenticationInput) error { return nil } var _ AuthenticationFunc = NoopAuthenticationFunc @@ -17,19 +24,23 @@ var _ AuthenticationFunc = NoopAuthenticationFunc type ValidationHandler struct { Handler http.Handler AuthenticationFunc AuthenticationFunc - SwaggerFile string + File string ErrorEncoder ErrorEncoder - IgnoreServerErrors bool - router *Router + router routers.Router } func (h *ValidationHandler) Load() error { - h.router = NewRouter() - - err := h.LoadSwagger() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromFile(h.File) if err != nil { return err } + if err := doc.Validate(loader.Context); err != nil { + return err + } + if h.router, err = legacyrouter.NewRouter(doc); err != nil { + return err + } // set defaults if h.Handler == nil { @@ -45,20 +56,20 @@ func (h *ValidationHandler) Load() error { return nil } -func (h *ValidationHandler) LoadSwagger() error { - swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile(h.SwaggerFile) - if err != nil { - return err - } - if h.IgnoreServerErrors { - // remove servers from the OpenAPI spec if we shouldn't validate them - swagger, err = h.removeServers(swagger) - if err != nil { - return err - } - } - return h.router.AddSwagger(swagger) -} +//func (h *ValidationHandler) LoadSwagger() error { +// swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile(h.SwaggerFile) +// if err != nil { +// return err +// } +// if h.IgnoreServerErrors { +// // remove servers from the OpenAPI spec if we shouldn't validate them +// swagger, err = h.removeServers(swagger) +// if err != nil { +// return err +// } +// } +// return h.router.AddSwagger(swagger) +//} func (h *ValidationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if handled := h.before(w, r); handled { @@ -80,8 +91,7 @@ func (h *ValidationHandler) Middleware(next http.Handler) http.Handler { } func (h *ValidationHandler) before(w http.ResponseWriter, r *http.Request) (handled bool) { - err := h.validateRequest(r) - if err != nil { + if err := h.validateRequest(r); err != nil { h.ErrorEncoder(r.Context(), err, w) return true } @@ -90,7 +100,7 @@ func (h *ValidationHandler) before(w http.ResponseWriter, r *http.Request) (hand func (h *ValidationHandler) validateRequest(r *http.Request) error { // Find route - route, pathParams, err := h.router.FindRoute(r.Method, r.URL) + route, pathParams, err := h.router.FindRoute(r) if err != nil { return err } @@ -106,8 +116,7 @@ func (h *ValidationHandler) validateRequest(r *http.Request) error { Route: route, Options: options, } - err = ValidateRequest(r.Context(), requestValidationInput) - if err != nil { + if err = ValidateRequest(r.Context(), requestValidationInput); err != nil { return err } @@ -118,7 +127,7 @@ func (h *ValidationHandler) validateRequest(r *http.Request) error { // // It also rewrites all the paths to begin with the server path, so that the paths still work. // This assumes that all servers share the same path (e.g., all have /v1), or return an error. -func (h *ValidationHandler) removeServers(swagger *openapi3.Swagger) (*openapi3.Swagger, error) { +func (h *ValidationHandler) removeServers(swagger *openapi3.T) (*openapi3.T, error) { // collect API pathPrefix path prefixes prefixes := make(map[string]struct{}, 0) // a "set" for _, s := range swagger.Servers { diff --git a/openapi3filter/validation_test.go b/openapi3filter/validation_test.go index 6ac05b331..d3a1b45bb 100644 --- a/openapi3filter/validation_test.go +++ b/openapi3filter/validation_test.go @@ -1,4 +1,4 @@ -package openapi3filter_test +package openapi3filter import ( "bytes" @@ -14,9 +14,10 @@ import ( "strings" "testing" - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" ) type ExampleRequest struct { @@ -45,7 +46,7 @@ func TestFilter(t *testing.T) { complexArgSchema.Required = []string{"name", "id"} // Declare router - swagger := &openapi3.Swagger{ + doc := &openapi3.T{ OpenAPI: "3.0.0", Info: &openapi3.Info{ Title: "MyAPI", @@ -62,9 +63,10 @@ func TestFilter(t *testing.T) { Parameters: openapi3.Parameters{ { Value: &openapi3.Parameter{ - In: "path", - Name: "pathArg", - Schema: openapi3.NewStringSchema().WithMaxLength(2).NewRef(), + In: "path", + Name: "pathArg", + Schema: openapi3.NewStringSchema().WithMaxLength(2).NewRef(), + Required: true, }, }, { @@ -104,7 +106,7 @@ func TestFilter(t *testing.T) { ).NewRef(), }, }, - // TODO(decode not): handle decoding "not" JSON Schema + // TODO(decode not): handle decoding "not" Schema // { // Value: &openapi3.Parameter{ // In: "query", @@ -133,13 +135,13 @@ func TestFilter(t *testing.T) { }, }, }, - Responses: make(openapi3.Responses), + Responses: openapi3.NewResponses(), }, }, "/issue151": &openapi3.PathItem{ Get: &openapi3.Operation{ - Responses: make(openapi3.Responses), + Responses: openapi3.NewResponses(), }, Parameters: openapi3.Parameters{ { @@ -155,32 +157,36 @@ func TestFilter(t *testing.T) { }, } - router := openapi3filter.NewRouter().WithSwagger(swagger) - expectWithDecoder := func(req ExampleRequest, resp ExampleResponse, decoder openapi3filter.ContentParameterDecoder) error { + err := doc.Validate(context.Background()) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) + expectWithDecoder := func(req ExampleRequest, resp ExampleResponse, decoder ContentParameterDecoder) error { t.Logf("Request: %s %s", req.Method, req.URL) - httpReq, _ := http.NewRequest(req.Method, req.URL, marshalReader(req.Body)) - httpReq.Header.Set("Content-Type", req.ContentType) + httpReq, err := http.NewRequest(req.Method, req.URL, marshalReader(req.Body)) + require.NoError(t, err) + httpReq.Header.Set(headerCT, req.ContentType) // Find route - route, pathParams, err := router.FindRoute(httpReq.Method, httpReq.URL) + route, pathParams, err := router.FindRoute(httpReq) require.NoError(t, err) // Validate request - requestValidationInput := &openapi3filter.RequestValidationInput{ + requestValidationInput := &RequestValidationInput{ Request: httpReq, PathParams: pathParams, Route: route, ParamDecoder: decoder, } - if err := openapi3filter.ValidateRequest(context.TODO(), requestValidationInput); err != nil { + if err := ValidateRequest(context.Background(), requestValidationInput); err != nil { return err } t.Logf("Response: %d", resp.Status) - responseValidationInput := &openapi3filter.ResponseValidationInput{ + responseValidationInput := &ResponseValidationInput{ RequestValidationInput: requestValidationInput, Status: resp.Status, Header: http.Header{ - "Content-Type": []string{ + headerCT: []string{ resp.ContentType, }, }, @@ -190,27 +196,23 @@ func TestFilter(t *testing.T) { require.NoError(t, err) responseValidationInput.SetBodyBytes(data) } - err = openapi3filter.ValidateResponse(context.TODO(), responseValidationInput) + err = ValidateResponse(context.Background(), responseValidationInput) require.NoError(t, err) - return err + return nil } expect := func(req ExampleRequest, resp ExampleResponse) error { return expectWithDecoder(req, resp, nil) } - var err error - var req ExampleRequest - var resp ExampleResponse - resp = ExampleResponse{ + resp := ExampleResponse{ Status: 200, } - // Test paths - req = ExampleRequest{ + // Test paths + req := ExampleRequest{ Method: "POST", URL: "http://example.com/api/prefix/v/suffix", } - err = expect(req, resp) require.NoError(t, err) @@ -220,7 +222,7 @@ func TestFilter(t *testing.T) { URL: "http://example.com/api/prefix/EXCEEDS_MAX_LENGTH/suffix", } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) // Test query parameter openapi3filter req = ExampleRequest{ @@ -235,14 +237,14 @@ func TestFilter(t *testing.T) { URL: "http://example.com/api/prefix/v/suffix?queryArg=EXCEEDS_MAX_LENGTH", } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) req = ExampleRequest{ Method: "GET", URL: "http://example.com/api/issue151?par2=par1_is_missing", } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) // Test query parameter openapi3filter req = ExampleRequest{ @@ -264,51 +266,51 @@ func TestFilter(t *testing.T) { URL: "http://example.com/api/prefix/v/suffix?queryArgAnyOf=123", } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) req = ExampleRequest{ Method: "POST", URL: "http://example.com/api/prefix/v/suffix?queryArgOneOf=567", } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) req = ExampleRequest{ Method: "POST", URL: "http://example.com/api/prefix/v/suffix?queryArgOneOf=2017-12-31T11:59:59", } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) req = ExampleRequest{ Method: "POST", URL: "http://example.com/api/prefix/v/suffix?queryArgAllOf=abdfg", } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) - // TODO(decode not): handle decoding "not" JSON Schema + // TODO(decode not): handle decoding "not" Schema // req = ExampleRequest{ // Method: "POST", // URL: "http://example.com/api/prefix/v/suffix?queryArgNot=abdfg", // } // err = expect(req, resp) - // require.IsType(t, &openapi3filter.RequestError{}, err) + // require.IsType(t, &RequestError{}, err) - // TODO(decode not): handle decoding "not" JSON Schema + // TODO(decode not): handle decoding "not" Schema // req = ExampleRequest{ // Method: "POST", // URL: "http://example.com/api/prefix/v/suffix?queryArgNot=123", // } // err = expect(req, resp) - // require.IsType(t, &openapi3filter.RequestError{}, err) + // require.IsType(t, &RequestError{}, err) req = ExampleRequest{ Method: "POST", URL: "http://example.com/api/prefix/v/suffix?queryArg=EXCEEDS_MAX_LENGTH", } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) req = ExampleRequest{ Method: "POST", @@ -318,14 +320,14 @@ func TestFilter(t *testing.T) { Status: 200, } err = expect(req, resp) - // require.IsType(t, &openapi3filter.ResponseError{}, err) + // require.IsType(t, &ResponseError{}, err) require.NoError(t, err) // Check that content validation works. This should pass, as ID is short // enough. req = ExampleRequest{ Method: "POST", - URL: "http://example.com/api/prefix/v/suffix?contentArg={\"name\":\"bob\", \"id\":\"a\"}", + URL: `http://example.com/api/prefix/v/suffix?contentArg={"name":"bob", "id":"a"}`, } err = expect(req, resp) require.NoError(t, err) @@ -333,10 +335,10 @@ func TestFilter(t *testing.T) { // Now it should fail due the ID being too long req = ExampleRequest{ Method: "POST", - URL: "http://example.com/api/prefix/v/suffix?contentArg={\"name\":\"bob\", \"id\":\"EXCEEDS_MAX_LENGTH\"}", + URL: `http://example.com/api/prefix/v/suffix?contentArg={"name":"bob", "id":"EXCEEDS_MAX_LENGTH"}`, } err = expect(req, resp) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) // Now, repeat the above two test cases using a custom parameter decoder. customDecoder := func(param *openapi3.Parameter, values []string) (interface{}, *openapi3.Schema, error) { @@ -348,7 +350,7 @@ func TestFilter(t *testing.T) { req = ExampleRequest{ Method: "POST", - URL: "http://example.com/api/prefix/v/suffix?contentArg2={\"name\":\"bob\", \"id\":\"a\"}", + URL: `http://example.com/api/prefix/v/suffix?contentArg2={"name":"bob", "id":"a"}`, } err = expectWithDecoder(req, resp, customDecoder) require.NoError(t, err) @@ -356,10 +358,10 @@ func TestFilter(t *testing.T) { // Now it should fail due the ID being too long req = ExampleRequest{ Method: "POST", - URL: "http://example.com/api/prefix/v/suffix?contentArg2={\"name\":\"bob\", \"id\":\"EXCEEDS_MAX_LENGTH\"}", + URL: `http://example.com/api/prefix/v/suffix?contentArg2={"name":"bob", "id":"EXCEEDS_MAX_LENGTH"}`, } err = expectWithDecoder(req, resp, customDecoder) - require.IsType(t, &openapi3filter.RequestError{}, err) + require.IsType(t, &RequestError{}, err) } func marshalReader(value interface{}) io.ReadCloser { @@ -403,7 +405,7 @@ func TestValidateRequestBody(t *testing.T) { { name: "required empty", body: requiredReqBody, - wantErr: &openapi3filter.RequestError{RequestBody: requiredReqBody, Err: openapi3filter.ErrInvalidRequired}, + wantErr: &RequestError{RequestBody: requiredReqBody, Err: ErrInvalidRequired}, }, { name: "required not empty", @@ -434,10 +436,10 @@ func TestValidateRequestBody(t *testing.T) { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/test", tc.data) if tc.mime != "" { - req.Header.Set(http.CanonicalHeaderKey("Content-Type"), tc.mime) + req.Header.Set(headerCT, tc.mime) } - inp := &openapi3filter.RequestValidationInput{Request: req} - err := openapi3filter.ValidateRequestBody(context.Background(), inp, tc.body) + inp := &RequestValidationInput{Request: req} + err := ValidateRequestBody(context.Background(), inp, tc.body) if tc.wantErr == nil { require.NoError(t, err) @@ -453,11 +455,11 @@ func matchReqBodyError(want, got error) bool { if want == got { return true } - wErr, ok := want.(*openapi3filter.RequestError) + wErr, ok := want.(*RequestError) if !ok { return false } - gErr, ok := got.(*openapi3filter.RequestError) + gErr, ok := got.(*RequestError) if !ok { return false } @@ -478,8 +480,7 @@ func toJSON(v interface{}) io.Reader { return bytes.NewReader(data) } -// TestOperationOrSwaggerSecurity asserts that the swagger's SecurityRequirements are used if no SecurityRequirements are provided for an operation. -func TestOperationOrSwaggerSecurity(t *testing.T) { +func TestRootSecurityRequirementsAreUsedIfNotProvidedAtTheOperationLevel(t *testing.T) { // Create the security schemes securitySchemes := []ExampleSecurityScheme{ { @@ -528,8 +529,7 @@ func TestOperationOrSwaggerSecurity(t *testing.T) { }, } - // Create the swagger - swagger := &openapi3.Swagger{ + doc := &openapi3.T{ OpenAPI: "3.0.0", Info: &openapi3.Info{ Title: "MyAPI", @@ -541,43 +541,40 @@ func TestOperationOrSwaggerSecurity(t *testing.T) { securitySchemes[1].Name: {}, }, }, - Components: openapi3.Components{ + Components: &openapi3.Components{ SecuritySchemes: map[string]*openapi3.SecuritySchemeRef{}, }, } // Add the security schemes to the components for _, scheme := range securitySchemes { - swagger.Components.SecuritySchemes[scheme.Name] = &openapi3.SecuritySchemeRef{ + doc.Components.SecuritySchemes[scheme.Name] = &openapi3.SecuritySchemeRef{ Value: scheme.Scheme, } } - // Add the paths from the test cases to the swagger's paths + // Add the paths from the test cases to the spec's paths for _, tc := range tc { var securityRequirements *openapi3.SecurityRequirements = nil if tc.schemes != nil { - tempS := make(openapi3.SecurityRequirements, 0) + tempS := openapi3.NewSecurityRequirements() for _, scheme := range *tc.schemes { - tempS = append( - tempS, - openapi3.SecurityRequirement{ - scheme.Name: {}, - }, - ) + tempS.With(openapi3.SecurityRequirement{scheme.Name: {}}) } - securityRequirements = &tempS + securityRequirements = tempS } - swagger.Paths[tc.name] = &openapi3.PathItem{ + doc.Paths[tc.name] = &openapi3.PathItem{ Get: &openapi3.Operation{ Security: securityRequirements, - Responses: make(openapi3.Responses), + Responses: openapi3.NewResponses(), }, } } - // Declare the router - router := openapi3filter.NewRouter().WithSwagger(swagger) + err := doc.Validate(context.Background()) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(doc) + require.NoError(t, err) // Test each case for _, path := range tc { @@ -593,15 +590,14 @@ func TestOperationOrSwaggerSecurity(t *testing.T) { // Create the request emptyBody := bytes.NewReader(make([]byte, 0)) - pathURL, err := url.Parse(path.name) - require.NoError(t, err) - route, _, err := router.FindRoute(http.MethodGet, pathURL) + httpReq := httptest.NewRequest(http.MethodGet, path.name, emptyBody) + route, _, err := router.FindRoute(httpReq) require.NoError(t, err) - req := openapi3filter.RequestValidationInput{ - Request: httptest.NewRequest(http.MethodGet, path.name, emptyBody), + req := RequestValidationInput{ + Request: httpReq, Route: route, - Options: &openapi3filter.Options{ - AuthenticationFunc: func(c context.Context, input *openapi3filter.AuthenticationInput) error { + Options: &Options{ + AuthenticationFunc: func(ctx context.Context, input *AuthenticationInput) error { if schemesValidated != nil { if validated, ok := (*schemesValidated)[input.SecurityScheme]; ok { if validated { @@ -622,7 +618,7 @@ func TestOperationOrSwaggerSecurity(t *testing.T) { } // Validate the request - err = openapi3filter.ValidateRequest(context.TODO(), &req) + err = ValidateRequest(context.Background(), &req) require.NoError(t, err) for securityRequirement, validated := range *schemesValidated { @@ -667,22 +663,21 @@ func TestAnySecurityRequirementMet(t *testing.T) { }, } - // Create the swagger - swagger := openapi3.Swagger{ + doc := openapi3.T{ OpenAPI: "3.0.0", Info: &openapi3.Info{ Title: "MyAPI", Version: "0.1", }, Paths: map[string]*openapi3.PathItem{}, - Components: openapi3.Components{ + Components: &openapi3.Components{ SecuritySchemes: map[string]*openapi3.SecuritySchemeRef{}, }, } - // Add the security schemes to the swagger's components + // Add the security schemes to the spec's components for schemeName := range schemes { - swagger.Components.SecuritySchemes[schemeName] = &openapi3.SecuritySchemeRef{ + doc.Components.SecuritySchemes[schemeName] = &openapi3.SecuritySchemeRef{ Value: &openapi3.SecurityScheme{ Type: "http", Scheme: "basic", @@ -690,27 +685,27 @@ func TestAnySecurityRequirementMet(t *testing.T) { } } - // Add the paths to the swagger + // Add the paths to the spec for _, tc := range tc { // Create the security requirements from the test cases's schemes - securityRequirements := make(openapi3.SecurityRequirements, len(tc.schemes)) - for i, scheme := range tc.schemes { - securityRequirements[i] = openapi3.SecurityRequirement{ - scheme: {}, - } + securityRequirements := openapi3.NewSecurityRequirements() + for _, scheme := range tc.schemes { + securityRequirements.With(openapi3.SecurityRequirement{scheme: {}}) } // Create the path with the security requirements - swagger.Paths[tc.name] = &openapi3.PathItem{ + doc.Paths[tc.name] = &openapi3.PathItem{ Get: &openapi3.Operation{ - Security: &securityRequirements, - Responses: make(openapi3.Responses), + Security: securityRequirements, + Responses: openapi3.NewResponses(), }, } } - // Create the router - router := openapi3filter.NewRouter().WithSwagger(&swagger) + err := doc.Validate(context.Background()) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(&doc) + require.NoError(t, err) // Create the authentication function authFunc := makeAuthFunc(schemes) @@ -719,17 +714,18 @@ func TestAnySecurityRequirementMet(t *testing.T) { // Create the request input for the path tcURL, err := url.Parse(tc.name) require.NoError(t, err) - route, _, err := router.FindRoute(http.MethodGet, tcURL) + httpReq := httptest.NewRequest(http.MethodGet, tcURL.String(), nil) + route, _, err := router.FindRoute(httpReq) require.NoError(t, err) - req := openapi3filter.RequestValidationInput{ + req := RequestValidationInput{ Route: route, - Options: &openapi3filter.Options{ + Options: &Options{ AuthenticationFunc: authFunc, }, } // Validate the security requirements - err = openapi3filter.ValidateSecurityRequirements(context.TODO(), &req, *route.Operation.Security) + err = ValidateSecurityRequirements(context.Background(), &req, *route.Operation.Security) // If there should have been an error if tc.error { @@ -764,22 +760,21 @@ func TestAllSchemesMet(t *testing.T) { }, } - // Create the swagger - swagger := openapi3.Swagger{ + doc := openapi3.T{ OpenAPI: "3.0.0", Info: &openapi3.Info{ Title: "MyAPI", Version: "0.1", }, Paths: map[string]*openapi3.PathItem{}, - Components: openapi3.Components{ + Components: &openapi3.Components{ SecuritySchemes: map[string]*openapi3.SecuritySchemeRef{}, }, } - // Add the security schemes to the swagger's components + // Add the security schemes to the spec's components for schemeName := range schemes { - swagger.Components.SecuritySchemes[schemeName] = &openapi3.SecuritySchemeRef{ + doc.Components.SecuritySchemes[schemeName] = &openapi3.SecuritySchemeRef{ Value: &openapi3.SecurityScheme{ Type: "http", Scheme: "basic", @@ -787,7 +782,7 @@ func TestAllSchemesMet(t *testing.T) { } } - // Add the paths to the swagger + // Add the paths to the spec for _, tc := range tc { // Create the security requirement for the path securityRequirement := openapi3.SecurityRequirement{} @@ -799,18 +794,20 @@ func TestAllSchemesMet(t *testing.T) { } } - swagger.Paths[tc.name] = &openapi3.PathItem{ + doc.Paths[tc.name] = &openapi3.PathItem{ Get: &openapi3.Operation{ Security: &openapi3.SecurityRequirements{ securityRequirement, }, - Responses: make(openapi3.Responses), + Responses: openapi3.NewResponses(), }, } } - // Create the router from the swagger - router := openapi3filter.NewRouter().WithSwagger(&swagger) + err := doc.Validate(context.Background()) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(&doc) + require.NoError(t, err) // Create the authentication function authFunc := makeAuthFunc(schemes) @@ -819,17 +816,18 @@ func TestAllSchemesMet(t *testing.T) { // Create the request input for the path tcURL, err := url.Parse(tc.name) require.NoError(t, err) - route, _, err := router.FindRoute(http.MethodGet, tcURL) + httpReq := httptest.NewRequest(http.MethodGet, tcURL.String(), nil) + route, _, err := router.FindRoute(httpReq) require.NoError(t, err) - req := openapi3filter.RequestValidationInput{ + req := RequestValidationInput{ Route: route, - Options: &openapi3filter.Options{ + Options: &Options{ AuthenticationFunc: authFunc, }, } // Validate the security requirements - err = openapi3filter.ValidateSecurityRequirements(context.TODO(), &req, *route.Operation.Security) + err = ValidateSecurityRequirements(context.Background(), &req, *route.Operation.Security) // If there should have been an error if tc.error { @@ -843,8 +841,8 @@ func TestAllSchemesMet(t *testing.T) { // makeAuthFunc creates an authentication function that accepts the given valid schemes. // If an invalid or unknown scheme is encountered, an error is returned by the returned function. // Otherwise the return value of the returned function is nil. -func makeAuthFunc(schemes map[string]bool) func(c context.Context, input *openapi3filter.AuthenticationInput) error { - return func(c context.Context, input *openapi3filter.AuthenticationInput) error { +func makeAuthFunc(schemes map[string]bool) func(ctx context.Context, input *AuthenticationInput) error { + return func(ctx context.Context, input *AuthenticationInput) error { // If the scheme is valid and present in the schemes valid, present := schemes[input.SecuritySchemeName] if valid && present { diff --git a/openapi3filter/zip_file_upload_test.go b/openapi3filter/zip_file_upload_test.go new file mode 100644 index 000000000..69c6419cc --- /dev/null +++ b/openapi3filter/zip_file_upload_test.go @@ -0,0 +1,116 @@ +package openapi3filter_test + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func TestValidateZipFileUpload(t *testing.T) { + const spec = ` +openapi: 3.0.0 +info: + title: 'Validator' + version: 0.0.1 +paths: + /test: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + responses: + '200': + description: Created +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + tests := []struct { + zipData []byte + wantErr bool + }{ + { + []byte{ + 0x50, 0x4b, 0x03, 0x04, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x7d, 0x23, 0x56, 0xcd, 0xfd, 0x67, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x09, 0x00, 0x1c, 0x00, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x2e, 0x74, 0x78, 0x74, 0x55, 0x54, 0x09, 0x00, 0x03, 0xac, 0xce, 0xb3, 0x63, 0xaf, 0xce, 0xb3, 0x63, 0x75, 0x78, 0x0b, 0x00, 0x01, 0x04, 0xf7, 0x01, 0x00, 0x00, 0x04, 0x14, 0x00, 0x00, 0x00, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x2e, 0x0a, 0x50, 0x4b, 0x01, 0x02, 0x1e, 0x03, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x7d, 0x23, 0x56, 0xcd, 0xfd, 0x67, 0xf8, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x09, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xa4, 0x81, 0x00, 0x00, 0x00, 0x00, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x2e, 0x74, 0x78, 0x74, 0x55, 0x54, 0x05, 0x00, 0x03, 0xac, 0xce, 0xb3, 0x63, 0x75, 0x78, 0x0b, 0x00, 0x01, 0x04, 0xf7, 0x01, 0x00, 0x00, 0x04, 0x14, 0x00, 0x00, 0x00, 0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x4f, 0x00, 0x00, 0x00, 0x4a, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + false, + }, + { + []byte{ + 0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, // No entry + true, + }, + } + for _, tt := range tests { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + { // Add file data + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="file"; filename="hello.zip"`) + h.Set("Content-Type", "application/zip") + + fw, err := writer.CreatePart(h) + require.NoError(t, err) + _, err = io.Copy(fw, bytes.NewReader(tt.zipData)) + + require.NoError(t, err) + } + + writer.Close() + + req, err := http.NewRequest(http.MethodPost, "/test", bytes.NewReader(body.Bytes())) + require.NoError(t, err) + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + if err = openapi3filter.ValidateRequestBody( + context.Background(), + &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + }, + route.Operation.RequestBody.Value, + ); err != nil { + if !tt.wantErr { + t.Errorf("got %v", err) + } + continue + } + if tt.wantErr { + t.Errorf("want err") + } + } +} diff --git a/jsoninfo/field_info.go b/openapi3gen/field_info.go similarity index 66% rename from jsoninfo/field_info.go rename to openapi3gen/field_info.go index d949a79d3..13f5ba048 100644 --- a/jsoninfo/field_info.go +++ b/openapi3gen/field_info.go @@ -1,4 +1,4 @@ -package jsoninfo +package openapi3gen import ( "reflect" @@ -7,9 +7,8 @@ import ( "unicode/utf8" ) -// FieldInfo contains information about JSON serialization of a field. -type FieldInfo struct { - MultipleFields bool // Whether multiple Go fields share this JSON name +// theFieldInfo contains information about JSON serialization of a field. +type theFieldInfo struct { HasJSONTag bool TypeIsMarshaller bool TypeIsUnmarshaller bool @@ -20,7 +19,10 @@ type FieldInfo struct { JSONName string } -func AppendFields(fields []FieldInfo, parentIndex []int, t reflect.Type) []FieldInfo { +func appendFields(fields []theFieldInfo, parentIndex []int, t reflect.Type) []theFieldInfo { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } // For each field numField := t.NumField() iteration: @@ -32,11 +34,14 @@ iteration: // See whether this is an embedded field if f.Anonymous { - if f.Tag.Get("json") == "-" { + jsonTag := f.Tag.Get("json") + if jsonTag == "-" { continue } - fields = AppendFields(fields, index, f.Type) - continue iteration + if jsonTag == "" { + fields = appendFields(fields, index, f.Type) + continue iteration + } } // Ignore certain types @@ -52,7 +57,7 @@ iteration: } // Declare a field - field := FieldInfo{ + field := theFieldInfo{ Index: index, Type: f.Type, JSONName: f.Name, @@ -61,24 +66,17 @@ iteration: // Read "json" tag jsonTag := f.Tag.Get("json") - // Read our custom "multijson" tag that - // allows multiple fields with the same name. - if v := f.Tag.Get("multijson"); len(v) > 0 { - field.MultipleFields = true - jsonTag = v - } - // Handle "-" if jsonTag == "-" { continue } // Parse the tag - if len(jsonTag) > 0 { + if jsonTag != "" { field.HasJSONTag = true for i, part := range strings.Split(jsonTag, ",") { if i == 0 { - if len(part) > 0 { + if part != "" { field.JSONName = part } } else { @@ -92,12 +90,8 @@ iteration: } } - if _, ok := field.Type.MethodByName("MarshalJSON"); ok { - field.TypeIsMarshaller = true - } - if _, ok := field.Type.MethodByName("UnmarshalJSON"); ok { - field.TypeIsUnmarshaller = true - } + _, field.TypeIsMarshaller = field.Type.MethodByName("MarshalJSON") + _, field.TypeIsUnmarshaller = field.Type.MethodByName("UnmarshalJSON") // Field is done fields = append(fields, field) @@ -106,7 +100,7 @@ iteration: return fields } -type sortableFieldInfos []FieldInfo +type sortableFieldInfos []theFieldInfo func (list sortableFieldInfos) Len() int { return len(list) diff --git a/openapi3gen/openapi3gen.go b/openapi3gen/openapi3gen.go index 4a80405ba..eccabd85d 100644 --- a/openapi3gen/openapi3gen.go +++ b/openapi3gen/openapi3gen.go @@ -1,94 +1,183 @@ -// Package openapi3gen generates OpenAPI 3 schemas for Go types. +// Package openapi3gen generates OpenAPIv3 JSON schemas from Go types. package openapi3gen import ( "encoding/json" + "fmt" + "math" "reflect" "strings" "time" - "github.com/getkin/kin-openapi/jsoninfo" "github.com/getkin/kin-openapi/openapi3" ) // CycleError indicates that a type graph has one or more possible cycles. type CycleError struct{} -func (err *CycleError) Error() string { - return "Detected JSON cycle" +func (err *CycleError) Error() string { return "detected cycle" } + +// ExcludeSchemaSentinel indicates that the schema for a specific field should not be included in the final output. +type ExcludeSchemaSentinel struct{} + +func (err *ExcludeSchemaSentinel) Error() string { return "schema excluded" } + +// Option allows tweaking SchemaRef generation +type Option func(*generatorOpt) + +// SchemaCustomizerFn is a callback function, allowing +// the OpenAPI schema definition to be updated with additional +// properties during the generation process, based on the +// name of the field, the Go type, and the struct tags. +// name will be "_root" for the top level object, and tag will be "". +// A SchemaCustomizerFn can return an ExcludeSchemaSentinel error to +// indicate that the schema for this field should not be included in +// the final output +type SchemaCustomizerFn func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error + +type generatorOpt struct { + useAllExportedFields bool + throwErrorOnCycle bool + schemaCustomizer SchemaCustomizerFn } -func NewSchemaRefForValue(value interface{}) (*openapi3.SchemaRef, map[*openapi3.SchemaRef]int, error) { - g := NewGenerator() - ref, err := g.GenerateSchemaRef(reflect.TypeOf(value)) - for ref := range g.SchemaRefs { - ref.Ref = "" - } - return ref, g.SchemaRefs, err +// UseAllExportedFields changes the default behavior of only +// generating schemas for struct fields with a JSON tag. +func UseAllExportedFields() Option { + return func(x *generatorOpt) { x.useAllExportedFields = true } +} + +// ThrowErrorOnCycle changes the default behavior of creating cycle +// refs to instead error if a cycle is detected. +func ThrowErrorOnCycle() Option { + return func(x *generatorOpt) { x.throwErrorOnCycle = true } +} + +// SchemaCustomizer allows customization of the schema that is generated +// for a field, for example to support an additional tagging scheme +func SchemaCustomizer(sc SchemaCustomizerFn) Option { + return func(x *generatorOpt) { x.schemaCustomizer = sc } +} + +// NewSchemaRefForValue is a shortcut for NewGenerator(...).NewSchemaRefForValue(...) +func NewSchemaRefForValue(value interface{}, schemas openapi3.Schemas, opts ...Option) (*openapi3.SchemaRef, error) { + g := NewGenerator(opts...) + return g.NewSchemaRefForValue(value, schemas) } type Generator struct { + opts generatorOpt + Types map[reflect.Type]*openapi3.SchemaRef // SchemaRefs contains all references and their counts. // If count is 1, it's not ne // An OpenAPI identifier has been assigned to each. SchemaRefs map[*openapi3.SchemaRef]int + + // componentSchemaRefs is a set of schemas that must be defined in the components to avoid cycles + componentSchemaRefs map[string]struct{} } -func NewGenerator() *Generator { +func NewGenerator(opts ...Option) *Generator { + gOpt := &generatorOpt{} + for _, f := range opts { + f(gOpt) + } return &Generator{ - Types: make(map[reflect.Type]*openapi3.SchemaRef), - SchemaRefs: make(map[*openapi3.SchemaRef]int), + Types: make(map[reflect.Type]*openapi3.SchemaRef), + SchemaRefs: make(map[*openapi3.SchemaRef]int), + componentSchemaRefs: make(map[string]struct{}), + opts: *gOpt, } } func (g *Generator) GenerateSchemaRef(t reflect.Type) (*openapi3.SchemaRef, error) { - return g.generateSchemaRefFor(nil, t) + //check generatorOpt consistency here + return g.generateSchemaRefFor(nil, t, "_root", "") } -func (g *Generator) generateSchemaRefFor(parents []*jsoninfo.TypeInfo, t reflect.Type) (*openapi3.SchemaRef, error) { - ref := g.Types[t] - if ref != nil { +// NewSchemaRefForValue uses reflection on the given value to produce a SchemaRef, and updates a supplied map with any dependent component schemas if they lead to cycles +func (g *Generator) NewSchemaRefForValue(value interface{}, schemas openapi3.Schemas) (*openapi3.SchemaRef, error) { + ref, err := g.GenerateSchemaRef(reflect.TypeOf(value)) + if err != nil { + return nil, err + } + for ref := range g.SchemaRefs { + if _, ok := g.componentSchemaRefs[ref.Ref]; ok && schemas != nil { + schemas[ref.Ref] = &openapi3.SchemaRef{ + Value: ref.Value, + } + } + if strings.HasPrefix(ref.Ref, "#/components/schemas/") { + ref.Value = nil + } else { + ref.Ref = "" + } + } + return ref, nil +} + +func (g *Generator) generateSchemaRefFor(parents []*theTypeInfo, t reflect.Type, name string, tag reflect.StructTag) (*openapi3.SchemaRef, error) { + if ref := g.Types[t]; ref != nil && g.opts.schemaCustomizer == nil { g.SchemaRefs[ref]++ return ref, nil } - ref, err := g.generateWithoutSaving(parents, t) + ref, err := g.generateWithoutSaving(parents, t, name, tag) + if _, ok := err.(*ExcludeSchemaSentinel); ok { + // This schema should not be included in the final output + return nil, nil + } + if err != nil { + return nil, err + } if ref != nil { g.Types[t] = ref g.SchemaRefs[ref]++ } - return ref, err + return ref, nil } -func (g *Generator) generateWithoutSaving(parents []*jsoninfo.TypeInfo, t reflect.Type) (*openapi3.SchemaRef, error) { - // Get TypeInfo - typeInfo := jsoninfo.GetTypeInfo(t) +func getStructField(t reflect.Type, fieldInfo theFieldInfo) reflect.StructField { + var ff reflect.StructField + // fieldInfo.Index is an array of indexes starting from the root of the type + for i := 0; i < len(fieldInfo.Index); i++ { + ff = t.Field(fieldInfo.Index[i]) + t = ff.Type + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + } + return ff +} + +func (g *Generator) generateWithoutSaving(parents []*theTypeInfo, t reflect.Type, name string, tag reflect.StructTag) (*openapi3.SchemaRef, error) { + typeInfo := getTypeInfo(t) for _, parent := range parents { if parent == typeInfo { return nil, &CycleError{} } } - // Doesn't exist. - // Create the schema. if cap(parents) == 0 { - parents = make([]*jsoninfo.TypeInfo, 0, 4) + parents = make([]*theTypeInfo, 0, 4) } parents = append(parents, typeInfo) - // Ignore pointers for t.Kind() == reflect.Ptr { t = t.Elem() } - // Create instance if strings.HasSuffix(t.Name(), "Ref") { _, a := t.FieldByName("Ref") v, b := t.FieldByName("Value") if a && b { - vs, err := g.generateSchemaRefFor(parents, v.Type) + vs, err := g.generateSchemaRefFor(parents, v.Type, name, tag) if err != nil { + if _, ok := err.(*CycleError); ok && !g.opts.throwErrorOnCycle { + g.SchemaRefs[vs]++ + return vs, nil + } return nil, err } refSchemaRef := RefSchemaRef @@ -104,23 +193,57 @@ func (g *Generator) generateWithoutSaving(parents []*jsoninfo.TypeInfo, t reflec } } - // Allocate schema schema := &openapi3.Schema{} switch t.Kind() { case reflect.Func, reflect.Chan: - return nil, nil + return nil, nil // ignore + case reflect.Bool: schema.Type = "boolean" - case reflect.Int, - reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + case reflect.Int: + schema.Type = "integer" + case reflect.Int8: + schema.Type = "integer" + schema.Min = &minInt8 + schema.Max = &maxInt8 + case reflect.Int16: + schema.Type = "integer" + schema.Min = &minInt16 + schema.Max = &maxInt16 + case reflect.Int32: + schema.Type = "integer" + schema.Format = "int32" + case reflect.Int64: schema.Type = "integer" schema.Format = "int64" + case reflect.Uint: + schema.Type = "integer" + schema.Min = &zeroInt + case reflect.Uint8: + schema.Type = "integer" + schema.Min = &zeroInt + schema.Max = &maxUint8 + case reflect.Uint16: + schema.Type = "integer" + schema.Min = &zeroInt + schema.Max = &maxUint16 + case reflect.Uint32: + schema.Type = "integer" + schema.Min = &zeroInt + schema.Max = &maxUint32 + case reflect.Uint64: + schema.Type = "integer" + schema.Min = &zeroInt + schema.Max = &maxUint64 - case reflect.Float32, reflect.Float64: + case reflect.Float32: + schema.Type = "number" + schema.Format = "float" + case reflect.Float64: schema.Type = "number" + schema.Format = "double" case reflect.String: schema.Type = "string" @@ -128,17 +251,19 @@ func (g *Generator) generateWithoutSaving(parents []*jsoninfo.TypeInfo, t reflec case reflect.Slice: if t.Elem().Kind() == reflect.Uint8 { if t == rawMessageType { - return &openapi3.SchemaRef{ - Value: schema, - }, nil + return &openapi3.SchemaRef{Value: schema}, nil } schema.Type = "string" schema.Format = "byte" } else { schema.Type = "array" - items, err := g.generateSchemaRefFor(parents, t.Elem()) + items, err := g.generateSchemaRefFor(parents, t.Elem(), name, tag) if err != nil { - return nil, err + if _, ok := err.(*CycleError); ok && !g.opts.throwErrorOnCycle { + items = g.generateCycleSchemaRef(t.Elem(), schema) + } else { + return nil, err + } } if items != nil { g.SchemaRefs[items]++ @@ -148,13 +273,17 @@ func (g *Generator) generateWithoutSaving(parents []*jsoninfo.TypeInfo, t reflec case reflect.Map: schema.Type = "object" - additionalProperties, err := g.generateSchemaRefFor(parents, t.Elem()) + additionalProperties, err := g.generateSchemaRefFor(parents, t.Elem(), name, tag) if err != nil { - return nil, err + if _, ok := err.(*CycleError); ok && !g.opts.throwErrorOnCycle { + additionalProperties = g.generateCycleSchemaRef(t.Elem(), schema) + } else { + return nil, err + } } if additionalProperties != nil { g.SchemaRefs[additionalProperties]++ - schema.AdditionalProperties = additionalProperties + schema.AdditionalProperties = openapi3.AdditionalProperties{Schema: additionalProperties} } case reflect.Struct: @@ -163,17 +292,53 @@ func (g *Generator) generateWithoutSaving(parents []*jsoninfo.TypeInfo, t reflec schema.Format = "date-time" } else { for _, fieldInfo := range typeInfo.Fields { - // Only fields with JSON tag are considered - if !fieldInfo.HasJSONTag { + // Only fields with JSON tag are considered (by default) + if !fieldInfo.HasJSONTag && !g.opts.useAllExportedFields { continue } - ref, err := g.generateSchemaRefFor(parents, fieldInfo.Type) + // If asked, try to use yaml tag + fieldName, fType := fieldInfo.JSONName, fieldInfo.Type + if !fieldInfo.HasJSONTag && g.opts.useAllExportedFields { + // Handle anonymous fields/embedded structs + if t.Field(fieldInfo.Index[0]).Anonymous { + ref, err := g.generateSchemaRefFor(parents, fType, fieldName, tag) + if err != nil { + if _, ok := err.(*CycleError); ok && !g.opts.throwErrorOnCycle { + ref = g.generateCycleSchemaRef(fType, schema) + } else { + return nil, err + } + } + if ref != nil { + g.SchemaRefs[ref]++ + schema.WithPropertyRef(fieldName, ref) + } + } else { + ff := getStructField(t, fieldInfo) + if tag, ok := ff.Tag.Lookup("yaml"); ok && tag != "-" { + fieldName, fType = tag, ff.Type + } + } + } + + // extract the field tag if we have a customizer + var fieldTag reflect.StructTag + if g.opts.schemaCustomizer != nil { + ff := getStructField(t, fieldInfo) + fieldTag = ff.Tag + } + + ref, err := g.generateSchemaRefFor(parents, fType, fieldName, fieldTag) if err != nil { - return nil, err + if _, ok := err.(*CycleError); ok && !g.opts.throwErrorOnCycle { + ref = g.generateCycleSchemaRef(fType, schema) + } else { + return nil, err + } } if ref != nil { g.SchemaRefs[ref]++ - schema.WithPropertyRef(fieldInfo.JSONName, ref) + schema.WithPropertyRef(fieldName, ref) } } @@ -183,13 +348,55 @@ func (g *Generator) generateWithoutSaving(parents []*jsoninfo.TypeInfo, t reflec } } } + + if g.opts.schemaCustomizer != nil { + if err := g.opts.schemaCustomizer(name, t, tag, schema); err != nil { + return nil, err + } + } + return openapi3.NewSchemaRef(t.Name(), schema), nil } +func (g *Generator) generateCycleSchemaRef(t reflect.Type, schema *openapi3.Schema) *openapi3.SchemaRef { + var typeName string + switch t.Kind() { + case reflect.Ptr: + return g.generateCycleSchemaRef(t.Elem(), schema) + case reflect.Slice: + ref := g.generateCycleSchemaRef(t.Elem(), schema) + sliceSchema := openapi3.NewSchema() + sliceSchema.Type = "array" + sliceSchema.Items = ref + return openapi3.NewSchemaRef("", sliceSchema) + case reflect.Map: + ref := g.generateCycleSchemaRef(t.Elem(), schema) + mapSchema := openapi3.NewSchema() + mapSchema.Type = "object" + mapSchema.AdditionalProperties = openapi3.AdditionalProperties{Schema: ref} + return openapi3.NewSchemaRef("", mapSchema) + default: + typeName = t.Name() + } + + g.componentSchemaRefs[typeName] = struct{}{} + return openapi3.NewSchemaRef(fmt.Sprintf("#/components/schemas/%s", typeName), schema) +} + var RefSchemaRef = openapi3.NewSchemaRef("Ref", openapi3.NewObjectSchema().WithProperty("$ref", openapi3.NewStringSchema().WithMinLength(1))) var ( timeType = reflect.TypeOf(time.Time{}) rawMessageType = reflect.TypeOf(json.RawMessage{}) + + zeroInt = float64(0) + maxInt8 = float64(math.MaxInt8) + minInt8 = float64(math.MinInt8) + maxInt16 = float64(math.MaxInt16) + minInt16 = float64(math.MinInt16) + maxUint8 = float64(math.MaxUint8) + maxUint16 = float64(math.MaxUint16) + maxUint32 = float64(math.MaxUint32) + maxUint64 = float64(math.MaxUint64) ) diff --git a/openapi3gen/openapi3gen_test.go b/openapi3gen/openapi3gen_test.go index d94cfce9f..9a143e415 100644 --- a/openapi3gen/openapi3gen_test.go +++ b/openapi3gen/openapi3gen_test.go @@ -2,111 +2,582 @@ package openapi3gen_test import ( "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + "strings" "testing" "time" - "github.com/getkin/kin-openapi/openapi3gen" "github.com/stretchr/testify/require" -) -type CyclicType0 struct { - CyclicField *CyclicType1 `json:"a"` -} -type CyclicType1 struct { - CyclicField *CyclicType0 `json:"b"` -} + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3gen" +) -func TestCyclic(t *testing.T) { - schema, refsMap, err := openapi3gen.NewSchemaRefForValue(&CyclicType0{}) - require.IsType(t, &openapi3gen.CycleError{}, err) - require.Nil(t, schema) - require.Empty(t, refsMap) -} +func ExampleGenerator_SchemaRefs() { + type SomeOtherType string + type Embedded struct { + Z string `json:"z"` + } + type Embedded2 struct { + A string `json:"a"` + } + type SomeStruct struct { + Bool bool `json:"bool"` + Int int `json:"int"` + Int64 int64 `json:"int64"` + Float64 float64 `json:"float64"` + String string `json:"string"` + Bytes []byte `json:"bytes"` + JSON json.RawMessage `json:"json"` + Time time.Time `json:"time"` + Slice []SomeOtherType `json:"slice"` + Map map[string]*SomeOtherType `json:"map"` -func TestSimple(t *testing.T) { - type ExampleChild string - type Example struct { - Bool bool `json:"bool"` - Int int `json:"int"` - Int64 int64 `json:"int64"` - Float64 float64 `json:"float64"` - String string `json:"string"` - Bytes []byte `json:"bytes"` - JSON json.RawMessage `json:"json"` - Time time.Time `json:"time"` - Slice []*ExampleChild `json:"slice"` - Map map[string]*ExampleChild `json:"map"` - Struct struct { + Struct struct { X string `json:"x"` } `json:"struct"` + EmptyStruct struct { - X string + Y string } `json:"structWithoutFields"` - Ptr *ExampleChild `json:"ptr"` + + Embedded `json:"embedded"` + + Embedded2 + + Ptr *SomeOtherType `json:"ptr"` + } + + g := openapi3gen.NewGenerator() + schemaRef, err := g.NewSchemaRefForValue(&SomeStruct{}, nil) + if err != nil { + panic(err) + } + + fmt.Printf("g.SchemaRefs: %d\n", len(g.SchemaRefs)) + var data []byte + if data, err = json.MarshalIndent(&schemaRef, "", " "); err != nil { + panic(err) + } + fmt.Printf("schemaRef: %s\n", data) + // Output: + // g.SchemaRefs: 16 + // schemaRef: { + // "properties": { + // "a": { + // "type": "string" + // }, + // "bool": { + // "type": "boolean" + // }, + // "bytes": { + // "format": "byte", + // "type": "string" + // }, + // "embedded": { + // "properties": { + // "z": { + // "type": "string" + // } + // }, + // "type": "object" + // }, + // "float64": { + // "format": "double", + // "type": "number" + // }, + // "int": { + // "type": "integer" + // }, + // "int64": { + // "format": "int64", + // "type": "integer" + // }, + // "json": {}, + // "map": { + // "additionalProperties": { + // "type": "string" + // }, + // "type": "object" + // }, + // "ptr": { + // "type": "string" + // }, + // "slice": { + // "items": { + // "type": "string" + // }, + // "type": "array" + // }, + // "string": { + // "type": "string" + // }, + // "struct": { + // "properties": { + // "x": { + // "type": "string" + // } + // }, + // "type": "object" + // }, + // "structWithoutFields": {}, + // "time": { + // "format": "date-time", + // "type": "string" + // } + // }, + // "type": "object" + // } +} + +func ExampleThrowErrorOnCycle() { + type CyclicType0 struct { + CyclicField *struct { + CyclicField *CyclicType0 `json:"b"` + } `json:"a"` + } + + schemas := make(openapi3.Schemas) + schemaRef, err := openapi3gen.NewSchemaRefForValue(&CyclicType0{}, schemas, openapi3gen.ThrowErrorOnCycle()) + if schemaRef != nil || err == nil { + panic(`With option ThrowErrorOnCycle, an error is returned when a schema reference cycle is found`) + } + if _, ok := err.(*openapi3gen.CycleError); !ok { + panic(`With option ThrowErrorOnCycle, an error of type CycleError is returned`) + } + if len(schemas) != 0 { + panic(`No references should have been collected at this point`) + } + + if schemaRef, err = openapi3gen.NewSchemaRefForValue(&CyclicType0{}, schemas); err != nil { + panic(err) + } + + var data []byte + if data, err = json.MarshalIndent(schemaRef, "", " "); err != nil { + panic(err) + } + fmt.Printf("schemaRef: %s\n", data) + if data, err = json.MarshalIndent(schemas, "", " "); err != nil { + panic(err) + } + fmt.Printf("schemas: %s\n", data) + // Output: + // schemaRef: { + // "properties": { + // "a": { + // "properties": { + // "b": { + // "$ref": "#/components/schemas/CyclicType0" + // } + // }, + // "type": "object" + // } + // }, + // "type": "object" + // } + // schemas: { + // "CyclicType0": { + // "properties": { + // "a": { + // "properties": { + // "b": { + // "$ref": "#/components/schemas/CyclicType0" + // } + // }, + // "type": "object" + // } + // }, + // "type": "object" + // } + // } +} + +func TestExportedNonTagged(t *testing.T) { + type Bla struct { + A string + Another string `json:"another"` + yetAnother string // unused because unexported + EvenAYaml string `yaml:"even_a_yaml"` + } + + schemaRef, err := openapi3gen.NewSchemaRefForValue(&Bla{}, nil, openapi3gen.UseAllExportedFields()) + require.NoError(t, err) + require.Equal(t, &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: "object", + Properties: map[string]*openapi3.SchemaRef{ + "A": {Value: &openapi3.Schema{Type: "string"}}, + "another": {Value: &openapi3.Schema{Type: "string"}}, + "even_a_yaml": {Value: &openapi3.Schema{Type: "string"}}, + }}}, schemaRef) +} + +func ExampleUseAllExportedFields() { + type UnsignedIntStruct struct { + UnsignedInt uint `json:"uint"` + } + + schemaRef, err := openapi3gen.NewSchemaRefForValue(&UnsignedIntStruct{}, nil, openapi3gen.UseAllExportedFields()) + if err != nil { + panic(err) + } + + var data []byte + if data, err = json.MarshalIndent(schemaRef, "", " "); err != nil { + panic(err) + } + fmt.Printf("schemaRef: %s\n", data) + // Output: + // schemaRef: { + // "properties": { + // "uint": { + // "minimum": 0, + // "type": "integer" + // } + // }, + // "type": "object" + // } +} + +func ExampleGenerator_GenerateSchemaRef() { + type EmbeddedStruct struct { + ID string + } + + type ContainerStruct struct { + Name string + EmbeddedStruct + } + + instance := &ContainerStruct{ + Name: "Container", + EmbeddedStruct: EmbeddedStruct{ + ID: "Embedded", + }, + } + + generator := openapi3gen.NewGenerator(openapi3gen.UseAllExportedFields()) + + schemaRef, err := generator.GenerateSchemaRef(reflect.TypeOf(instance)) + if err != nil { + panic(err) + } + + var data []byte + if data, err = json.MarshalIndent(schemaRef.Value.Properties["Name"].Value, "", " "); err != nil { + panic(err) + } + fmt.Printf(`schemaRef.Value.Properties["Name"].Value: %s`, data) + fmt.Println() + if data, err = json.MarshalIndent(schemaRef.Value.Properties["ID"].Value, "", " "); err != nil { + panic(err) + } + fmt.Printf(`schemaRef.Value.Properties["ID"].Value: %s`, data) + fmt.Println() + // Output: + // schemaRef.Value.Properties["Name"].Value: { + // "type": "string" + // } + // schemaRef.Value.Properties["ID"].Value: { + // "type": "string" + // } +} + +func TestEmbeddedPointerStructs(t *testing.T) { + type EmbeddedStruct struct { + ID string + } + + type ContainerStruct struct { + Name string + *EmbeddedStruct + } + + instance := &ContainerStruct{ + Name: "Container", + EmbeddedStruct: &EmbeddedStruct{ + ID: "Embedded", + }, + } + + generator := openapi3gen.NewGenerator(openapi3gen.UseAllExportedFields()) + + schemaRef, err := generator.GenerateSchemaRef(reflect.TypeOf(instance)) + require.NoError(t, err) + + var ok bool + _, ok = schemaRef.Value.Properties["Name"] + require.Equal(t, true, ok) + + _, ok = schemaRef.Value.Properties["ID"] + require.Equal(t, true, ok) +} + +// See: https://github.com/getkin/kin-openapi/issues/500 +func TestEmbeddedPointerStructsWithSchemaCustomizer(t *testing.T) { + type EmbeddedStruct struct { + ID string + } + + type ContainerStruct struct { + Name string + *EmbeddedStruct + } + + instance := &ContainerStruct{ + Name: "Container", + EmbeddedStruct: &EmbeddedStruct{ + ID: "Embedded", + }, } - schema, refsMap, err := openapi3gen.NewSchemaRefForValue(&Example{}) + customizerFn := func(name string, t reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error { + return nil + } + customizerOpt := openapi3gen.SchemaCustomizer(customizerFn) + + generator := openapi3gen.NewGenerator(openapi3gen.UseAllExportedFields(), customizerOpt) + + schemaRef, err := generator.GenerateSchemaRef(reflect.TypeOf(instance)) require.NoError(t, err) - require.Len(t, refsMap, 14) - data, err := json.Marshal(schema) + + var ok bool + _, ok = schemaRef.Value.Properties["Name"] + require.Equal(t, true, ok) + + _, ok = schemaRef.Value.Properties["ID"] + require.Equal(t, true, ok) +} + +func TestCyclicReferences(t *testing.T) { + type ObjectDiff struct { + FieldCycle *ObjectDiff + SliceCycle []*ObjectDiff + MapCycle map[*ObjectDiff]*ObjectDiff + } + + instance := &ObjectDiff{ + FieldCycle: nil, + SliceCycle: nil, + MapCycle: nil, + } + + generator := openapi3gen.NewGenerator(openapi3gen.UseAllExportedFields()) + + schemaRef, err := generator.GenerateSchemaRef(reflect.TypeOf(instance)) require.NoError(t, err) - require.JSONEq(t, expectedSimple, string(data)) + + require.NotNil(t, schemaRef.Value.Properties["FieldCycle"]) + require.Equal(t, "#/components/schemas/ObjectDiff", schemaRef.Value.Properties["FieldCycle"].Ref) + + require.NotNil(t, schemaRef.Value.Properties["SliceCycle"]) + require.Equal(t, "array", schemaRef.Value.Properties["SliceCycle"].Value.Type) + require.Equal(t, "#/components/schemas/ObjectDiff", schemaRef.Value.Properties["SliceCycle"].Value.Items.Ref) + + require.NotNil(t, schemaRef.Value.Properties["MapCycle"]) + require.Equal(t, "object", schemaRef.Value.Properties["MapCycle"].Value.Type) + require.Equal(t, "#/components/schemas/ObjectDiff", schemaRef.Value.Properties["MapCycle"].Value.AdditionalProperties.Schema.Ref) } -const expectedSimple = ` -{ - "type": "object", - "properties": { - "bool": { - "type": "boolean" - }, - "int": { - "type": "integer", - "format": "int64" - }, - "int64": { - "type": "integer", - "format": "int64" - }, - "float64": { - "type": "number" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "string": { - "type": "string" - }, - "bytes": { - "type": "string", - "format": "byte" - }, - "json": {}, - "slice": { - "type": "array", - "items": { - "type": "string" - } - }, - "map": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "struct": { - "type": "object", - "properties": { - "x": { - "type": "string" - } - } - }, - "structWithoutFields": {}, - "ptr": { - "type": "string" - } - } +func ExampleSchemaCustomizer() { + type NestedInnerBla struct { + Enum1Field string `json:"enum1" myenumtag:"a,b"` + } + + type InnerBla struct { + UntaggedStringField string + AnonStruct struct { + InnerFieldWithoutTag int + InnerFieldWithTag int `mymintag:"-1" mymaxtag:"50"` + NestedInnerBla + } + Enum2Field string `json:"enum2" myenumtag:"c,d"` + } + + type Bla struct { + InnerBla + EnumField3 string `json:"enum3" myenumtag:"e,f"` + } + + customizer := openapi3gen.SchemaCustomizer(func(name string, ft reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error { + if tag.Get("mymintag") != "" { + minVal, err := strconv.ParseFloat(tag.Get("mymintag"), 64) + if err != nil { + return err + } + schema.Min = &minVal + } + if tag.Get("mymaxtag") != "" { + maxVal, err := strconv.ParseFloat(tag.Get("mymaxtag"), 64) + if err != nil { + return err + } + schema.Max = &maxVal + } + if tag.Get("myenumtag") != "" { + for _, s := range strings.Split(tag.Get("myenumtag"), ",") { + schema.Enum = append(schema.Enum, s) + } + } + return nil + }) + + schemaRef, err := openapi3gen.NewSchemaRefForValue(&Bla{}, nil, openapi3gen.UseAllExportedFields(), customizer) + if err != nil { + panic(err) + } + + var data []byte + if data, err = json.MarshalIndent(schemaRef, "", " "); err != nil { + panic(err) + } + fmt.Printf("schemaRef: %s\n", data) + // Output: + // schemaRef: { + // "properties": { + // "AnonStruct": { + // "properties": { + // "InnerFieldWithTag": { + // "maximum": 50, + // "minimum": -1, + // "type": "integer" + // }, + // "InnerFieldWithoutTag": { + // "type": "integer" + // }, + // "enum1": { + // "enum": [ + // "a", + // "b" + // ], + // "type": "string" + // } + // }, + // "type": "object" + // }, + // "UntaggedStringField": { + // "type": "string" + // }, + // "enum2": { + // "enum": [ + // "c", + // "d" + // ], + // "type": "string" + // }, + // "enum3": { + // "enum": [ + // "e", + // "f" + // ], + // "type": "string" + // } + // }, + // "type": "object" + // } +} + +func TestSchemaCustomizerError(t *testing.T) { + customizer := openapi3gen.SchemaCustomizer(func(name string, ft reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error { + return errors.New("test error") + }) + + type Bla struct{} + _, err := openapi3gen.NewSchemaRefForValue(&Bla{}, nil, openapi3gen.UseAllExportedFields(), customizer) + require.EqualError(t, err, "test error") +} + +func TestSchemaCustomizerExcludeSchema(t *testing.T) { + type Bla struct { + Str string + } + + customizer := openapi3gen.SchemaCustomizer(func(name string, ft reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error { + return nil + }) + schema, err := openapi3gen.NewSchemaRefForValue(&Bla{}, nil, openapi3gen.UseAllExportedFields(), customizer) + require.NoError(t, err) + require.Equal(t, &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: "object", + Properties: map[string]*openapi3.SchemaRef{ + "Str": {Value: &openapi3.Schema{Type: "string"}}, + }}}, schema) + + customizer = openapi3gen.SchemaCustomizer(func(name string, ft reflect.Type, tag reflect.StructTag, schema *openapi3.Schema) error { + return &openapi3gen.ExcludeSchemaSentinel{} + }) + schema, err = openapi3gen.NewSchemaRefForValue(&Bla{}, nil, openapi3gen.UseAllExportedFields(), customizer) + require.NoError(t, err) + require.Nil(t, schema) +} + +func ExampleNewSchemaRefForValue_recursive() { + type RecursiveType struct { + Field1 string `json:"field1"` + Field2 string `json:"field2"` + Field3 string `json:"field3"` + Components []*RecursiveType `json:"children,omitempty"` + } + + schemas := make(openapi3.Schemas) + schemaRef, err := openapi3gen.NewSchemaRefForValue(&RecursiveType{}, schemas) + if err != nil { + panic(err) + } + + var data []byte + if data, err = json.MarshalIndent(&schemas, "", " "); err != nil { + panic(err) + } + fmt.Printf("schemas: %s\n", data) + if data, err = json.MarshalIndent(&schemaRef, "", " "); err != nil { + panic(err) + } + fmt.Printf("schemaRef: %s\n", data) + // Output: + // schemas: { + // "RecursiveType": { + // "properties": { + // "children": { + // "items": { + // "$ref": "#/components/schemas/RecursiveType" + // }, + // "type": "array" + // }, + // "field1": { + // "type": "string" + // }, + // "field2": { + // "type": "string" + // }, + // "field3": { + // "type": "string" + // } + // }, + // "type": "object" + // } + // } + // schemaRef: { + // "properties": { + // "children": { + // "items": { + // "$ref": "#/components/schemas/RecursiveType" + // }, + // "type": "array" + // }, + // "field1": { + // "type": "string" + // }, + // "field2": { + // "type": "string" + // }, + // "field3": { + // "type": "string" + // } + // }, + // "type": "object" + // } } -` diff --git a/openapi3gen/simple_test.go b/openapi3gen/simple_test.go new file mode 100644 index 000000000..99e94ae12 --- /dev/null +++ b/openapi3gen/simple_test.go @@ -0,0 +1,105 @@ +package openapi3gen_test + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/getkin/kin-openapi/openapi3gen" +) + +type ( + SomeStruct struct { + Bool bool `json:"bool"` + Int int `json:"int"` + Int64 int64 `json:"int64"` + Float64 float64 `json:"float64"` + String string `json:"string"` + Bytes []byte `json:"bytes"` + JSON json.RawMessage `json:"json"` + Time time.Time `json:"time"` + Slice []SomeOtherType `json:"slice"` + Map map[string]*SomeOtherType `json:"map"` + + Struct struct { + X string `json:"x"` + } `json:"struct"` + + EmptyStruct struct { + Y string + } `json:"structWithoutFields"` + + Ptr *SomeOtherType `json:"ptr"` + } + + SomeOtherType string +) + +func Example() { + schemaRef, err := openapi3gen.NewSchemaRefForValue(&SomeStruct{}, nil) + if err != nil { + panic(err) + } + + data, err := json.MarshalIndent(schemaRef, "", " ") + if err != nil { + panic(err) + } + fmt.Printf("%s\n", data) + // Output: + // { + // "properties": { + // "bool": { + // "type": "boolean" + // }, + // "bytes": { + // "format": "byte", + // "type": "string" + // }, + // "float64": { + // "format": "double", + // "type": "number" + // }, + // "int": { + // "type": "integer" + // }, + // "int64": { + // "format": "int64", + // "type": "integer" + // }, + // "json": {}, + // "map": { + // "additionalProperties": { + // "type": "string" + // }, + // "type": "object" + // }, + // "ptr": { + // "type": "string" + // }, + // "slice": { + // "items": { + // "type": "string" + // }, + // "type": "array" + // }, + // "string": { + // "type": "string" + // }, + // "struct": { + // "properties": { + // "x": { + // "type": "string" + // } + // }, + // "type": "object" + // }, + // "structWithoutFields": {}, + // "time": { + // "format": "date-time", + // "type": "string" + // } + // }, + // "type": "object" + // } +} diff --git a/openapi3gen/type_info.go b/openapi3gen/type_info.go new file mode 100644 index 000000000..062882b4c --- /dev/null +++ b/openapi3gen/type_info.go @@ -0,0 +1,54 @@ +package openapi3gen + +import ( + "reflect" + "sort" + "sync" +) + +var ( + typeInfos = map[reflect.Type]*theTypeInfo{} + typeInfosMutex sync.RWMutex +) + +// theTypeInfo contains information about JSON serialization of a type +type theTypeInfo struct { + Type reflect.Type + Fields []theFieldInfo +} + +// getTypeInfo returns theTypeInfo for the given type. +func getTypeInfo(t reflect.Type) *theTypeInfo { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + typeInfosMutex.RLock() + typeInfo, exists := typeInfos[t] + typeInfosMutex.RUnlock() + if exists { + return typeInfo + } + if t.Kind() != reflect.Struct { + typeInfo = &theTypeInfo{ + Type: t, + } + } else { + // Allocate + typeInfo = &theTypeInfo{ + Type: t, + Fields: make([]theFieldInfo, 0, 16), + } + + // Add fields + typeInfo.Fields = appendFields(nil, nil, t) + + // Sort fields + sort.Sort(sortableFieldInfos(typeInfo.Fields)) + } + + // Publish + typeInfosMutex.Lock() + typeInfos[t] = typeInfo + typeInfosMutex.Unlock() + return typeInfo +} diff --git a/refs.sh b/refs.sh new file mode 100755 index 000000000..9ade24196 --- /dev/null +++ b/refs.sh @@ -0,0 +1,124 @@ +#!/bin/bash -eux +set -o pipefail + +types=() +types+=("Callback") +types+=("Example") +types+=("Header") +types+=("Link") +types+=("Parameter") +types+=("RequestBody") +types+=("Response") +types+=("Schema") +types+=("SecurityScheme") + +cat < 0 { + if servers, err = makeServers(pathItem.Servers); err != nil { + return nil, err + } + } + + operations := pathItem.Operations() + methods := make([]string, 0, len(operations)) + for method := range operations { + methods = append(methods, method) + } + sort.Strings(methods) + + for _, s := range servers { + muxRoute := muxRouter.Path(s.base + path).Methods(methods...) + if schemes := s.schemes; len(schemes) != 0 { + muxRoute.Schemes(schemes...) + } + if host := s.host; host != "" { + muxRoute.Host(host) + } + if err := muxRoute.GetError(); err != nil { + return nil, err + } + r.muxes = append(r.muxes, routeMux{ + muxRoute: muxRoute, + varsUpdater: s.varsUpdater, + }) + r.routes = append(r.routes, &routers.Route{ + Spec: doc, + Server: s.server, + Path: path, + PathItem: pathItem, + Method: "", + Operation: nil, + }) + } + } + return r, nil +} + +// FindRoute extracts the route and parameters of an http.Request +func (r *Router) FindRoute(req *http.Request) (*routers.Route, map[string]string, error) { + for i, m := range r.muxes { + var match mux.RouteMatch + if m.muxRoute.Match(req, &match) { + if err := match.MatchErr; err != nil { + // What then? + } + vars := match.Vars + if f := m.varsUpdater; f != nil { + f(vars) + } + route := *r.routes[i] + route.Method = req.Method + route.Operation = route.Spec.Paths[route.Path].GetOperation(route.Method) + return &route, vars, nil + } + switch match.MatchErr { + case nil: + case mux.ErrMethodMismatch: + return nil, nil, routers.ErrMethodNotAllowed + default: // What then? + } + } + return nil, nil, routers.ErrPathNotFound +} + +func makeServers(in openapi3.Servers) ([]srv, error) { + servers := make([]srv, 0, len(in)) + for _, server := range in { + serverURL := server.URL + if submatch := singleVariableMatcher.FindStringSubmatch(serverURL); submatch != nil { + sVar := submatch[1] + sVal := server.Variables[sVar].Default + serverURL = strings.ReplaceAll(serverURL, "{"+sVar+"}", sVal) + var varsUpdater varsf + if lhs := strings.TrimSuffix(serverURL, server.Variables[sVar].Default); lhs != "" { + varsUpdater = func(vars map[string]string) { vars[sVar] = lhs } + } + svr, err := newSrv(serverURL, server, varsUpdater) + if err != nil { + return nil, err + } + + servers = append(servers, svr) + continue + } + + // If a variable represents the port "http://domain.tld:{port}/bla" + // then url.Parse() cannot parse "http://domain.tld:`bEncode({port})`/bla" + // and mux is not able to set the {port} variable + // So we just use the default value for this variable. + // See https://github.com/getkin/kin-openapi/issues/367 + var varsUpdater varsf + if lhs := strings.Index(serverURL, ":{"); lhs > 0 { + rest := serverURL[lhs+len(":{"):] + rhs := strings.Index(rest, "}") + portVariable := rest[:rhs] + portValue := server.Variables[portVariable].Default + serverURL = strings.ReplaceAll(serverURL, "{"+portVariable+"}", portValue) + varsUpdater = func(vars map[string]string) { + vars[portVariable] = portValue + } + } + + svr, err := newSrv(serverURL, server, varsUpdater) + if err != nil { + return nil, err + } + servers = append(servers, svr) + } + if len(servers) == 0 { + servers = append(servers, srv{}) + } + + return servers, nil +} + +func newSrv(serverURL string, server *openapi3.Server, varsUpdater varsf) (srv, error) { + var schemes []string + if strings.Contains(serverURL, "://") { + scheme0 := strings.Split(serverURL, "://")[0] + schemes = permutePart(scheme0, server) + serverURL = strings.Replace(serverURL, scheme0+"://", schemes[0]+"://", 1) + } + + u, err := url.Parse(bEncode(serverURL)) + if err != nil { + return srv{}, err + } + path := bDecode(u.EscapedPath()) + if len(path) > 0 && path[len(path)-1] == '/' { + path = path[:len(path)-1] + } + svr := srv{ + host: bDecode(u.Host), //u.Hostname()? + base: path, + schemes: schemes, // scheme: []string{scheme0}, TODO: https://github.com/gorilla/mux/issues/624 + server: server, + varsUpdater: varsUpdater, + } + return svr, nil +} + +// Magic strings that temporarily replace "{}" so net/url.Parse() works +var blURL, brURL = strings.Repeat("-", 50), strings.Repeat("_", 50) + +func bEncode(s string) string { + s = strings.Replace(s, "{", blURL, -1) + s = strings.Replace(s, "}", brURL, -1) + return s +} +func bDecode(s string) string { + s = strings.Replace(s, blURL, "{", -1) + s = strings.Replace(s, brURL, "}", -1) + return s +} + +func permutePart(part0 string, srv *openapi3.Server) []string { + type mapAndSlice struct { + m map[string]struct{} + s []string + } + var2val := make(map[string]mapAndSlice) + max := 0 + for name0, v := range srv.Variables { + name := "{" + name0 + "}" + if !strings.Contains(part0, name) { + continue + } + m := map[string]struct{}{v.Default: {}} + for _, value := range v.Enum { + m[value] = struct{}{} + } + if l := len(m); l > max { + max = l + } + s := make([]string, 0, len(m)) + for value := range m { + s = append(s, value) + } + var2val[name] = mapAndSlice{m: m, s: s} + } + if len(var2val) == 0 { + return []string{part0} + } + + partsMap := make(map[string]struct{}, max*len(var2val)) + for i := 0; i < max; i++ { + part := part0 + for name, mas := range var2val { + part = strings.Replace(part, name, mas.s[i%len(mas.s)], -1) + } + partsMap[part] = struct{}{} + } + parts := make([]string, 0, len(partsMap)) + for part := range partsMap { + parts = append(parts, part) + } + sort.Strings(parts) + return parts +} diff --git a/routers/gorillamux/router_test.go b/routers/gorillamux/router_test.go new file mode 100644 index 000000000..5fb9be2b0 --- /dev/null +++ b/routers/gorillamux/router_test.go @@ -0,0 +1,509 @@ +package gorillamux + +import ( + "context" + "net/http" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" +) + +func TestRouter(t *testing.T) { + helloCONNECT := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloDELETE := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloGET := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloHEAD := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloOPTIONS := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloPATCH := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloPOST := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloPUT := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloTRACE := &openapi3.Operation{Responses: openapi3.NewResponses()} + paramsGET := &openapi3.Operation{Responses: openapi3.NewResponses()} + booksPOST := &openapi3.Operation{Responses: openapi3.NewResponses()} + partialGET := &openapi3.Operation{Responses: openapi3.NewResponses()} + doc := &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "MyAPI", + Version: "0.1", + }, + Paths: openapi3.Paths{ + "/hello": &openapi3.PathItem{ + Connect: helloCONNECT, + Delete: helloDELETE, + Get: helloGET, + Head: helloHEAD, + Options: helloOPTIONS, + Patch: helloPATCH, + Post: helloPOST, + Put: helloPUT, + Trace: helloTRACE, + }, + "/onlyGET": &openapi3.PathItem{ + Get: helloGET, + }, + "/params/{x}/{y}/{z:.*}": &openapi3.PathItem{ + Get: paramsGET, + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("x")}, + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("y")}, + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("z")}, + }, + }, + "/books/{bookid}": &openapi3.PathItem{ + Get: paramsGET, + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("bookid")}, + }, + }, + "/books/{bookid}.json": &openapi3.PathItem{ + Post: booksPOST, + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("bookid")}, + }, + }, + "/partial": &openapi3.PathItem{ + Get: partialGET, + }, + }, + } + + expect := func(r routers.Router, method string, uri string, operation *openapi3.Operation, params map[string]string) { + t.Helper() + req, err := http.NewRequest(method, uri, nil) + require.NoError(t, err) + route, pathParams, err := r.FindRoute(req) + if err != nil { + if operation == nil { + pathItem := doc.Paths[uri] + if pathItem == nil { + if err.Error() != routers.ErrPathNotFound.Error() { + t.Fatalf("'%s %s': should have returned %q, but it returned an error: %v", method, uri, routers.ErrPathNotFound, err) + } + return + } + if pathItem.GetOperation(method) == nil { + if err.Error() != routers.ErrMethodNotAllowed.Error() { + t.Fatalf("'%s %s': should have returned %q, but it returned an error: %v", method, uri, routers.ErrMethodNotAllowed, err) + } + } + } else { + t.Fatalf("'%s %s': should have returned an operation, but it returned an error: %v", method, uri, err) + } + } + if operation == nil && err == nil { + t.Fatalf("'%s %s': should have failed, but returned\nroute = %+v\npathParams = %+v", method, uri, route, pathParams) + } + if route == nil { + return + } + if route.Operation != operation { + t.Fatalf("'%s %s': Returned wrong operation (%v)", + method, uri, route.Operation) + } + if len(params) == 0 { + if len(pathParams) != 0 { + t.Fatalf("'%s %s': should return no path arguments, but found %+v", method, uri, pathParams) + } + } else { + names := make([]string, 0, len(params)) + for name := range params { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + expected := params[name] + actual, exists := pathParams[name] + if !exists { + t.Fatalf("'%s %s': path parameter %q should be %q, but it's not defined.", method, uri, name, expected) + } + if actual != expected { + t.Fatalf("'%s %s': path parameter %q should be %q, but it's %q", method, uri, name, expected, actual) + } + } + } + } + + err := doc.Validate(context.Background()) + require.NoError(t, err) + r, err := NewRouter(doc) + require.NoError(t, err) + + expect(r, http.MethodGet, "/not_existing", nil, nil) + expect(r, http.MethodDelete, "/hello", helloDELETE, nil) + expect(r, http.MethodGet, "/hello", helloGET, nil) + expect(r, http.MethodHead, "/hello", helloHEAD, nil) + expect(r, http.MethodPatch, "/hello", helloPATCH, nil) + expect(r, http.MethodPost, "/hello", helloPOST, nil) + expect(r, http.MethodPut, "/hello", helloPUT, nil) + expect(r, http.MethodGet, "/params/a/b/", paramsGET, map[string]string{ + "x": "a", + "y": "b", + "z": "", + }) + expect(r, http.MethodGet, "/params/a/b/c%2Fd", paramsGET, map[string]string{ + "x": "a", + "y": "b", + "z": "c%2Fd", + }) + expect(r, http.MethodGet, "/books/War.and.Peace", paramsGET, map[string]string{ + "bookid": "War.and.Peace", + }) + expect(r, http.MethodPost, "/books/War.and.Peace.json", booksPOST, map[string]string{ + "bookid": "War.and.Peace", + }) + expect(r, http.MethodPost, "/partial", nil, nil) + + doc.Servers = []*openapi3.Server{ + {URL: "https://www.example.com/api/v1"}, + {URL: "{scheme}://{d0}.{d1}.com/api/v1/", Variables: map[string]*openapi3.ServerVariable{ + "d0": {Default: "www"}, + "d1": {Default: "example", Enum: []string{"example"}}, + "scheme": {Default: "https", Enum: []string{"https", "http"}}, + }}, + {URL: "http://127.0.0.1:{port}/api/v1", Variables: map[string]*openapi3.ServerVariable{ + "port": {Default: "8000"}, + }}, + } + err = doc.Validate(context.Background()) + require.NoError(t, err) + r, err = NewRouter(doc) + require.NoError(t, err) + expect(r, http.MethodGet, "/hello", nil, nil) + expect(r, http.MethodGet, "/api/v1/hello", nil, nil) + expect(r, http.MethodGet, "www.example.com/api/v1/hello", nil, nil) + expect(r, http.MethodGet, "https:///api/v1/hello", nil, nil) + expect(r, http.MethodGet, "https://www.example.com/hello", nil, nil) + expect(r, http.MethodGet, "https://www.example.com/api/v1/hello", helloGET, nil) + expect(r, http.MethodGet, "https://domain0.domain1.com/api/v1/hello", helloGET, map[string]string{ + "d0": "domain0", + "d1": "domain1", + // "scheme": "https", TODO: https://github.com/gorilla/mux/issues/624 + }) + expect(r, http.MethodGet, "http://127.0.0.1:8000/api/v1/hello", helloGET, map[string]string{ + "port": "8000", + }) + + doc.Servers = []*openapi3.Server{ + {URL: "{server}", Variables: map[string]*openapi3.ServerVariable{ + "server": {Default: "/api/v1"}, + }}, + } + err = doc.Validate(context.Background()) + require.NoError(t, err) + r, err = NewRouter(doc) + require.NoError(t, err) + expect(r, http.MethodGet, "https://myserver/api/v1/hello", helloGET, nil) + + { + uri := "https://www.example.com/api/v1/onlyGET" + expect(r, http.MethodGet, uri, helloGET, nil) + req, err := http.NewRequest(http.MethodDelete, uri, nil) + require.NoError(t, err) + require.NotNil(t, req) + route, pathParams, err := r.FindRoute(req) + require.EqualError(t, err, routers.ErrMethodNotAllowed.Error()) + require.Nil(t, route) + require.Nil(t, pathParams) + } +} + +func TestPermuteScheme(t *testing.T) { + scheme0 := "{sche}{me}" + server := &openapi3.Server{URL: scheme0 + "://{d0}.{d1}.com/api/v1/", Variables: map[string]*openapi3.ServerVariable{ + "d0": {Default: "www"}, + "d1": {Default: "example", Enum: []string{"example"}}, + "sche": {Default: "http"}, + "me": {Default: "s", Enum: []string{"", "s"}}, + }} + err := server.Validate(context.Background()) + require.NoError(t, err) + perms := permutePart(scheme0, server) + require.Equal(t, []string{"http", "https"}, perms) +} + +func TestServerPath(t *testing.T) { + server := &openapi3.Server{URL: "http://example.com"} + err := server.Validate(context.Background()) + require.NoError(t, err) + + _, err = NewRouter(&openapi3.T{Servers: openapi3.Servers{ + server, + &openapi3.Server{URL: "http://example.com/"}, + &openapi3.Server{URL: "http://example.com/path"}, + newServerWithVariables( + "{scheme}://localhost", + map[string]string{ + "scheme": "https", + }), + newServerWithVariables( + "{url}", + map[string]string{ + "url": "http://example.com/path", + }), + newServerWithVariables( + "http://example.com:{port}/path", + map[string]string{ + "port": "8088", + }), + newServerWithVariables( + "{server}", + map[string]string{ + "server": "/", + }), + newServerWithVariables( + "/", + nil, + )}, + }) + require.NoError(t, err) +} + +func TestServerOverrideAtPathLevel(t *testing.T) { + helloGET := &openapi3.Operation{Responses: openapi3.NewResponses()} + doc := &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "rel", + Version: "1", + }, + Servers: openapi3.Servers{ + &openapi3.Server{ + URL: "https://example.com", + }, + }, + Paths: openapi3.Paths{ + "/hello": &openapi3.PathItem{ + Servers: openapi3.Servers{ + &openapi3.Server{ + URL: "https://another.com", + }, + }, + Get: helloGET, + }, + }, + } + err := doc.Validate(context.Background()) + require.NoError(t, err) + router, err := NewRouter(doc) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, "https://another.com/hello", nil) + require.NoError(t, err) + route, _, err := router.FindRoute(req) + require.Equal(t, "/hello", route.Path) + + req, err = http.NewRequest(http.MethodGet, "https://example.com/hello", nil) + require.NoError(t, err) + route, _, err = router.FindRoute(req) + require.Nil(t, route) + require.Error(t, err) +} + +func TestRelativeURL(t *testing.T) { + helloGET := &openapi3.Operation{Responses: openapi3.NewResponses()} + doc := &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "rel", + Version: "1", + }, + Servers: openapi3.Servers{ + &openapi3.Server{ + URL: "/api/v1", + }, + }, + Paths: openapi3.Paths{ + "/hello": &openapi3.PathItem{ + Get: helloGET, + }, + }, + } + err := doc.Validate(context.Background()) + require.NoError(t, err) + router, err := NewRouter(doc) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodGet, "https://example.com/api/v1/hello", nil) + require.NoError(t, err) + route, _, err := router.FindRoute(req) + require.NoError(t, err) + require.Equal(t, "/hello", route.Path) +} + +func Test_makeServers(t *testing.T) { + type testStruct struct { + name string + servers openapi3.Servers + want []srv + wantErr bool + initFn func(tt *testStruct) + } + tests := []testStruct{ + { + name: "server is root path", + servers: openapi3.Servers{ + newServerWithVariables("/", nil), + }, + want: []srv{{ + schemes: nil, + host: "", + base: "", + server: nil, + varsUpdater: nil, + }}, + wantErr: false, + initFn: func(tt *testStruct) { + for i, server := range tt.servers { + tt.want[i].server = server + } + }, + }, + { + name: "server with single variable that evaluates to root path", + servers: openapi3.Servers{ + newServerWithVariables("{server}", map[string]string{"server": "/"}), + }, + want: []srv{{ + schemes: nil, + host: "", + base: "", + server: nil, + varsUpdater: nil, + }}, + wantErr: false, + initFn: func(tt *testStruct) { + for i, server := range tt.servers { + tt.want[i].server = server + } + }, + }, + { + name: "server is http://localhost:28002", + servers: openapi3.Servers{ + newServerWithVariables("http://localhost:28002", nil), + }, + want: []srv{{ + schemes: []string{"http"}, + host: "localhost:28002", + base: "", + server: nil, + varsUpdater: nil, + }}, + wantErr: false, + initFn: func(tt *testStruct) { + for i, server := range tt.servers { + tt.want[i].server = server + } + }, + }, + { + name: "server with single variable that evaluates to http://localhost:28002", + servers: openapi3.Servers{ + newServerWithVariables("{server}", map[string]string{"server": "http://localhost:28002"}), + }, + want: []srv{{ + schemes: []string{"http"}, + host: "localhost:28002", + base: "", + server: nil, + varsUpdater: nil, + }}, + wantErr: false, + initFn: func(tt *testStruct) { + for i, server := range tt.servers { + tt.want[i].server = server + } + }, + }, + { + name: "server with multiple variables that evaluates to http://localhost:28002", + servers: openapi3.Servers{ + newServerWithVariables("{scheme}://{host}:{port}", map[string]string{"scheme": "http", "host": "localhost", "port": "28002"}), + }, + want: []srv{{ + schemes: []string{"http"}, + host: "{host}:28002", + base: "", + server: nil, + varsUpdater: func(vars map[string]string) { vars["port"] = "28002" }, + }}, + wantErr: false, + initFn: func(tt *testStruct) { + for i, server := range tt.servers { + tt.want[i].server = server + } + }, + }, + { + name: "server with unparsable URL fails", + servers: openapi3.Servers{ + newServerWithVariables("exam^ple.com:443", nil), + }, + want: nil, + wantErr: true, + initFn: nil, + }, + { + name: "server with single variable that evaluates to unparsable URL fails", + servers: openapi3.Servers{ + newServerWithVariables("{server}", map[string]string{"server": "exam^ple.com:443"}), + }, + want: nil, + wantErr: true, + initFn: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.initFn != nil { + tt.initFn(&tt) + } + got, err := makeServers(tt.servers) + if (err != nil) != tt.wantErr { + t.Errorf("makeServers() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, len(tt.want), len(got), "expected and actual servers lengths are not equal") + for i := 0; i < len(tt.want); i++ { + // Unfortunately using assert.Equals or reflect.DeepEquals isn't + // an option because function pointers cannot be compared + assert.Equal(t, tt.want[i].schemes, got[i].schemes) + assert.Equal(t, tt.want[i].host, got[i].host) + assert.Equal(t, tt.want[i].host, got[i].host) + assert.Equal(t, tt.want[i].server, got[i].server) + if tt.want[i].varsUpdater == nil { + assert.Nil(t, got[i].varsUpdater, "expected and actual varsUpdater should point to same function") + } else { + assert.NotNil(t, got[i].varsUpdater, "expected and actual varsUpdater should point to same function") + } + } + }) + } +} + +func newServerWithVariables(url string, variables map[string]string) *openapi3.Server { + var serverVariables = map[string]*openapi3.ServerVariable{} + + for key, value := range variables { + serverVariables[key] = newServerVariable(value) + } + + return &openapi3.Server{ + URL: url, + Description: "", + Variables: serverVariables, + } +} + +func newServerVariable(defaultValue string) *openapi3.ServerVariable { + return &openapi3.ServerVariable{ + Enum: nil, + Default: defaultValue, + Description: "", + } +} diff --git a/routers/issue356_test.go b/routers/issue356_test.go new file mode 100644 index 000000000..de9f06b91 --- /dev/null +++ b/routers/issue356_test.go @@ -0,0 +1,145 @@ +package routers_test + +import ( + "context" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" + "github.com/getkin/kin-openapi/routers/legacy" +) + +func TestIssue356(t *testing.T) { + spec := func(servers string) []byte { + return []byte(` +openapi: 3.0.0 +info: + title: Example + version: '1.0' + description: test +servers: +` + servers + ` +paths: + /test: + post: + responses: + '201': + description: Created + content: + application/json: + schema: {type: object} + requestBody: + content: + application/json: + schema: {type: object} + description: '' + description: Create a test object +`) + } + + for servers, expectError := range map[string]bool{ + ` +- url: http://localhost:3000/base +- url: /base +`: false, + + ` +- url: /base +- url: http://localhost:3000/base +`: false, + + `- url: /base`: false, + + `- url: http://localhost:3000/base`: true, + + ``: true, + } { + loader := &openapi3.Loader{Context: context.Background()} + t.Logf("using servers: %q (%v)", servers, expectError) + doc, err := loader.LoadFromData(spec(servers)) + require.NoError(t, err) + err = doc.Validate(context.Background()) + require.NoError(t, err) + gorillamuxNewRouterWrapped := func(doc *openapi3.T, opts ...openapi3.ValidationOption) (routers.Router, error) { + return gorillamux.NewRouter(doc) + } + + for i, newRouter := range []func(*openapi3.T, ...openapi3.ValidationOption) (routers.Router, error){gorillamuxNewRouterWrapped, legacy.NewRouter} { + t.Logf("using NewRouter from %s", map[int]string{0: "gorillamux", 1: "legacy"}[i]) + router, err := newRouter(doc) + require.NoError(t, err) + + if true { + t.Logf("using naked newRouter") + httpReq, err := http.NewRequest(http.MethodPost, "/base/test", strings.NewReader(`{}`)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + + route, pathParams, err := router.FindRoute(httpReq) + if expectError { + require.Error(t, err, routers.ErrPathNotFound) + return + } + require.NoError(t, err) + + requestValidationInput := &openapi3filter.RequestValidationInput{ + Request: httpReq, + PathParams: pathParams, + Route: route, + } + err = openapi3filter.ValidateRequest(context.Background(), requestValidationInput) + require.NoError(t, err) + } + + if true { + t.Logf("using httptest.NewServer") + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + route, pathParams, err := router.FindRoute(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + requestValidationInput := &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + } + err = openapi3filter.ValidateRequest(r.Context(), requestValidationInput) + require.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{}")) + })) + defer ts.Close() + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/base/test", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + rep, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer rep.Body.Close() + body, err := ioutil.ReadAll(rep.Body) + require.NoError(t, err) + + if expectError { + require.Equal(t, 500, rep.StatusCode) + require.Equal(t, routers.ErrPathNotFound.Error(), string(body)) + return + } + require.Equal(t, 200, rep.StatusCode) + require.Equal(t, "{}", string(body)) + } + } + } +} diff --git a/routers/legacy/issue444_test.go b/routers/legacy/issue444_test.go new file mode 100644 index 000000000..c1e9b14f2 --- /dev/null +++ b/routers/legacy/issue444_test.go @@ -0,0 +1,59 @@ +package legacy_test + +import ( + "bytes" + "context" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" +) + +func TestIssue444(t *testing.T) { + loader := openapi3.NewLoader() + oas, err := loader.LoadFromData([]byte(` +openapi: '3.0.0' +info: + title: API + version: 1.0.0 +paths: + '/path': + post: + requestBody: + required: true + content: + application/x-yaml: + schema: + type: object + responses: + '200': + description: x + content: + application/json: + schema: + type: string +`)) + require.NoError(t, err) + router, err := legacyrouter.NewRouter(oas) + require.NoError(t, err) + + r := httptest.NewRequest("POST", "/path", bytes.NewReader([]byte(` +foo: bar +`))) + r.Header.Set("Content-Type", "application/x-yaml") + + openapi3.SchemaErrorDetailsDisabled = true + route, pathParams, err := router.FindRoute(r) + require.NoError(t, err) + reqValidationInput := &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + } + err = openapi3filter.ValidateRequest(context.Background(), reqValidationInput) + require.NoError(t, err) +} diff --git a/pathpattern/node.go b/routers/legacy/pathpattern/node.go similarity index 94% rename from pathpattern/node.go rename to routers/legacy/pathpattern/node.go index 43e2959d4..011dda358 100644 --- a/pathpattern/node.go +++ b/routers/legacy/pathpattern/node.go @@ -1,11 +1,11 @@ // Package pathpattern implements path matching. // // Examples of supported patterns: -// * "/" -// * "/abc"" -// * "/abc/{variable}" (matches until next '/' or end-of-string) -// * "/abc/{variable*}" (matches everything, including "/abc" if "/abc" has noot) -// * "/abc/{ variable | prefix_(.*}_suffix }" (matches regular expressions) +// - "/" +// - "/abc"" +// - "/abc/{variable}" (matches until next '/' or end-of-string) +// - "/abc/{variable*}" (matches everything, including "/abc" if "/abc" has noot) +// - "/abc/{ variable | prefix_(.*}_suffix }" (matches regular expressions) package pathpattern import ( @@ -28,8 +28,8 @@ type Options struct { // PathFromHost converts a host pattern to a path pattern. // // Examples: -// * PathFromHost("some-subdomain.domain.com", false) -> "com/./domain/./some-subdomain" -// * PathFromHost("some-subdomain.domain.com", true) -> "com/./domain/./subdomain/-/some" +// - PathFromHost("some-subdomain.domain.com", false) -> "com/./domain/./some-subdomain" +// - PathFromHost("some-subdomain.domain.com", true) -> "com/./domain/./subdomain/-/some" func PathFromHost(host string, specialDashes bool) string { buf := make([]byte, 0, len(host)) end := len(host) @@ -212,7 +212,7 @@ loop: // Find variable name i := strings.IndexByte(remaining, '}') if i < 0 { - return nil, fmt.Errorf("Missing '}' in: %s", path) + return nil, fmt.Errorf("missing '}' in: %s", path) } variableName := strings.TrimSpace(remaining[1:i]) remaining = remaining[i+1:] @@ -247,7 +247,7 @@ loop: if suffix.Kind == SuffixKindRegExp { regExp, err := regexp.Compile(suffix.Pattern) if err != nil { - return nil, fmt.Errorf("Invalid regular expression in: %s", path) + return nil, fmt.Errorf("invalid regular expression in: %s", path) } suffix.regExp = regExp } diff --git a/pathpattern/node_test.go b/routers/legacy/pathpattern/node_test.go similarity index 68% rename from pathpattern/node_test.go rename to routers/legacy/pathpattern/node_test.go index 6d0ec9d92..14d8457ce 100644 --- a/pathpattern/node_test.go +++ b/routers/legacy/pathpattern/node_test.go @@ -1,14 +1,12 @@ -package pathpattern_test +package pathpattern import ( "testing" - - "github.com/getkin/kin-openapi/pathpattern" ) func TestPatterns(t *testing.T) { - pathpattern.DefaultOptions.SupportRegExp = true - rootNode := &pathpattern.Node{} + DefaultOptions.SupportRegExp = true + rootNode := &Node{} add := func(path, value string) { rootNode.MustAdd(path, value, nil) } @@ -24,8 +22,8 @@ func TestPatterns(t *testing.T) { add("/root/{path*}", "DIRECTORY") add("/impossible_route", "IMPOSSIBLE") - add(pathpattern.PathFromHost("www.nike.com", true), "WWW-HOST") - add(pathpattern.PathFromHost("{other}.nike.com", true), "OTHER-HOST") + add(PathFromHost("www.nike.com", true), "WWW-HOST") + add(PathFromHost("{other}.nike.com", true), "OTHER-HOST") expect := func(uri string, expected string, expectedArgs ...string) { actually := "not found" @@ -36,11 +34,11 @@ func TestPatterns(t *testing.T) { } } if actually != expected { - t.Fatalf("Wrong path!\nInput: %s\nExpected: '%s'\nActually: '%s'\nTree:\n%s\n\n", uri, expected, actually, rootNode.String()) + t.Fatalf("Wrong path!\nInput: %s\nExpected: %q\nActually: %q\nTree:\n%s\n\n", uri, expected, actually, rootNode.String()) return } if !argsEqual(expectedArgs, actualArgs) { - t.Fatalf("Wrong variable values!\nInput: %s\nExpected: '%s'\nActually: '%s'\nTree:\n%s\n\n", uri, expectedArgs, actualArgs, rootNode.String()) + t.Fatalf("Wrong variable values!\nInput: %s\nExpected: %q\nActually: %q\nTree:\n%s\n\n", uri, expectedArgs, actualArgs, rootNode.String()) return } } @@ -65,9 +63,9 @@ func TestPatterns(t *testing.T) { expect("/root/", "DIRECTORY", "") expect("/root/a/b/c", "DIRECTORY", "a/b/c") - expect(pathpattern.PathFromHost("www.nike.com", true), "WWW-HOST") - expect(pathpattern.PathFromHost("example.nike.com", true), "OTHER-HOST", "example") - expect(pathpattern.PathFromHost("subdomain.example.nike.com", true), "not found") + expect(PathFromHost("www.nike.com", true), "WWW-HOST") + expect(PathFromHost("example.nike.com", true), "OTHER-HOST", "example") + expect(PathFromHost("subdomain.example.nike.com", true), "not found") } func argsEqual(a, b []string) bool { diff --git a/routers/legacy/router.go b/routers/legacy/router.go new file mode 100644 index 000000000..911422b85 --- /dev/null +++ b/routers/legacy/router.go @@ -0,0 +1,167 @@ +// Package legacy implements a router. +// +// It differs from the gorilla/mux router: +// * it provides granular errors: "path not found", "method not allowed", "variable missing from path" +// * it does not handle matching routes with extensions (e.g. /books/{id}.json) +// * it handles path patterns with a different syntax (e.g. /params/{x}/{y}/{z.*}) +package legacy + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/legacy/pathpattern" +) + +// Routers maps a HTTP request to a Router. +type Routers []*Router + +// FindRoute extracts the route and parameters of an http.Request +func (rs Routers) FindRoute(req *http.Request) (routers.Router, *routers.Route, map[string]string, error) { + for _, router := range rs { + // Skip routers that have DO NOT have servers + if len(router.doc.Servers) == 0 { + continue + } + route, pathParams, err := router.FindRoute(req) + if err == nil { + return router, route, pathParams, nil + } + } + for _, router := range rs { + // Skip routers that DO have servers + if len(router.doc.Servers) > 0 { + continue + } + route, pathParams, err := router.FindRoute(req) + if err == nil { + return router, route, pathParams, nil + } + } + return nil, nil, nil, &routers.RouteError{ + Reason: "none of the routers match", + } +} + +// Router maps a HTTP request to an OpenAPI operation. +type Router struct { + doc *openapi3.T + pathNode *pathpattern.Node +} + +// NewRouter creates a new router. +// +// If the given OpenAPIv3 document has servers, router will use them. +// All operations of the document will be added to the router. +func NewRouter(doc *openapi3.T, opts ...openapi3.ValidationOption) (routers.Router, error) { + if err := doc.Validate(context.Background(), opts...); err != nil { + return nil, fmt.Errorf("validating OpenAPI failed: %w", err) + } + router := &Router{doc: doc} + root := router.node() + for path, pathItem := range doc.Paths { + for method, operation := range pathItem.Operations() { + method = strings.ToUpper(method) + if err := root.Add(method+" "+path, &routers.Route{ + Spec: doc, + Path: path, + PathItem: pathItem, + Method: method, + Operation: operation, + }, nil); err != nil { + return nil, err + } + } + } + return router, nil +} + +// AddRoute adds a route in the router. +func (router *Router) AddRoute(route *routers.Route) error { + method := route.Method + if method == "" { + return errors.New("route is missing method") + } + method = strings.ToUpper(method) + path := route.Path + if path == "" { + return errors.New("route is missing path") + } + return router.node().Add(method+" "+path, router, nil) +} + +func (router *Router) node() *pathpattern.Node { + root := router.pathNode + if root == nil { + root = &pathpattern.Node{} + router.pathNode = root + } + return root +} + +// FindRoute extracts the route and parameters of an http.Request +func (router *Router) FindRoute(req *http.Request) (*routers.Route, map[string]string, error) { + method, url := req.Method, req.URL + doc := router.doc + + // Get server + servers := doc.Servers + var server *openapi3.Server + var remainingPath string + var pathParams map[string]string + if len(servers) == 0 { + remainingPath = url.Path + } else { + var paramValues []string + server, paramValues, remainingPath = servers.MatchURL(url) + if server == nil { + return nil, nil, &routers.RouteError{ + Reason: routers.ErrPathNotFound.Error(), + } + } + pathParams = make(map[string]string) + paramNames, err := server.ParameterNames() + if err != nil { + return nil, nil, err + } + for i, value := range paramValues { + name := paramNames[i] + pathParams[name] = value + } + } + + // Get PathItem + root := router.node() + var route *routers.Route + node, paramValues := root.Match(method + " " + remainingPath) + if node != nil { + route, _ = node.Value.(*routers.Route) + } + if route == nil { + pathItem := doc.Paths[remainingPath] + if pathItem == nil { + return nil, nil, &routers.RouteError{Reason: routers.ErrPathNotFound.Error()} + } + if pathItem.GetOperation(method) == nil { + return nil, nil, &routers.RouteError{Reason: routers.ErrMethodNotAllowed.Error()} + } + } + + if pathParams == nil { + pathParams = make(map[string]string, len(paramValues)) + } + paramKeys := node.VariableNames + for i, value := range paramValues { + key := paramKeys[i] + if strings.HasSuffix(key, "*") { + key = key[:len(key)-1] + } + pathParams[key] = value + } + return route, pathParams, nil +} diff --git a/routers/legacy/router_test.go b/routers/legacy/router_test.go new file mode 100644 index 000000000..e9b875986 --- /dev/null +++ b/routers/legacy/router_test.go @@ -0,0 +1,213 @@ +package legacy + +import ( + "context" + "net/http" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" +) + +func TestRouter(t *testing.T) { + helloCONNECT := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloDELETE := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloGET := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloHEAD := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloOPTIONS := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloPATCH := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloPOST := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloPUT := &openapi3.Operation{Responses: openapi3.NewResponses()} + helloTRACE := &openapi3.Operation{Responses: openapi3.NewResponses()} + paramsGET := &openapi3.Operation{Responses: openapi3.NewResponses()} + booksPOST := &openapi3.Operation{Responses: openapi3.NewResponses()} + partialGET := &openapi3.Operation{Responses: openapi3.NewResponses()} + doc := &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "MyAPI", + Version: "0.1", + }, + Paths: openapi3.Paths{ + "/hello": &openapi3.PathItem{ + Connect: helloCONNECT, + Delete: helloDELETE, + Get: helloGET, + Head: helloHEAD, + Options: helloOPTIONS, + Patch: helloPATCH, + Post: helloPOST, + Put: helloPUT, + Trace: helloTRACE, + }, + "/onlyGET": &openapi3.PathItem{ + Get: helloGET, + }, + "/params/{x}/{y}/{z.*}": &openapi3.PathItem{ + Get: paramsGET, + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("x")}, + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("y")}, + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("z")}, + }, + }, + "/books/{bookid}": &openapi3.PathItem{ + Get: paramsGET, + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("bookid")}, + }, + }, + "/books/{bookid2}.json": &openapi3.PathItem{ + Post: booksPOST, + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{Value: openapi3.NewPathParameter("bookid2")}, + }, + }, + "/partial": &openapi3.PathItem{ + Get: partialGET, + }, + }, + } + + expect := func(r routers.Router, method string, uri string, operation *openapi3.Operation, params map[string]string) { + req, err := http.NewRequest(method, uri, nil) + require.NoError(t, err) + route, pathParams, err := r.FindRoute(req) + if err != nil { + if operation == nil { + pathItem := doc.Paths[uri] + if pathItem == nil { + if err.Error() != routers.ErrPathNotFound.Error() { + t.Fatalf("'%s %s': should have returned %q, but it returned an error: %v", method, uri, routers.ErrPathNotFound, err) + } + return + } + if pathItem.GetOperation(method) == nil { + if err.Error() != routers.ErrMethodNotAllowed.Error() { + t.Fatalf("'%s %s': should have returned %q, but it returned an error: %v", method, uri, routers.ErrMethodNotAllowed, err) + } + } + } else { + t.Fatalf("'%s %s': should have returned an operation, but it returned an error: %v", method, uri, err) + } + } + if operation == nil && err == nil { + t.Fatalf("'%s %s': should have failed, but returned\nroute = %+v\npathParams = %+v", method, uri, route, pathParams) + } + if route == nil { + return + } + if route.Operation != operation { + t.Fatalf("'%s %s': Returned wrong operation (%v)", + method, uri, route.Operation) + } + if len(params) == 0 { + if len(pathParams) != 0 { + t.Fatalf("'%s %s': should return no path arguments, but found %+v", method, uri, pathParams) + } + } else { + names := make([]string, 0, len(params)) + for name := range params { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + expected := params[name] + actual, exists := pathParams[name] + if !exists { + t.Fatalf("'%s %s': path parameter %q should be %q, but it's not defined.", method, uri, name, expected) + } + if actual != expected { + t.Fatalf("'%s %s': path parameter %q should be %q, but it's %q", method, uri, name, expected, actual) + } + } + } + } + + err := doc.Validate(context.Background()) + require.NoError(t, err) + r, err := NewRouter(doc) + require.NoError(t, err) + + expect(r, http.MethodGet, "/not_existing", nil, nil) + expect(r, http.MethodDelete, "/hello", helloDELETE, nil) + expect(r, http.MethodGet, "/hello", helloGET, nil) + expect(r, http.MethodHead, "/hello", helloHEAD, nil) + expect(r, http.MethodPatch, "/hello", helloPATCH, nil) + expect(r, http.MethodPost, "/hello", helloPOST, nil) + expect(r, http.MethodPut, "/hello", helloPUT, nil) + expect(r, http.MethodGet, "/params/a/b/", paramsGET, map[string]string{ + "x": "a", + "y": "b", + // "z": "", + }) + expect(r, http.MethodGet, "/params/a/b/c%2Fd", paramsGET, map[string]string{ + "x": "a", + "y": "b", + // "z": "c/d", + }) + expect(r, http.MethodGet, "/books/War.and.Peace", paramsGET, map[string]string{ + "bookid": "War.and.Peace", + }) + { + req, err := http.NewRequest(http.MethodPost, "/books/War.and.Peace.json", nil) + require.NoError(t, err) + _, _, err = r.FindRoute(req) + require.EqualError(t, err, routers.ErrPathNotFound.Error()) + } + expect(r, http.MethodPost, "/partial", nil, nil) + + doc.Servers = []*openapi3.Server{ + {URL: "https://www.example.com/api/v1"}, + {URL: "https://{d0}.{d1}.com/api/v1/", Variables: map[string]*openapi3.ServerVariable{ + "d0": {Default: "www"}, + "d1": {Default: "example", Enum: []string{"example"}}, + }}, + } + err = doc.Validate(context.Background()) + require.NoError(t, err) + r, err = NewRouter(doc) + require.NoError(t, err) + expect(r, http.MethodGet, "/hello", nil, nil) + expect(r, http.MethodGet, "/api/v1/hello", nil, nil) + expect(r, http.MethodGet, "www.example.com/api/v1/hello", nil, nil) + expect(r, http.MethodGet, "https:///api/v1/hello", nil, nil) + expect(r, http.MethodGet, "https://www.example.com/hello", nil, nil) + expect(r, http.MethodGet, "https://www.example.com/api/v1/hello", helloGET, nil) + expect(r, http.MethodGet, "https://domain0.domain1.com/api/v1/hello", helloGET, map[string]string{ + "d0": "domain0", + "d1": "domain1", + }) + + { + uri := "https://www.example.com/api/v1/onlyGET" + expect(r, http.MethodGet, uri, helloGET, nil) + req, err := http.NewRequest(http.MethodDelete, uri, nil) + require.NoError(t, err) + require.NotNil(t, req) + route, pathParams, err := r.FindRoute(req) + require.EqualError(t, err, routers.ErrMethodNotAllowed.Error()) + require.Nil(t, route) + require.Nil(t, pathParams) + } + + schema := &openapi3.Schema{ + Type: "string", + Example: 3, + } + content := openapi3.NewContentWithJSONSchema(schema) + responses := openapi3.NewResponses() + responses["default"].Value.Content = content + doc.Paths["/withExamples"] = &openapi3.PathItem{ + Get: &openapi3.Operation{Responses: responses}, + } + err = doc.Validate(context.Background()) + require.Error(t, err) + r, err = NewRouter(doc) + require.Error(t, err) + r, err = NewRouter(doc, openapi3.DisableExamplesValidation()) + require.NoError(t, err) +} diff --git a/routers/legacy/validate_request_test.go b/routers/legacy/validate_request_test.go new file mode 100644 index 000000000..9c15ed44a --- /dev/null +++ b/routers/legacy/validate_request_test.go @@ -0,0 +1,112 @@ +package legacy_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/legacy" +) + +const spec = ` +openapi: 3.0.0 +info: + title: My API + version: 0.0.1 +paths: + /: + post: + responses: + default: + description: '' + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: pet_type + +components: + schemas: + Pet: + type: object + required: [pet_type] + properties: + pet_type: + type: string + discriminator: + propertyName: pet_type + + Dog: + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + breed: + type: string + enum: [Dingo, Husky, Retriever, Shepherd] + Cat: + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + hunts: + type: boolean + age: + type: integer +` + +func Example() { + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + if err != nil { + panic(err) + } + if err := doc.Validate(loader.Context); err != nil { + panic(err) + } + + router, err := legacy.NewRouter(doc) + if err != nil { + panic(err) + } + + p, err := json.Marshal(map[string]interface{}{ + "pet_type": "Cat", + "breed": "Dingo", + "bark": true, + }) + if err != nil { + panic(err) + } + + req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(p)) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", "application/json") + + route, pathParams, err := router.FindRoute(req) + if err != nil { + panic(err) + } + + requestValidationInput := &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + } + if err := openapi3filter.ValidateRequest(loader.Context, requestValidationInput); err != nil { + fmt.Println(err) + } + // Output: + // request body has an error: doesn't match schema: input matches more than one oneOf schemas + +} diff --git a/routers/types.go b/routers/types.go new file mode 100644 index 000000000..93746cfe9 --- /dev/null +++ b/routers/types.go @@ -0,0 +1,42 @@ +package routers + +import ( + "net/http" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Router helps link http.Request.s and an OpenAPIv3 spec +type Router interface { + // FindRoute matches an HTTP request with the operation it resolves to. + // Hosts are matched from the OpenAPIv3 servers key. + // + // If you experience ErrPathNotFound and have localhost hosts specified as your servers, + // turning these server URLs as relative (leaving only the path) should resolve this. + // + // See openapi3filter for example uses with request and response validation. + FindRoute(req *http.Request) (route *Route, pathParams map[string]string, err error) +} + +// Route describes the operation an http.Request can match +type Route struct { + Spec *openapi3.T + Server *openapi3.Server + Path string + PathItem *openapi3.PathItem + Method string + Operation *openapi3.Operation +} + +// ErrPathNotFound is returned when no route match is found +var ErrPathNotFound error = &RouteError{"no matching operation was found"} + +// ErrMethodNotAllowed is returned when no method of the matched route matches +var ErrMethodNotAllowed error = &RouteError{"method not allowed"} + +// RouteError describes Router errors +type RouteError struct { + Reason string +} + +func (e *RouteError) Error() string { return e.Reason }