diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cebd42bea..1a41f29e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,10 +13,6 @@ jobs: fail-fast: false matrix: image: - - swift:5.8-focal - - swift:5.8-jammy - - swift:5.9-focal - - swift:5.9-jammy - swift:5.10-focal - swift:5.10-jammy - swift:6.0-focal @@ -25,6 +21,8 @@ jobs: - swift:6.1-focal - swift:6.1-jammy - swift:6.1-noble + - swift:6.2-jammy + - swift:6.2-noble - swiftlang/swift:nightly-focal - swiftlang/swift:nightly-jammy container: ${{ matrix.image }} diff --git a/Package.swift b/Package.swift index 5e8bafa02..ce22f3cc3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.8 +// swift-tools-version: 5.10 import PackageDescription @@ -6,7 +6,7 @@ let package = Package( name: "OpenAPIKit", platforms: [ .macOS(.v10_15), - .iOS(.v11) + .iOS(.v12) ], products: [ .library( diff --git a/README.md b/README.md index af529d64b..9921233c5 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ -[![sswg:sandbox|94x20](https://img.shields.io/badge/sswg-sandbox-lightgrey.svg)](https://github.com/swift-server/sswg/blob/master/process/incubation.md#sandbox-level) [![Swift 5.8+](http://img.shields.io/badge/Swift-5.8+-blue.svg)](https://swift.org) +[![sswg:sandbox|94x20](https://img.shields.io/badge/sswg-sandbox-lightgrey.svg)](https://github.com/swift-server/sswg/blob/master/process/incubation.md#sandbox-level) [![Swift 5.10+](http://img.shields.io/badge/Swift-5.10+-blue.svg)](https://swift.org) [![MIT license](http://img.shields.io/badge/license-MIT-lightgrey.svg)](http://opensource.org/licenses/MIT) ![Tests](https://github.com/mattpolzin/OpenAPIKit/actions/workflows/tests.yml/badge.svg?branch=main) # OpenAPIKit -A library containing Swift types that encode to- and decode from [OpenAPI 3.0.x](https://spec.openapis.org/oas/v3.0.4.html) and [OpenAPI 3.1.x](https://spec.openapis.org/oas/v3.1.1.html) Documents and their components. +A library containing Swift types that encode to- and decode from [OpenAPI 3.0.x](https://spec.openapis.org/oas/v3.0.4.html), [OpenAPI 3.1.x](https://spec.openapis.org/oas/v3.1.2.html), and [OpenAPI 3.2.x](https://spec.openapis.org/oas/v3.2.0.html) Documents and their components. OpenAPIKit follows semantic versioning despite the fact that the OpenAPI specificaiton does not. The following chart shows which OpenAPI specification versions and key features are supported by which OpenAPIKit versions. -| OpenAPIKit | Swift | OpenAPI v3.0 | OpenAPI v3.1 | External Dereferencing & Sendable | -|------------|-------|--------------|--------------|-----------------------------------| -| v2.x | 5.1+ | ✅ | | | -| v3.x | 5.1+ | ✅ | ✅ | | -| v4.x | 5.8+ | ✅ | ✅ | ✅ | +| OpenAPIKit | Swift | OpenAPI v3.0, v3.1 | External Dereferencing & Sendable | OpenAPI v3.2 | +|------------|-------|--------------------|-----------------------------------|--------------| +| v3.x | 5.1+ | ✅ | | | +| v4.x | 5.8+ | ✅ | ✅ | | +| v5.x | 5.10+ | ✅ | ✅ | ✅ | - [Usage](#usage) - [Migration](#migration) - - [1.x to 2.x](#1.x-to-2.x) - - [2.x to 3.x](#2.x-to-3.x) - - [3.x to 4.x](#3.x-to-4.x) + - [Older Versions](#older-versions) + - [3.x to 4.x](#3x-to-4x) + - [4.x to 5.x](#4x-to-5x) - [Decoding OpenAPI Documents](#decoding-openapi-documents) - [Decoding Errors](#decoding-errors) - [Encoding OpenAPI Documents](#encoding-openapi-documents) @@ -47,40 +47,25 @@ OpenAPIKit follows semantic versioning despite the fact that the OpenAPI specifi ## Usage ### Migration -#### 1.x to 2.x -If you are migrating from OpenAPIKit 1.x to OpenAPIKit 2.x, check out the [v2 migration guide](./documentation/v2_migration_guide.md). +#### Older Versions +- [`1.x` to `2.x`](./documentation/migration_guides/v2_migration_guide.md) +- [`2.x` to `3.x`](./documentation/migration_guides/v3_migration_guide.md) -#### 2.x to 3.x -If you are migrating from OpenAPIKit 2.x to OpenAPIKit 3.x, check out the [v3 migration guide](./documentation/v3_migration_guide.md). - -You will need to start being explicit about which of the two new modules you want to use in your project: `OpenAPIKit` (now supports OpenAPI spec v3.1) and/or `OpenAPIKit30` (continues to support OpenAPI spec v3.0 like the previous versions of OpenAPIKit did). - -In package manifests, dependencies will be one of: -``` -// v3.0 of spec: -dependencies: [.product(name: "OpenAPIKit30", package: "OpenAPIKit")] - -// v3.1 of spec: -dependencies: [.product(name: "OpenAPIKit", package: "OpenAPIKit")] -``` - -Your imports need to be specific as well: -```swift -// v3.0 of spec: -import OpenAPIKit30 +#### 3.x to 4.x +If you are migrating from OpenAPIKit 3.x to OpenAPIKit 4.x, check out the [v4 migration guide](./documentation/migration_guides/v4_migration_guide.md). -// v3.1 of spec: -import OpenAPIKit -``` +Be aware of the changes to minimum Swift version and minimum Yams version (although Yams is only a test dependency of OpenAPIKit). -It is recommended that you build your project against the `OpenAPIKit` module and only use `OpenAPIKit30` to support reading OpenAPI 3.0.x documents in and then [converting them](#supporting-openapi-30x-documents) to OpenAPI 3.1.x documents. The situation not supported yet by this strategy is where you need to write out an OpenAPI 3.0.x document (as opposed to 3.1.x). That is a planned feature but it has not yet been implemented. If your use-case benefits from reading in an OpenAPI 3.0.x document and also writing out an OpenAPI 3.0.x document then you can operate entirely against the `OpenAPIKit30` module. +#### 4.x to 5.x +If you are migrating from OpenAPIKit 4.x to OpenAPIKit 5.x, check out the [v5 migration guide](./documentation/migration_guides/v5_migration_guide.md). -#### 3.x to 4.x -If you are migrating from OpenAPIKit 3.x to OpenAPIKit 4.x, check out the [v4 migration guide](./documentation/v4_migration_guide.md). +Be aware of the change to minimum Swift version. ### Decoding OpenAPI Documents -Most documentation will focus on what it looks like to work with the `OpenAPIKit` module and OpenAPI 3.1.x documents. If you need to support OpenAPI 3.0.x documents, take a look at the section on [supporting OpenAPI 3.0.x documents](#supporting-openapi-30x-documents) before you get too deep into this library's docs. +Most documentation will focus on what it looks like to work with the `OpenAPIKit` module and OpenAPI 3.2.x documents. If you need to support OpenAPI 3.0.x documents, take a look at the section on [supporting OpenAPI 3.0.x documents](#supporting-openapi-30x-documents) before you get too deep into this library's docs. + +Version 3.2.x of the OpenAPI Specification is backwards compatible with version 3.1.x of the specification but it adds some new features. The OpenAPIKit types support these new features regardless of what the stated Document version is, but if a Document states that it is version 3.1.x and it uses OAS 3.2.x features then OpenAPIKit will produce a warning. If you run strict validations on the document, those warnings will be errors. If you choose not to run strict validations on the document, you can handle such a document leniently. You can decode a JSON OpenAPI document (i.e. using the `JSONDecoder` from **Foundation** library) or a YAML OpenAPI document (i.e. using the `YAMLDecoder` from the [**Yams**](https://github.com/jpsim/Yams) library) with the following code: ```swift @@ -148,21 +133,21 @@ You can use this same validation system to dig arbitrarily deep into an OpenAPI ### Supporting OpenAPI 3.0.x Documents If you need to operate on OpenAPI 3.0.x documents and only 3.0.x documents, you can use the `OpenAPIKit30` module throughout your code. -However, if you need to operate on both OpenAPI 3.0.x and 3.1.x documents, the recommendation is to use the OpenAPIKit compatibility layer to read in a 3.0.x document and convert it to a 3.1.x document so that you can use just the one set of Swift types throughout most of your program. An example of that follows. +However, if you need to operate on both OpenAPI 3.0.x and 3.1.x/3.2.x documents, the recommendation is to use the OpenAPIKit compatibility layer to read in a 3.0.x document and convert it to a 3.1.x or 3.2.x document so that you can use just the one set of Swift types throughout most of your program. An example of that follows. -In this example, only one file in the whole project needs to import `OpenAPIKit30` or `OpenAPIKitCompat`. Every other file would just import `OpenAPIKit` and work with the document in the 3.1.x format. +In this example, only one file in the whole project needs to import `OpenAPIKit30` or `OpenAPIKitCompat`. Every other file would just import `OpenAPIKit` and work with the document in the 3.2.x format. -#### Converting from 3.0.x to 3.1.x +#### Converting from 3.0.x to 3.2.x ```swift // import OpenAPIKit30 for OpenAPI 3.0 document support import OpenAPIKit30 -// import OpenAPIKit for OpenAPI 3.1 document support +// import OpenAPIKit for OpenAPI 3.2 document support import OpenAPIKit // import OpenAPIKitCompat to convert between the versions import OpenAPIKitCompat -// if most of your project just works with OpenAPI v3.1, most files only need to import OpenAPIKit. -// Only in the file where you are supporting converting from OpenAPI v3.0 to v3.1 do you need the +// if most of your project just works with OpenAPI v3.2, most files only need to import OpenAPIKit. +// Only in the file where you are supporting converting from OpenAPI v3.0 to v3.2 do you need the // other two imports. // we can support either version by attempting to parse an old version and then a new version if the old version fails @@ -171,12 +156,12 @@ let newDoc: OpenAPIKit.OpenAPI.Document oldDoc = try? JSONDecoder().decode(OpenAPI.Document.self, from: someFileData) -newDoc = oldDoc?.convert(to: .v3_1_1) ?? +newDoc = oldDoc?.convert(to: .v3_2_0) ?? (try! JSONDecoder().decode(OpenAPI.Document.self, from: someFileData)) -// ^ Here we simply fall-back to 3.1.x if loading as 3.0.x failed. You could do a more +// ^ Here we simply fall-back to 3.2.x if loading as 3.0.x failed. You could do a more // graceful job of this by determining up front which version to attempt to load or by // holding onto errors for each decode attempt so you can tell the user why the document -// failed to decode as neither 3.0.x nor 3.1.x if it fails in both cases. +// failed to decode as neither 3.0.x nor 3.2.x if it fails in both cases. ``` ### A note on dictionary ordering @@ -187,7 +172,7 @@ If retaining order is important for your use-case, I recommend the [**Yams**](ht The Foundation JSON encoding and decoding will be the most stable and battle-tested option with Yams as a pretty well established and stable option as well. FineJSON is lesser used (to my knowledge) but I have had success with it in the past. ### OpenAPI Document structure -The types used by this library largely mirror the object definitions found in the OpenAPI specification [version 3.1.1](https://spec.openapis.org/oas/v3.1.1.html) (`OpenAPIKit` module) and [version 3.0.4](https://spec.openapis.org/oas/v3.0.4.html) (`OpenAPIKit30` module). The [Project Status](#project-status) lists each object defined by the spec and the name of the respective type in this library. The project status page currently focuses on OpenAPI 3.1.x but for the purposes of determining what things are named and what is supported you can mostly infer the status of the OpenAPI 3.0.x support as well. +The types used by this library largely mirror the object definitions found in the OpenAPI specification [version 3.2.0](https://spec.openapis.org/oas/v3.2.0.html) (`OpenAPIKit` module) and [version 3.0.4](https://spec.openapis.org/oas/v3.0.4.html) (`OpenAPIKit30` module). The [Project Status](#project-status) lists each object defined by the spec and the name of the respective type in this library. The project status page currently focuses on OpenAPI 3.2.x but for the purposes of determining what things are named and what is supported you can mostly infer the status of the OpenAPI 3.0.x support as well. #### Document Root At the root there is an `OpenAPI.Document`. In addition to some information that applies to the entire API, the document contains `OpenAPI.Components` (essentially a dictionary of reusable components that can be referenced with `JSONReferences` and `OpenAPI.References`) and an `OpenAPI.PathItem.Map` (a dictionary of routes your API defines). @@ -210,7 +195,7 @@ A schema can be made **optional** (i.e. it can be omitted) with `JSONSchema.inte A schema can be made **nullable** with `JSONSchema.number(nullable: true)` or an existing schema can be asked for a `nullableSchemaObject()`. -Nullability highlights an important decision OpenAPIKit makes. The JSON Schema specification that dictates how OpenAPI v3.1 documents _encode_ nullability states that a nullable property is encoded as having the `null` type in addition to whatever other type(s) it has. So in OpenAPIKit you set `nullability` as a property of a schema, but when encoded/decoded it will represent the inclusion of absence of `null` in the list of `type`s of the schema. If you are using the `OpenAPIKit30` module then nullability is encoded as a `nullable` property per the OpenAPI 3.0.x specification. +Nullability highlights an important decision OpenAPIKit makes. The JSON Schema specification that dictates how OpenAPI v3.2 documents _encode_ nullability states that a nullable property is encoded as having the `null` type in addition to whatever other type(s) it has. So in OpenAPIKit you set `nullability` as a property of a schema, but when encoded/decoded it will represent the inclusion of absence of `null` in the list of `type`s of the schema. If you are using the `OpenAPIKit30` module then nullability is encoded as a `nullable` property per the OpenAPI 3.0.x specification. Some types of schemas can be further specialized with a **format**. For example, `JSONSchema.number(format: .double)` or `JSONSchema.string(format: .dateTime)`. @@ -311,7 +296,7 @@ let document = OpenAPI.Document( ``` #### Specification Extensions -Many OpenAPIKit types support [Specification Extensions](https://spec.openapis.org/oas/v3.1.1.html#specification-extensions). As described in the OpenAPI Specification, these extensions must be objects that are keyed with the prefix "x-". For example, a property named "specialProperty" on the root OpenAPI Object (`OpenAPI.Document`) is invalid but the property "x-specialProperty" is a valid specification extension. +Many OpenAPIKit types support [Specification Extensions](https://spec.openapis.org/oas/v3.2.0.html#specification-extensions). As described in the OpenAPI Specification, these extensions must be objects that are keyed with the prefix "x-". For example, a property named "specialProperty" on the root OpenAPI Object (`OpenAPI.Document`) is invalid but the property "x-specialProperty" is a valid specification extension. You can get or set specification extensions via the [`vendorExtensions`](https://mattpolzin.github.io/OpenAPIKit/documentation/openapikit/vendorextendable/vendorextensions-swift.property) property on any object that supports this feature. The keys are `Strings` beginning with the aforementioned "x-" prefix and the values are `AnyCodable`. If you set an extension without using the "x-" prefix, the prefix will be added upon encoding. diff --git a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift index 0ca1f5caa..fc709c9b2 100644 --- a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift +++ b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift @@ -10,30 +10,36 @@ import OpenAPIKitCore extension OpenAPI.Components { /// Check if the `Components` contains the given reference or not. /// - /// Look up a reference in this components dictionary. If you want a - /// non-throwing alternative, you can pull a `JSONReference.InternalReference` - /// out of the `reference` (which is of type `JSONReference`) and pass that to `contains` - /// instead. + /// This may in some cases mean that the `Components` entry for the given + /// reference is itself another reference (e.g. entries in the `responses` + /// dictionary are allowed to be references). + /// + /// If you want a non-throwing alternative, you can pull a + /// `JSONReference.InternalReference` out of the `reference` and pass that + /// to `contains` instead. /// /// - Throws: If the given reference cannot be checked against `Components` /// then this method will throw `ReferenceError`. This will occur when /// the given reference is a remote file reference. - public func contains(_ reference: OpenAPI.Reference) throws -> Bool { + public func contains(_ reference: OpenAPI.Reference) throws -> Bool { return try contains(reference.jsonReference) } /// Check if the `Components` contains the given reference or not. /// - /// Look up a reference in this components dictionary. If you want a - /// non-throwing alternative, you can pull a `JSONReference.InternalReference` - /// out of your `JSONReference` and pass that to `contains` - /// instead. + /// This may in some cases mean that the `Components` entry for the given + /// reference is itself another reference (e.g. entries in the `responses` + /// dictionary are allowed to be references). + /// + /// If you want a non-throwing alternative, you can pull a + /// `JSONReference.InternalReference` out of your `reference` and pass that + /// to `contains` instead. /// /// - Throws: If the given reference cannot be checked against `Components` /// then this method will throw `ReferenceError`. This will occur when /// the given reference is a remote file reference. - public func contains(_ reference: JSONReference) throws -> Bool { + public func contains(_ reference: JSONReference) throws -> Bool { guard case .internal(let localReference) = reference else { throw ReferenceError.cannotLookupRemoteReference } @@ -42,16 +48,34 @@ extension OpenAPI.Components { } /// Check if the `Components` contains the given internal reference or not. - public func contains(_ reference: JSONReference.InternalReference) -> Bool { - return reference.name - .flatMap(OpenAPI.ComponentKey.init(rawValue:)) - .map { self[keyPath: ReferenceType.openAPIComponentsKeyPath].contains(key: $0) } - ?? false + /// + /// This may in some cases mean that the `Components` entry for the given + /// reference is itself another reference (e.g. entries in the `responses` + /// dictionary are allowed to be references). + public func contains(_ reference: JSONReference.InternalReference) -> Bool { + switch ReferenceType.openAPIComponentsKeyPath { + case .a(let directPath): + return reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .map { self[keyPath: directPath].contains(key: $0) } + ?? false + case .b(let referencePath): + return reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .map { self[keyPath: referencePath].contains(key: $0) } + ?? false + } } - /// Retrieve a referenced item from the `Components` or - /// just return the item directly depending on what value - /// the `Either` contains. + /// Retrieve a referenced item from the `Components` or just return the + /// item directly depending on what value the `Either` contains. + /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// + /// If you want a throwing lookup, use the `lookup()` method. public subscript(_ maybeReference: Either, ReferenceType>) -> ReferenceType? { switch maybeReference { case .a(let reference): @@ -63,6 +87,11 @@ extension OpenAPI.Components { /// Retrieve item referenced from the `Components`. /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// /// If you want a throwing lookup, use the `lookup()` method. public subscript(_ reference: OpenAPI.Reference) -> ReferenceType? { @@ -71,6 +100,11 @@ extension OpenAPI.Components { /// Retrieve item referenced from the `Components`. /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// /// If you want a throwing lookup, use the `lookup()` method. public subscript(_ reference: JSONReference) -> ReferenceType? { guard case .internal(let localReference) = reference else { @@ -82,11 +116,57 @@ extension OpenAPI.Components { /// Retrieve item referenced from the `Components`. /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// /// If you want a throwing lookup, use the `lookup()` method. public subscript(_ reference: JSONReference.InternalReference) -> ReferenceType? { - return reference.name - .flatMap(OpenAPI.ComponentKey.init(rawValue:)) - .flatMap { self[keyPath: ReferenceType.openAPIComponentsKeyPath][$0] } + return try? lookup(reference) + } + + /// Pass a reference to a component. + /// `lookupOnce()` will return the component value if it is found + /// in the Components Object. + /// + /// The value may itself be a reference. If you want to follow all + /// references until the ReferenceType is found, use `lookup()`. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// If the `OpenAPI.Reference` has a `summary` or `description` then the referenced + /// object will have its `summary` and/or `description` overridden by that of the reference. + /// This only applies if the referenced object would normally have a summary/description. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` + public func lookupOnce(_ reference: OpenAPI.Reference) throws -> Either, ReferenceType> { + let value = try lookupOnce(reference.jsonReference) + + switch value { + case .a(let reference): + return .a( + reference + .overriddenNonNil(summary: reference.summary) + .overriddenNonNil(description: reference.description) + ) + + case .b(let direct): + return .b( + direct + .overriddenNonNil(summary: reference.summary) + .overriddenNonNil(description: reference.description) + ) + } } /// Pass a reference to a component. @@ -108,9 +188,8 @@ extension OpenAPI.Components { /// is not currently supported by OpenAPIKit and will therefore always throw an error. /// /// - Throws: `ReferenceError.cannotLookupRemoteReference` or - /// `MissingReferenceError.referenceMissingOnLookup(name:)` depending - /// on whether the reference points to another file or just points to a component in - /// the same file that cannot be found in the Components Object. + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` public func lookup(_ reference: OpenAPI.Reference) throws -> ReferenceType { return try lookup(reference.jsonReference) @@ -118,6 +197,32 @@ extension OpenAPI.Components { .overriddenNonNil(description: reference.description) } + /// Pass a reference to a component. + /// `lookupOnce()` will return the component value if it is found + /// in the Components Object. + /// + /// The value may itself be a reference. If you want to follow all + /// references until the ReferenceType is found, use `lookup()`. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` + public func lookupOnce(_ reference: JSONReference) throws -> Either, ReferenceType> { + guard case let .internal(internalReference) = reference else { + throw ReferenceError.cannotLookupRemoteReference + } + return try lookupOnce(internalReference) + } + /// Pass a reference to a component. /// `lookup()` will return the component value if it is found /// in the Components Object. @@ -133,19 +238,116 @@ extension OpenAPI.Components { /// is not currently supported by OpenAPIKit and will therefore always throw an error. /// /// - Throws: `ReferenceError.cannotLookupRemoteReference` or - /// `MissingReferenceError.referenceMissingOnLookup(name:)` depending - /// on whether the reference points to another file or just points to a component in - /// the same file that cannot be found in the Components Object. + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` public func lookup(_ reference: JSONReference) throws -> ReferenceType { - guard case .internal = reference else { + guard case let .internal(internalReference) = reference else { throw ReferenceError.cannotLookupRemoteReference } - guard let value = self[reference] else { + return try lookup(internalReference) + } + + internal func _lookup(_ reference: JSONReference, following visitedReferences: Set = .init()) throws -> ReferenceType { + guard case let .internal(internalReference) = reference else { + throw ReferenceError.cannotLookupRemoteReference + } + return try _lookup(internalReference, following: visitedReferences) + } + + /// Pass a reference to a component. + /// `lookupOnce()` will return the component value if it is found + /// in the Components Object. + /// + /// The value may itself be a reference. If you want to follow all + /// references until the ReferenceType is found, use `lookup()`. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` + public func lookupOnce(_ reference: JSONReference.InternalReference) throws -> Either, ReferenceType> { + let value: Either, ReferenceType>? + switch ReferenceType.openAPIComponentsKeyPath { + case .a(let directPath): + value = reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .flatMap { self[keyPath: directPath][$0] } + .map { .b($0) } + + case .b(let referencePath): + value = reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .flatMap { self[keyPath: referencePath][$0] } + } + guard let value else { throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) } return value } + /// Pass a reference to a component. + /// `lookup()` will return the component value if it is found + /// in the Components Object. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` + public func lookup(_ reference: JSONReference.InternalReference) throws -> ReferenceType { + return try _lookup(reference) + } + + internal func _lookup(_ reference: JSONReference.InternalReference, following visitedReferences: Set = .init()) throws -> ReferenceType { + if visitedReferences.contains(reference) { + throw ReferenceCycleError(ref: reference.rawValue) + } + + switch ReferenceType.openAPIComponentsKeyPath { + case .a(let directPath): + let value: ReferenceType? = reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .flatMap { self[keyPath: directPath][$0] } + + guard let value else { + throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) + } + return value + + case .b(let referencePath): + let possibleValue: Either, ReferenceType>? = reference.name + .flatMap(OpenAPI.ComponentKey.init(rawValue:)) + .flatMap { self[keyPath: referencePath][$0] } + + guard let possibleValue else { + throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) + } + + switch possibleValue { + case .a(let newReference): + return try _lookup(newReference.jsonReference, following: visitedReferences.union([reference])) + case .b(let value): + return value + } + } + } + /// Pass an `Either` with a reference or a component. /// `lookup()` will return the component value if it is found /// in the Components Object. @@ -161,9 +363,8 @@ extension OpenAPI.Components { /// is not currently supported by OpenAPIKit and will therefore always throw an error. /// /// - Throws: `ReferenceError.cannotLookupRemoteReference` or - /// `MissingReferenceError.referenceMissingOnLookup(name:)` depending - /// on whether the reference points to another file or just points to a component in - /// the same file that cannot be found in the Components Object. + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` public func lookup(_ maybeReference: Either, ReferenceType>) throws -> ReferenceType { switch maybeReference { case .a(let reference): @@ -215,7 +416,7 @@ extension OpenAPI.Components { public let ref: String public var description: String { - return "Encountered a JSON Schema $ref cycle that prevents fully dereferencing document at '\(ref)'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy." + return "Encountered a JSON Schema $ref cycle that prevents fully resolving a reference at '\(ref)'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy or fully looking up references in cycles. `lookupOnce()` is your best option in this case." } public var localizedDescription: String { diff --git a/Sources/OpenAPIKit/Components Object/Components+Locatable.swift b/Sources/OpenAPIKit/Components Object/Components+Locatable.swift index c1a56f0f0..441ba805f 100644 --- a/Sources/OpenAPIKit/Components Object/Components+Locatable.swift +++ b/Sources/OpenAPIKit/Components Object/Components+Locatable.swift @@ -16,57 +16,57 @@ public protocol ComponentDictionaryLocatable: SummaryOverridable { /// This can be used to create a JSON path /// like `#/name1/name2/name3` static var openAPIComponentsKey: String { get } - static var openAPIComponentsKeyPath: WritableKeyPath> { get } + static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { get } } extension JSONSchema: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "schemas" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.schemas } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .a(\.schemas) } } extension OpenAPI.Response: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "responses" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.responses } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.responses) } } extension OpenAPI.Callbacks: ComponentDictionaryLocatable & SummaryOverridable { public static var openAPIComponentsKey: String { "callbacks" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.callbacks } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.callbacks) } } extension OpenAPI.Parameter: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "parameters" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.parameters } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.parameters) } } extension OpenAPI.Example: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "examples" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.examples } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.examples) } } extension OpenAPI.Request: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "requestBodies" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.requestBodies } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.requestBodies) } } extension OpenAPI.Header: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "headers" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.headers } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.headers) } } extension OpenAPI.SecurityScheme: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "securitySchemes" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.securitySchemes } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.securitySchemes) } } extension OpenAPI.Link: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "links" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.links } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .b(\.links) } } extension OpenAPI.PathItem: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "pathItems" } - public static var openAPIComponentsKeyPath: WritableKeyPath> { \.pathItems } + public static var openAPIComponentsKeyPath: Either>, WritableKeyPath>> { .a(\.pathItems) } } /// A dereferenceable type can be recursively looked up in diff --git a/Sources/OpenAPIKit/Components Object/Components.swift b/Sources/OpenAPIKit/Components Object/Components.swift index 284ca030f..035644c43 100644 --- a/Sources/OpenAPIKit/Components Object/Components.swift +++ b/Sources/OpenAPIKit/Components Object/Components.swift @@ -15,17 +15,47 @@ extension OpenAPI { /// /// This is a place to put reusable components to /// be referenced from other parts of the spec. + /// + /// Most of the components dictionaries can contain either the component + /// directly or a $ref to the component. This distinction can be seen in + /// the types as either `ComponentDictionary` (direct) or + /// `ComponentReferenceDictionary` (direct or by-reference). + /// + /// If you are building a Components Object in Swift you may choose to make + /// all of your components direct in which case the + /// `OpenAPI.Components.direct()` convenience constructor will save you + /// some typing and verbosity. + /// + /// **Example** + /// OpenAPI.Components( + /// parameters: [ "my_param": .parameter(.cookie(name: "my_param", schema: .string)) ] + /// ) + /// + /// // The above value is the same as the below value + /// + /// OpenAPI.Components.direct( + /// parameters: [ "my_param": .cookie(name: "my_param", schema: .string) ] + /// ) + /// + /// // However, the `init()` initializer does allow you to use references where desired + /// + /// OpenAPI.Components( + /// parameters: [ + /// "my_direct_param": .parameter(.cookie(name: "my_param", schema: .string)), + /// "my_param": .reference(.component(named: "my_direct_param")) + /// ] + /// ) public struct Components: Equatable, CodableVendorExtendable, Sendable { public var schemas: ComponentDictionary - public var responses: ComponentDictionary - public var parameters: ComponentDictionary - public var examples: ComponentDictionary - public var requestBodies: ComponentDictionary - public var headers: ComponentDictionary
- public var securitySchemes: ComponentDictionary - public var links: ComponentDictionary - public var callbacks: ComponentDictionary + public var responses: ComponentReferenceDictionary + public var parameters: ComponentReferenceDictionary + public var examples: ComponentReferenceDictionary + public var requestBodies: ComponentReferenceDictionary + public var headers: ComponentReferenceDictionary
+ public var securitySchemes: ComponentReferenceDictionary + public var links: ComponentReferenceDictionary + public var callbacks: ComponentReferenceDictionary public var pathItems: ComponentDictionary @@ -38,14 +68,14 @@ extension OpenAPI { public init( schemas: ComponentDictionary = [:], - responses: ComponentDictionary = [:], - parameters: ComponentDictionary = [:], - examples: ComponentDictionary = [:], - requestBodies: ComponentDictionary = [:], - headers: ComponentDictionary
= [:], - securitySchemes: ComponentDictionary = [:], - links: ComponentDictionary = [:], - callbacks: ComponentDictionary = [:], + responses: ComponentReferenceDictionary = [:], + parameters: ComponentReferenceDictionary = [:], + examples: ComponentReferenceDictionary = [:], + requestBodies: ComponentReferenceDictionary = [:], + headers: ComponentReferenceDictionary
= [:], + securitySchemes: ComponentReferenceDictionary = [:], + links: ComponentReferenceDictionary = [:], + callbacks: ComponentReferenceDictionary = [:], pathItems: ComponentDictionary = [:], vendorExtensions: [String: AnyCodable] = [:] ) { @@ -62,6 +92,37 @@ extension OpenAPI { self.vendorExtensions = vendorExtensions } + /// Construct components as "direct" entries (no references). When + /// building a document in Swift code, this is often sufficient and it + /// means you don't need to wrap every entry in an `Either`. + public static func direct( + schemas: ComponentDictionary = [:], + responses: ComponentDictionary = [:], + parameters: ComponentDictionary = [:], + examples: ComponentDictionary = [:], + requestBodies: ComponentDictionary = [:], + headers: ComponentDictionary
= [:], + securitySchemes: ComponentDictionary = [:], + links: ComponentDictionary = [:], + callbacks: ComponentDictionary = [:], + pathItems: ComponentDictionary = [:], + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + schemas: schemas, + responses: responses.mapValues { .b($0) }, + parameters: parameters.mapValues { .b($0) }, + examples: examples.mapValues { .b($0) }, + requestBodies: requestBodies.mapValues { .b($0) }, + headers: headers.mapValues { .b($0) }, + securitySchemes: securitySchemes.mapValues { .b($0) }, + links: links.mapValues { .b($0) }, + callbacks: callbacks.mapValues { .b($0) }, + pathItems: pathItems, + vendorExtensions: vendorExtensions + ) + } + /// An empty OpenAPI Components Object. public static let noComponents: Components = .init() @@ -71,6 +132,12 @@ extension OpenAPI { } } +extension OpenAPI { + + public typealias ComponentDictionary = OrderedDictionary + public typealias ComponentReferenceDictionary = OrderedDictionary, T>> +} + extension OpenAPI.Components { public struct ComponentCollision: Swift.Error { public let componentType: String @@ -130,11 +197,6 @@ extension OpenAPI.Components { public static let componentNameExtension: String = "x-component-name" } -extension OpenAPI { - - public typealias ComponentDictionary = OrderedDictionary -} - // MARK: - Codable extension OpenAPI.Components: Encodable { public func encode(to encoder: Encoder) throws { @@ -194,30 +256,36 @@ extension OpenAPI.Components: Decodable { schemas = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .schemas) ?? [:] - responses = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .responses) + responses = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .responses) ?? [:] - parameters = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .parameters) + parameters = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .parameters) ?? [:] - examples = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .examples) + examples = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .examples) ?? [:] - requestBodies = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .requestBodies) + requestBodies = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .requestBodies) ?? [:] - headers = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .headers) + headers = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .headers) ?? [:] - securitySchemes = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .securitySchemes) ?? [:] + securitySchemes = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .securitySchemes) ?? [:] - links = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .links) ?? [:] + links = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .links) ?? [:] - callbacks = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .callbacks) ?? [:] + callbacks = try container.decodeIfPresent(OpenAPI.ComponentReferenceDictionary.self, forKey: .callbacks) ?? [:] pathItems = try container.decodeIfPresent(OpenAPI.ComponentDictionary.self, forKey: .pathItems) ?? [:] vendorExtensions = try Self.extensions(from: decoder) + } catch let error as EitherDecodeNoTypesMatchedError { + if let underlyingError = OpenAPI.Error.Decoding.Document.eitherBranchToDigInto(error) { + throw (underlyingError.underlyingError ?? underlyingError) + } + + throw error } catch let error as DecodingError { if let underlyingError = error.underlyingError as? KeyDecodingError { throw GenericError( diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index f0bb45300..d2a591c85 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -6,6 +6,7 @@ // import OpenAPIKitCore +import Foundation extension OpenAPI { /// The root of an OpenAPI 3.1 document. @@ -45,7 +46,7 @@ extension OpenAPI { /// /// See the documentation on `DereferencedDocument.resolved()` for more. /// - public struct Document: HasWarnings, CodableVendorExtendable, Sendable { + public struct Document: HasConditionalWarnings, HasWarnings, CodableVendorExtendable, Sendable { /// OpenAPI Spec "openapi" field. /// /// OpenAPIKit only explicitly supports versions that can be found in @@ -53,6 +54,9 @@ extension OpenAPI { /// by OpenAPIKit to a certain extent. public var openAPIVersion: Version + /// OpenAPI Spec "$self" field. + public var selfURI: URL? + /// Information about the API described by this OpenAPI Document. /// /// Licensing, Terms of Service, contact information, API version (the @@ -142,9 +146,11 @@ extension OpenAPI { public var vendorExtensions: [String: AnyCodable] public let warnings: [Warning] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] public init( openAPIVersion: Version = .v3_1_1, + selfURI: URL? = nil, info: Info, servers: [Server], paths: PathItem.Map, @@ -156,6 +162,7 @@ extension OpenAPI { vendorExtensions: [String: AnyCodable] = [:] ) { self.openAPIVersion = openAPIVersion + self.selfURI = selfURI self.info = info self.servers = servers self.paths = paths @@ -167,13 +174,28 @@ extension OpenAPI { self.vendorExtensions = vendorExtensions self.warnings = [] + + self.conditionalWarnings = [ + // If $self is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "$self", value: selfURI, minimumVersion: .v3_2_0), + ].compactMap { $0 } } } } +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Document \(fieldName) field" + ) + } +} + extension OpenAPI.Document: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { lhs.openAPIVersion == rhs.openAPIVersion + && lhs.selfURI == rhs.selfURI && lhs.info == rhs.info && lhs.servers == rhs.servers && lhs.paths == rhs.paths @@ -449,43 +471,123 @@ extension OpenAPI.Document { /// specification releases a new patch version, OpenAPIKit will see a patch version release /// explicitly supports decoding documents of that new patch version before said version will /// succesfully decode as the `v3_1_x` case. - public enum Version: RawRepresentable, Equatable, Codable, Sendable { + public enum Version: RawRepresentable, Equatable, Comparable, Codable, Sendable { case v3_1_0 case v3_1_1 + case v3_1_2 case v3_1_x(x: Int) - public static let v3_1_2 : Self = .v3_1_x(x: 2) - - public init?(rawValue: String) { - switch rawValue { - case "3.1.0": self = .v3_1_0 - case "3.1.1": self = .v3_1_1 - default: - let components = rawValue.split(separator: ".") - guard components.count == 3 else { - return nil - } - guard components[0] == "3", components[1] == "1" else { - return nil - } - guard let patchVersion = Int(components[2], radix: 10) else { - return nil - } - // to support newer versions released in the future without a breaking - // change to the enumeration, bump the upper limit here to e.g. 2 or 3 - // or 6: - guard patchVersion > 1 && patchVersion <= 2 else { - return nil - } - self = .v3_1_x(x: patchVersion) - } - } + case v3_2_0 + case v3_2_x(x: Int) + + public init?(rawValue: String) { + switch rawValue { + case "3.1.0": self = .v3_1_0 + case "3.1.1": self = .v3_1_1 + case "3.1.2": self = .v3_1_2 + case "3.2.0": self = .v3_2_0 + default: + let components = rawValue.split(separator: ".") + guard components.count == 3 else { + return nil + } + let minorVersion = components[1] + guard components[0] == "3", (minorVersion == "1" || minorVersion == "2") else { + return nil + } + guard let patchVersion = Int(components[2], radix: 10) else { + return nil + } + // to support newer versions released in the future without a breaking + // change to the enumeration, bump the upper limit here to e.g. 2 or 3 + // or 6: + if minorVersion == "2" { + guard patchVersion > 0 && patchVersion <= 0 else { + return nil + } + self = .v3_2_x(x: patchVersion) + } else { + guard patchVersion > 2 && patchVersion <= 2 else { + return nil + } + self = .v3_1_x(x: patchVersion) + } + } + } public var rawValue: String { switch self { case .v3_1_0: return "3.1.0" case .v3_1_1: return "3.1.1" + case .v3_1_2: return "3.1.2" case .v3_1_x(x: let x): return "3.1.\(x)" + + case .v3_2_0: return "3.2.0" + case .v3_2_x(x: let x): return "3.2.\(x)" + } + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + switch lhs { + case .v3_1_0: + switch rhs { + case .v3_1_0: false + case .v3_1_1: true + case .v3_1_2: true + case .v3_1_x(x: let x): 0 < x + case .v3_2_0: true + case .v3_2_x(x: _): true + } + + case .v3_1_1: + switch rhs { + case .v3_1_0: false + case .v3_1_1: false + case .v3_1_2: true + case .v3_1_x(x: let y): 1 < y + case .v3_2_0: true + case .v3_2_x(x: _): true + } + + case .v3_1_2: + switch rhs { + case .v3_1_0: false + case .v3_1_1: false + case .v3_1_2: false + case .v3_1_x(x: let y): 2 < y + case .v3_2_0: true + case .v3_2_x(x: _): true + } + + case .v3_1_x(x: let x): + switch rhs { + case .v3_1_0: x < 0 + case .v3_1_1: x < 1 + case .v3_1_2: x < 2 + case .v3_1_x(x: let y): x < y + case .v3_2_0: true + case .v3_2_x(x: _): true + } + + case .v3_2_0: + switch rhs { + case .v3_1_0: false + case .v3_1_1: false + case .v3_1_2: false + case .v3_1_x(x: _): false + case .v3_2_0: false + case .v3_2_x(x: let y): 0 < y + } + + case .v3_2_x(x: let x): + switch rhs { + case .v3_1_0: false + case .v3_1_1: false + case .v3_1_2: false + case .v3_1_x(x: _): false + case .v3_2_0: x < 0 + case .v3_2_x(x: let y): x < y + } } } } @@ -522,6 +624,9 @@ extension OpenAPI.Document: Encodable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(openAPIVersion, forKey: .openAPIVersion) + + try container.encodeIfPresent(selfURI?.absoluteString, forKey: .selfURI) + try container.encode(info, forKey: .info) try container.encodeIfPresent(externalDocs, forKey: .externalDocs) @@ -581,6 +686,11 @@ extension OpenAPI.Document: Decodable { ) } + let selfURIString: String? = try container.decodeIfPresent(String.self, forKey: .selfURI) + selfURI = try selfURIString.map { + try decodeURIString($0, forKey: CodingKeys.selfURI, atPath: decoder.codingPath) + } + info = try container.decode(OpenAPI.Document.Info.self, forKey: .info) servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) ?? [] @@ -601,6 +711,11 @@ extension OpenAPI.Document: Decodable { self.warnings = warnings + self.conditionalWarnings = [ + // If $self is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "$self", value: selfURI, minimumVersion: .v3_2_0), + ].compactMap { $0 } + } catch let error as OpenAPI.Error.Decoding.Path { throw OpenAPI.Error.Decoding.Document(error) @@ -617,9 +732,34 @@ extension OpenAPI.Document: Decodable { } } +fileprivate func decodeURIString(_ str: String, forKey key: CodingKey, atPath path: [CodingKey]) throws -> URL { + let uri: URL? + #if canImport(FoundationEssentials) + uri = URL(string: str, encodingInvalidCharacters: false) + #elseif os(macOS) || os(iOS) || os(watchOS) || os(tvOS) + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { + uri = URL(string: str, encodingInvalidCharacters: false) + } else { + uri = URL(string: str) + } + #else + uri = URL(string: str) + #endif + guard let uri else { + throw GenericError( + subjectName: key.stringValue, + details: "Failed to parse a valid URI from '\(str)'", + codingPath: path + ) + } + + return uri +} + extension OpenAPI.Document { internal enum CodingKeys: ExtendableCodingKey { case openAPIVersion + case selfURI case info case jsonSchemaDialect // TODO: implement parsing (https://github.com/mattpolzin/OpenAPIKit/issues/202) case servers @@ -634,6 +774,7 @@ extension OpenAPI.Document { static var allBuiltinKeys: [CodingKeys] { return [ .openAPIVersion, + .selfURI, .info, .jsonSchemaDialect, .servers, @@ -654,6 +795,8 @@ extension OpenAPI.Document { switch stringValue { case "openapi": self = .openAPIVersion + case "$self": + self = .selfURI case "info": self = .info case "jsonSchemaDialect": @@ -681,6 +824,8 @@ extension OpenAPI.Document { switch self { case .openAPIVersion: return "openapi" + case .selfURI: + return "$self" case .info: return "info" case .jsonSchemaDialect: diff --git a/Sources/OpenAPIKit/Either/Either+Convenience.swift b/Sources/OpenAPIKit/Either/Either+Convenience.swift index f468f0ca2..bbc414460 100644 --- a/Sources/OpenAPIKit/Either/Either+Convenience.swift +++ b/Sources/OpenAPIKit/Either/Either+Convenience.swift @@ -131,6 +131,16 @@ extension Either where B == OpenAPI.Header { public var headerValue: B? { b } } +extension Either where B == OpenAPI.Callbacks { + /// Retrieve the callbacks if that is what this property contains. + public var callbacksValue: B? { b } +} + +extension Either where B == OpenAPI.SecurityScheme { + /// Retrieve the security scheme if that is what this property contains. + public var securitySchemeValue: B? { b } +} + // MARK: - Convenience constructors extension Either where A == Bool { /// Construct a boolean value. @@ -169,6 +179,8 @@ extension Either where B == OpenAPI.PathItem { head: OpenAPI.Operation? = nil, patch: OpenAPI.Operation? = nil, trace: OpenAPI.Operation? = nil, + query: OpenAPI.Operation? = nil, + additionalOperations: OrderedDictionary = [:], vendorExtensions: [String: AnyCodable] = [:] ) { self = .b( @@ -185,6 +197,8 @@ extension Either where B == OpenAPI.PathItem { head: head, patch: patch, trace: trace, + query: query, + additionalOperations: additionalOperations, vendorExtensions: vendorExtensions ) ) @@ -216,7 +230,22 @@ extension Either where B == OpenAPI.Response { public static func response(_ response: OpenAPI.Response) -> Self { .b(response) } } +extension Either where B == OpenAPI.Link { + /// Construct a link value. + public static func link(_ link: OpenAPI.Link) -> Self { .b(link) } +} + extension Either where B == OpenAPI.Header { /// Construct a header value. public static func header(_ header: OpenAPI.Header) -> Self { .b(header) } } + +extension Either where B == OpenAPI.Callbacks { + /// Construct a callbacks value. + public static func callbacks(_ callbacks: OpenAPI.Callbacks) -> Self { .b(callbacks) } +} + +extension Either where B == OpenAPI.SecurityScheme { + /// Construct a security scheme value. + public static func securityScheme(_ securityScheme: OpenAPI.SecurityScheme) -> Self { .b(securityScheme) } +} diff --git a/Sources/OpenAPIKit/Encoding and Decoding Errors/PathDecodingError.swift b/Sources/OpenAPIKit/Encoding and Decoding Errors/PathDecodingError.swift index 4de8c4e44..30023d0ab 100644 --- a/Sources/OpenAPIKit/Encoding and Decoding Errors/PathDecodingError.swift +++ b/Sources/OpenAPIKit/Encoding and Decoding Errors/PathDecodingError.swift @@ -104,7 +104,7 @@ extension OpenAPI.Error.Decoding.Path { internal init(_ error: DecodingError) { var codingPath = error.codingPathWithoutSubject.dropFirst() - let route = OpenAPI.Path(rawValue: codingPath.removeFirst().stringValue) + let route = OpenAPI.Path(rawValue: codingPath.removeFirstPathComponentString()) path = route context = .other(error) @@ -113,7 +113,7 @@ extension OpenAPI.Error.Decoding.Path { internal init(_ error: OpenAPI.Error.Decoding.Operation) { var codingPath = error.codingPath.dropFirst() - let route = OpenAPI.Path(rawValue: codingPath.removeFirst().stringValue) + let route = OpenAPI.Path(rawValue: codingPath.removeFirstPathComponentString()) path = route context = .endpoint(error) @@ -122,7 +122,7 @@ extension OpenAPI.Error.Decoding.Path { internal init(_ error: GenericError) { var codingPath = error.codingPath.dropFirst() - let route = OpenAPI.Path(rawValue: codingPath.removeFirst().stringValue) + let route = OpenAPI.Path(rawValue: codingPath.removeFirstPathComponentString()) path = route context = .inconsistency(error) @@ -148,7 +148,7 @@ extension OpenAPI.Error.Decoding.Path { // } var codingPath = eitherError.codingPath.dropFirst() - let route = OpenAPI.Path(rawValue: codingPath.removeFirst().stringValue) + let route = OpenAPI.Path(rawValue: codingPath.removeFirstPathComponentString()) path = route context = .neither(eitherError) diff --git a/Sources/OpenAPIKit/JSONReference.swift b/Sources/OpenAPIKit/JSONReference.swift index 4966193d1..bc7c8a502 100644 --- a/Sources/OpenAPIKit/JSONReference.swift +++ b/Sources/OpenAPIKit/JSONReference.swift @@ -428,6 +428,20 @@ public protocol OpenAPISummarizable: OpenAPIDescribable { func overriddenNonNil(summary: String?) -> Self } +extension OpenAPI.Reference: OpenAPISummarizable { + public func overriddenNonNil(summary: String?) -> Self { + guard let summary else { return self } + + return .init(jsonReference, summary: summary, description: description) + } + + public func overriddenNonNil(description: String?) -> Self { + guard let description else { return self } + + return .init(jsonReference, summary: summary, description: description) + } +} + // MARK: - Codable extension JSONReference { @@ -558,7 +572,12 @@ extension JSONReference: ExternallyDereferenceable where ReferenceType: External let componentKey = try loader.componentKey(type: ReferenceType.self, at: url) let (component, messages): (ReferenceType, [Loader.Message]) = try await loader.load(url) var components = OpenAPI.Components() - components[keyPath: ReferenceType.openAPIComponentsKeyPath][componentKey] = component + switch ReferenceType.openAPIComponentsKeyPath { + case .a(let directPath): + components[keyPath: directPath][componentKey] = component + case .b(let referencePath): + components[keyPath: referencePath][componentKey] = .b(component) + } return (try components.reference(named: componentKey.rawValue, ofType: ReferenceType.self).jsonReference, components, messages) } } diff --git a/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift new file mode 100644 index 000000000..6e47cab74 --- /dev/null +++ b/Sources/OpenAPIKit/OpenAPIConditionalWarnings.swift @@ -0,0 +1,61 @@ +public protocol Condition: Equatable, Sendable { + /// Given an entire OpenAPI Document, determine the applicability of the + /// condition. + func applies(to: OpenAPI.Document) -> Bool +} + +public protocol HasConditionalWarnings { + /// Warnings that only apply if the paired condition is met. + /// + /// Among other things, this allows OpenAPIKit to generate a warning in + /// some nested type that only applies if the OpenAPI Standards version of + /// the document is less than a certain version. + var conditionalWarnings: [(any Condition, OpenAPI.Warning)] { get } +} + +extension HasConditionalWarnings { + public func applicableConditionalWarnings(for subject: OpenAPI.Document) -> [OpenAPI.Warning] { + conditionalWarnings.compactMap { (condition, warning) in + guard condition.applies(to: subject) else { return nil } + + return warning + } + } +} + +internal struct DocumentVersionCondition: Sendable, Condition { + enum Comparator: Sendable { + case lessThan + case equal + case greaterThan + } + + let version: OpenAPI.Document.Version + let comparator: Comparator + + func applies(to document: OpenAPI.Document) -> Bool { + switch comparator { + case .lessThan: document.openAPIVersion < version + + case .equal: document.openAPIVersion == version + + case .greaterThan: document.openAPIVersion > version + } + } +} + +internal extension OpenAPI.Document { + struct ConditionalWarnings { + static func version(lessThan version: OpenAPI.Document.Version, doesNotSupport subject: String) -> (any Condition, OpenAPI.Warning) { + let warning = OpenAPI.Warning.message("\(subject) is only supported for OpenAPI document versions \(version.rawValue) and later") + + return (DocumentVersionCondition(version: version, comparator: .lessThan), warning) + } + + static func version(lessThan version: OpenAPI.Document.Version, doesNotAllowOptional subject: String) -> (any Condition, OpenAPI.Warning) { + let warning = OpenAPI.Warning.message("\(subject) cannot be nil for OpenAPI document versions lower than \(version.rawValue)") + + return (DocumentVersionCondition(version: version, comparator: .lessThan), warning) + } + } +} diff --git a/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift b/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift index 4b5002ca3..23c079e82 100644 --- a/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift +++ b/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift @@ -90,26 +90,59 @@ extension OpenAPI.Parameter: ExternallyDereferenceable { // next line: // let (newSchemaOrContent, components) = try await schemaOrContent.externallyDereferenced(with: loader) - let newSchemaOrContent: Either + let newContext: OpenAPI.Parameter.Context let newComponents: OpenAPI.Components let newMessages: [Loader.Message] - switch schemaOrContent { - case .a(let schemaContext): - let (context, components, messages) = try await schemaContext.externallyDereferenced(with: loader) - newSchemaOrContent = .a(context) - newComponents = components - newMessages = messages - case .b(let contentMap): - let (map, components, messages) = try await contentMap.externallyDereferenced(with: loader) - newSchemaOrContent = .b(map) - newComponents = components - newMessages = messages + switch context { + case .query(required: let required, allowEmptyValue: let allowEmptyValue, schemaOrContent: let schemaOrContent): + let newSchemaOrContent: Either + (newSchemaOrContent, newComponents, newMessages) = try await externallyDereference(schemaOrContent: schemaOrContent, with: Loader.self) + + newContext = .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: newSchemaOrContent) + + case .header(required: let required, schemaOrContent: let schemaOrContent): + let newSchemaOrContent: Either + (newSchemaOrContent, newComponents, newMessages) = try await externallyDereference(schemaOrContent: schemaOrContent, with: Loader.self) + + newContext = .header(required: required, schemaOrContent: newSchemaOrContent) + + case .path(schemaOrContent: let schemaOrContent): + let newSchemaOrContent: Either + (newSchemaOrContent, newComponents, newMessages) = try await externallyDereference(schemaOrContent: schemaOrContent, with: Loader.self) + + newContext = .path(schemaOrContent: newSchemaOrContent) + + case .cookie(required: let required, schemaOrContent: let schemaOrContent): + let newSchemaOrContent: Either + (newSchemaOrContent, newComponents, newMessages) = try await externallyDereference(schemaOrContent: schemaOrContent, with: Loader.self) + + newContext = .cookie(required: required, schemaOrContent: newSchemaOrContent) + + case .querystring(required: let required, content: let content): + let newContent: OpenAPI.Content.Map + (newContent, newComponents, newMessages) = try await content.externallyDereferenced(with: Loader.self) + + newContext = .querystring(required: required, content: newContent) } var newParameter = self - newParameter.schemaOrContent = newSchemaOrContent + newParameter.context = newContext return (newParameter, newComponents, newMessages) } } + +fileprivate func externallyDereference( + schemaOrContent: Either, + with loader: Loader.Type +) async throws -> (Either, OpenAPI.Components, [Loader.Message]) { + switch schemaOrContent { + case .a(let schemaContext): + let (context, components, messages) = try await schemaContext.externallyDereferenced(with: loader) + return (.a(context), components, messages) + case .b(let contentMap): + let (map, components, messages) = try await contentMap.externallyDereferenced(with: loader) + return (.b(map), components, messages) + } +} diff --git a/Sources/OpenAPIKit/Parameter/Parameter.swift b/Sources/OpenAPIKit/Parameter/Parameter.swift index 20b3843ad..40952463e 100644 --- a/Sources/OpenAPIKit/Parameter/Parameter.swift +++ b/Sources/OpenAPIKit/Parameter/Parameter.swift @@ -11,7 +11,7 @@ extension OpenAPI { /// OpenAPI Spec "Parameter Object" /// /// See [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.1.1.html#parameter-object). - public struct Parameter: Equatable, CodableVendorExtendable, Sendable { + public struct Parameter: HasConditionalWarnings, CodableVendorExtendable, Sendable { public var name: String /// OpenAPI Spec "in" property determines the `Context`. @@ -25,22 +25,6 @@ extension OpenAPI { /// if unspecified and only gets encoded if true. public var deprecated: Bool // default is false - /// OpenAPI Spec "content" or "schema" properties. - /// - /// You can access the schema context (if it is in use for - /// this parameter) with `schemaOrContent.schemaContextValue`. - /// The schema context contains lots of information detailed in the - /// OpenAPI specification under the **Parameter Object** section. - /// - /// You can directly access the underlying `JSONSchema` with - /// `schemaOrContent.schemaValue`. If the schema is a reference - /// instead of an inline value, `schemaOrContent.schemaReference` - /// will get you the reference. - /// - /// You can access the content map (if it is in use for - /// this parameter) with `schemaOrContent.contentValue`. - public var schemaOrContent: Either - /// Dictionary of vendor extensions. /// /// These should be of the form: @@ -48,6 +32,8 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + /// Whether or not this parameter is required. See the context /// which determines whether the parameter is required or not. public var required: Bool { context.required } @@ -58,92 +44,85 @@ extension OpenAPI { /// parameter. public var location: Context.Location { return context.location } - /// Create a parameter with an `Either`. - public init( - name: String, - context: Context, - schemaOrContent: Either, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.name = name - self.context = context - self.schemaOrContent = schemaOrContent - self.description = description - self.deprecated = deprecated - self.vendorExtensions = vendorExtensions + /// OpenAPI Spec "content" or "schema" properties. + /// + /// You can access the schema context (if it is in use for + /// this parameter) with `schemaOrContent.schemaContextValue`. + /// The schema context contains lots of information detailed in the + /// OpenAPI specification under the **Parameter Object** section. + /// + /// You can directly access the underlying `JSONSchema` with + /// `schemaOrContent.schemaValue`. If the schema is a reference + /// instead of an inline value, `schemaOrContent.schemaReference` + /// will get you the reference. + /// + /// You can access the content map (if it is in use for + /// this parameter) with `schemaOrContent.contentValue`. + public var schemaOrContent: Either { + switch context { + case .query(required: _, allowEmptyValue: _, schemaOrContent: let schemaOrContent): + return schemaOrContent + case .header(required: _, schemaOrContent: let schemaOrContent): + return schemaOrContent + case .path(schemaOrContent: let schemaOrContent): + return schemaOrContent + case .cookie(required: _, schemaOrContent: let schemaOrContent): + return schemaOrContent + case .querystring(required: _, content: let content): + return .content(content) + } } - /// Create a parameter with a `SchemaContext`. - public init( - name: String, - context: Context, - schema: SchemaContext, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.name = name - self.context = context - self.schemaOrContent = .init(schema) - self.description = description - self.deprecated = deprecated - self.vendorExtensions = vendorExtensions + /// The parameter's schema `style`, if defined. Note that this is + /// guaranteed to be nil if the parameter has `content` defined. Use + /// the `schemaOrContent` property if you want to switch over the two + /// possibilities. + public var schemaStyle : SchemaContext.Style? { + schemaOrContent.schemaContextValue?.style } - /// Create a parameter with a `JSONSchema` and the default - /// `style` for the given `Context`. + /// Create a parameter. public init( name: String, context: Context, - schema: JSONSchema, description: String? = nil, deprecated: Bool = false, vendorExtensions: [String: AnyCodable] = [:] ) { self.name = name self.context = context - self.schemaOrContent = .init(SchemaContext(schema, style: .default(for: context))) self.description = description self.deprecated = deprecated self.vendorExtensions = vendorExtensions - } - /// Create a parameter with a reference to a `JSONSchema` - /// and the default `style` for the given `Context`. - public init( - name: String, - context: Context, - schemaReference: OpenAPI.Reference, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.name = name - self.context = context - self.schemaOrContent = .init(SchemaContext(schemaReference: schemaReference, style: .default(for: context))) - self.description = description - self.deprecated = deprecated - self.vendorExtensions = vendorExtensions + self.conditionalWarnings = context.location.conditionalWarnings } + } +} - /// Create a parameter with a `Content.Map`. - public init( - name: String, - context: Context, - content: OpenAPI.Content.Map, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) { - self.name = name - self.context = context - self.schemaOrContent = .init(content) - self.description = description - self.deprecated = deprecated - self.vendorExtensions = vendorExtensions +extension OpenAPI.Parameter: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.name == rhs.name + && lhs.context == rhs.context + && lhs.description == rhs.description + && lhs.deprecated == rhs.deprecated + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + +extension OpenAPI.Parameter.Context.Location { + fileprivate var conditionalWarnings: [(any Condition, OpenAPI.Warning)] { + let querystringWarning: (any Condition, OpenAPI.Warning)? + if self != .querystring { + querystringWarning = nil + } else { + querystringWarning = OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The querystring parameter location") } + + + return [ + querystringWarning + ].compactMap { $0 } } } @@ -157,6 +136,328 @@ extension OpenAPI.Parameter { public typealias Array = [Either, OpenAPI.Parameter>] } +// MARK: Convenience constructors +extension OpenAPI.Parameter { + public static func cookie( + name: String, + required: Bool = false, + schemaOrContent: Either, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .cookie(required: required, schemaOrContent: schemaOrContent), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func cookie( + name: String, + required: Bool = false, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .cookie( + required: required, + content: content + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func cookie( + name: String, + required: Bool = false, + schema: JSONSchema, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .cookie( + required: required, + schema: schema + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func cookie( + name: String, + required: Bool = false, + schemaReference: OpenAPI.Reference, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .cookie( + required: required, + schemaOrContent: .schema(.init(schemaReference: schemaReference, style: .default(for: .cookie))) + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func header( + name: String, + required: Bool = false, + schemaOrContent: Either, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .header(required: required, schemaOrContent: schemaOrContent), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func header( + name: String, + required: Bool = false, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .header( + required: required, + content: content + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func header( + name: String, + required: Bool = false, + schema: JSONSchema, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .header( + required: required, + schema: schema + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func header( + name: String, + required: Bool = false, + schemaReference: OpenAPI.Reference, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .header( + required: required, + schemaOrContent: .schema(.init(schemaReference: schemaReference, style: .default(for: .header))) + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func path( + name: String, + schemaOrContent: Either, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .path(schemaOrContent: schemaOrContent), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func path( + name: String, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .path(content: content), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func path( + name: String, + schema: JSONSchema, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .path(schema: schema), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func path( + name: String, + schemaReference: OpenAPI.Reference, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .path(schemaOrContent: .schema(.init(schemaReference: schemaReference, style: .default(for: .path)))), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func query( + name: String, + required: Bool = false, + allowEmptyValue: Bool = false, + schemaOrContent: Either, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: schemaOrContent), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func query( + name: String, + required: Bool = false, + allowEmptyValue: Bool = false, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .query( + required: required, + allowEmptyValue: allowEmptyValue, + content: content + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func query( + name: String, + required: Bool = false, + allowEmptyValue: Bool = false, + schema: JSONSchema, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .query( + required: required, + allowEmptyValue: allowEmptyValue, + schema: schema + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func query( + name: String, + required: Bool = false, + allowEmptyValue: Bool = false, + schemaReference: OpenAPI.Reference, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .query( + required: required, + allowEmptyValue: allowEmptyValue, + schemaOrContent: .schema(.init(schemaReference: schemaReference, style: .default(for: .query))) + ), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } + + public static func querystring( + name: String, + required: Bool = false, + content: OpenAPI.Content.Map, + description: String? = nil, + deprecated: Bool = false, + vendorExtensions: [String: AnyCodable] = [:] + ) -> Self { + .init( + name: name, + context: .querystring(content: content), + description: description, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + } +} + extension OpenAPI.Parameter { /// A parameter identity is just a hashable struct /// containing exactly the things that differentiate @@ -173,11 +474,10 @@ extension OpenAPI.Parameter { // OpenAPI.PathItem.Array.Element => extension Either where A == OpenAPI.Reference, B == OpenAPI.Parameter { - /// Construct a parameter using a `JSONSchema`. + /// Construct a parameter. public static func parameter( name: String, context: OpenAPI.Parameter.Context, - schema: JSONSchema, description: String? = nil, deprecated: Bool = false, vendorExtensions: [String: AnyCodable] = [:] @@ -186,28 +486,6 @@ extension Either where A == OpenAPI.Reference, B == OpenAPI.P .init( name: name, context: context, - schema: schema, - description: description, - deprecated: deprecated, - vendorExtensions: vendorExtensions - ) - ) - } - - /// Construct a parameter using a `Content.Map`. - public static func parameter( - name: String, - context: OpenAPI.Parameter.Context, - content: OpenAPI.Content.Map, - description: String? = nil, - deprecated: Bool = false, - vendorExtensions: [String: AnyCodable] = [:] - ) -> Self { - return .b( - .init( - name: name, - context: context, - content: content, description: description, deprecated: deprecated, vendorExtensions: vendorExtensions @@ -238,22 +516,25 @@ extension OpenAPI.Parameter: Encodable { let required: Bool let location: Context.Location switch context { - case .query(required: let req, allowEmptyValue: let allowEmptyValue): + case .query(required: let req, allowEmptyValue: let allowEmptyValue, schemaOrContent: _): required = req location = .query if allowEmptyValue { try container.encode(allowEmptyValue, forKey: .allowEmptyValue) } - case .header(required: let req): + case .header(required: let req, schemaOrContent: _): required = req location = .header - case .path: + case .path(schemaOrContent: _): required = true location = .path - case .cookie(required: let req): + case .cookie(required: let req, schemaOrContent: _): required = req location = .cookie + case .querystring(required: let req, content: _): + required = req + location = .querystring } try container.encode(location, forKey: .parameterLocation) @@ -263,7 +544,7 @@ extension OpenAPI.Parameter: Encodable { switch schemaOrContent { case .a(let schema): - try schema.encode(to: encoder, for: context) + try schema.encode(to: encoder, for: location) case .b(let contentMap): try container.encode(contentMap, forKey: .content) } @@ -290,34 +571,16 @@ extension OpenAPI.Parameter: Decodable { let required = try container.decodeIfPresent(Bool.self, forKey: .required) ?? false let location = try container.decode(Context.Location.self, forKey: .parameterLocation) - switch location { - case .query: - let allowEmptyValue = try container.decodeIfPresent(Bool.self, forKey: .allowEmptyValue) ?? false - context = .query(required: required, allowEmptyValue: allowEmptyValue) - case .header: - context = .header(required: required) - case .path: - if !required { - throw GenericError( - subjectName: name, - details: "positional path parameters must be explicitly set to required", - codingPath: decoder.codingPath - ) - } - context = .path - case .cookie: - context = .cookie(required: required) - } - let maybeContent = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) let maybeSchema: SchemaContext? if container.contains(.schema) { - maybeSchema = try SchemaContext(from: decoder, for: context) + maybeSchema = try SchemaContext(from: decoder, for: location) } else { maybeSchema = nil } + let schemaOrContent: Either switch (maybeContent, maybeSchema) { case (let content?, nil): schemaOrContent = .init(content) @@ -337,11 +600,41 @@ extension OpenAPI.Parameter: Decodable { ) } + switch location { + case .query: + let allowEmptyValue = try container.decodeIfPresent(Bool.self, forKey: .allowEmptyValue) ?? false + context = .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: schemaOrContent) + case .header: + context = .header(required: required, schemaOrContent: schemaOrContent) + case .path: + if !required { + throw GenericError( + subjectName: name, + details: "positional path parameters must be explicitly set to required", + codingPath: decoder.codingPath + ) + } + context = .path(schemaOrContent: schemaOrContent) + case .cookie: + context = .cookie(required: required, schemaOrContent: schemaOrContent) + case .querystring: + guard case .b(let content) = schemaOrContent else { + throw GenericError( + subjectName: name, + details: "`schema` and `style` are disallowed for `querystring` parameters", + codingPath: decoder.codingPath + ) + } + context = .querystring(required: required, content: content) + } + description = try container.decodeIfPresent(String.self, forKey: .description) deprecated = try container.decodeIfPresent(Bool.self, forKey: .deprecated) ?? false vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = context.location.conditionalWarnings } } diff --git a/Sources/OpenAPIKit/Parameter/ParameterContext.swift b/Sources/OpenAPIKit/Parameter/ParameterContext.swift index d017b7c67..5b8ab5f5e 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterContext.swift @@ -17,24 +17,78 @@ extension OpenAPI.Parameter { /// `required: true` to the context construction. /// Path parameters are always required. public enum Context: Equatable, Sendable { - case query(required: Bool, allowEmptyValue: Bool) - case header(required: Bool) - case path - case cookie(required: Bool) + case query(required: Bool, allowEmptyValue: Bool, schemaOrContent: Either) + case header(required: Bool, schemaOrContent: Either) + case path(schemaOrContent: Either) + case cookie(required: Bool, schemaOrContent: Either) + case querystring(required: Bool, content: OpenAPI.Content.Map) - public static func query(required: Bool) -> Context { return .query(required: required, allowEmptyValue: false) } + /// A query parameter that does not allow empty values. + public static func query( + required: Bool = false, + schemaOrContent: Either + ) -> Context { return .query(required: required, allowEmptyValue: false, schemaOrContent: schemaOrContent) } - public static func query(allowEmptyValue: Bool) -> Context { return .query(required: false, allowEmptyValue: allowEmptyValue) } + /// A query parameter that is not required. + public static func query( + allowEmptyValue: Bool, + schemaOrContent: Either + ) -> Context { return .query(required: false, allowEmptyValue: allowEmptyValue, schemaOrContent: schemaOrContent) } - /// An optional query parameter that does not allow - /// empty values. - public static var query: Context { return .query(required: false, allowEmptyValue: false) } + public static func query( + required: Bool = false, + allowEmptyValue: Bool = false, + schema: JSONSchema + ) -> Context { return .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: .schema(.init(schema, style: .default(for: .query)))) } + + public static func query( + required: Bool = false, + allowEmptyValue: Bool = false, + content: OpenAPI.Content.Map + ) -> Context { return .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: .content(content)) } /// An optional header parameter. - public static var header: Context { return .header(required: false) } + public static func header( + schemaOrContent: Either + ) -> Context { return .header(required: false, schemaOrContent: schemaOrContent) } + + public static func header( + required: Bool = false, + schema: JSONSchema + ) -> Context { return .header(required: required, schemaOrContent: .schema(.init(schema, style: .default(for: .header)))) } + + public static func header( + required: Bool = false, + content: OpenAPI.Content.Map + ) -> Context { return .header(required: required, schemaOrContent: .content(content)) } /// An optional cookie parameter. - public static var cookie: Context { return .cookie(required: false) } + public static func cookie( + schemaOrContent: Either + ) -> Context { return .cookie(required: false, schemaOrContent: schemaOrContent) } + + public static func cookie( + required: Bool = false, + schema: JSONSchema + ) -> Context { return .cookie(required: required, schemaOrContent: .schema(.init(schema, style: .default(for: .cookie)))) } + + public static func cookie( + required: Bool = false, + content: OpenAPI.Content.Map + ) -> Context { return .cookie(required: required, schemaOrContent: .content(content)) } + + public static func path( + schema: JSONSchema + ) -> Context { return .path(schemaOrContent: .schema(.init(schema, style: .default(for: .path)))) } + + public static func path( + content: OpenAPI.Content.Map + ) -> Context { return .path(schemaOrContent: .content(content)) } + + /// An optional querystring parameter. + public static func querystring( + content: OpenAPI.Content.Map + ) -> Context { return .querystring(required: false, content: content) } public var inQuery: Bool { guard case .query = self else { @@ -50,7 +104,12 @@ extension OpenAPI.Parameter { return true } - public var inPath: Bool { return self == .path } + public var inPath: Bool { + guard case .path = self else { + return false + } + return true + } public var inCookie: Bool { guard case .cookie = self else { @@ -59,13 +118,21 @@ extension OpenAPI.Parameter { return true } + public var inQuerystring: Bool { + guard case .querystring = self else { + return false + } + return true + } + public var required: Bool { switch self { - case .query(required: let required, allowEmptyValue: _), - .header(required: let required), - .cookie(required: let required): + case .query(required: let required, allowEmptyValue: _, schemaOrContent: _), + .header(required: let required, schemaOrContent: _), + .cookie(required: let required, schemaOrContent: _), + .querystring(required: let required, content: _): return required - case .path: + case .path(schemaOrContent: _): return true } } @@ -83,6 +150,8 @@ extension OpenAPI.Parameter.Context { return .path case .cookie: return .cookie + case .querystring: + return .querystring } } } diff --git a/Sources/OpenAPIKit/Parameter/ParameterContextLocation.swift b/Sources/OpenAPIKit/Parameter/ParameterContextLocation.swift new file mode 100644 index 000000000..7c5099b4d --- /dev/null +++ b/Sources/OpenAPIKit/Parameter/ParameterContextLocation.swift @@ -0,0 +1,20 @@ +// +// ParameterContextLocation.swift +// +// +// Created by Mathew Polzin on 12/24/22. +// + +import OpenAPIKitCore + +extension OpenAPI.Parameter.Context { + public enum Location: String, CaseIterable, Codable { + case query + case header + case path + case cookie + case querystring + } +} + +extension OpenAPI.Parameter.Context.Location: Validatable {} diff --git a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift index f8828da2e..c7b3b02fd 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift @@ -12,7 +12,7 @@ extension OpenAPI.Parameter { /// /// See [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.1.1.html#parameter-object) /// and [OpenAPI Style Values](https://spec.openapis.org/oas/v3.1.1.html#style-values). - public struct SchemaContext: Equatable, Sendable { + public struct SchemaContext: HasConditionalWarnings, Sendable { public var style: Style public var explode: Bool public var allowReserved: Bool //defaults to false @@ -21,6 +21,8 @@ extension OpenAPI.Parameter { public var example: AnyCodable? public var examples: OpenAPI.Example.Map? + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init(_ schema: JSONSchema, style: Style, explode: Bool, @@ -32,6 +34,8 @@ extension OpenAPI.Parameter { self.schema = .init(schema) self.example = example self.examples = nil + + self.conditionalWarnings = style.conditionalWarnings } public init(_ schema: JSONSchema, @@ -45,6 +49,8 @@ extension OpenAPI.Parameter { self.examples = nil self.explode = style.defaultExplode + + self.conditionalWarnings = style.conditionalWarnings } public init(schemaReference: OpenAPI.Reference, @@ -58,6 +64,8 @@ extension OpenAPI.Parameter { self.schema = .init(schemaReference) self.example = example self.examples = nil + + self.conditionalWarnings = style.conditionalWarnings } public init(schemaReference: OpenAPI.Reference, @@ -71,6 +79,8 @@ extension OpenAPI.Parameter { self.examples = nil self.explode = style.defaultExplode + + self.conditionalWarnings = style.conditionalWarnings } public init(_ schema: JSONSchema, @@ -84,6 +94,8 @@ extension OpenAPI.Parameter { self.schema = .init(schema) self.examples = examples self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + + self.conditionalWarnings = style.conditionalWarnings } public init(_ schema: JSONSchema, @@ -97,6 +109,8 @@ extension OpenAPI.Parameter { self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) self.explode = style.defaultExplode + + self.conditionalWarnings = style.conditionalWarnings } public init(schemaReference: OpenAPI.Reference, @@ -110,6 +124,8 @@ extension OpenAPI.Parameter { self.schema = .init(schemaReference) self.examples = examples self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) + + self.conditionalWarnings = style.conditionalWarnings } public init(schemaReference: OpenAPI.Reference, @@ -123,7 +139,75 @@ extension OpenAPI.Parameter { self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) self.explode = style.defaultExplode + + self.conditionalWarnings = style.conditionalWarnings + } + } +} + +extension OpenAPI.Parameter.SchemaContext.Style { + fileprivate var conditionalWarnings: [(any Condition, OpenAPI.Warning)] { + let cookieStyleWarning: (any Condition, OpenAPI.Warning)? + if self != .cookie { + cookieStyleWarning = nil + } else { + cookieStyleWarning = OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The cookie style") } + + + return [ + cookieStyleWarning + ].compactMap { $0 } + } +} + +extension OpenAPI.Parameter.SchemaContext: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.style == rhs.style + && lhs.allowReserved == rhs.allowReserved + && lhs.explode == rhs.explode + && lhs.schema == rhs.schema + && lhs.examples == rhs.examples + && lhs.example == rhs.example + } +} + +extension OpenAPI.Parameter.SchemaContext { + public static func schema(_ schema: JSONSchema, + style: Style, + explode: Bool, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? = nil) -> Self { + .init(schema, style: style, explode: explode, allowReserved: allowReserved, examples: examples) + } + + public static func schema(_ schema: JSONSchema, + style: Style, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? = nil) -> Self { + .init(schema, style: style, allowReserved: allowReserved, examples: examples) + } + + public static func schemaReference(_ reference: OpenAPI.Reference, + style: Style, + explode: Bool, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? = nil) -> Self { + .init(schemaReference: reference, + style: style, + explode: explode, + allowReserved: allowReserved, + examples: examples) + } + + public static func schemaReference(_ reference: OpenAPI.Reference, + style: Style, + allowReserved: Bool = false, + examples: OpenAPI.Example.Map? = nil) -> Self { + .init(schemaReference: reference, + style: style, + allowReserved: allowReserved, + examples: examples) } } @@ -133,7 +217,7 @@ extension OpenAPI.Parameter.SchemaContext.Style { /// /// See the `style` fixed field under /// [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.1.1.html#parameter-object). - public static func `default`(for location: OpenAPI.Parameter.Context) -> Self { + public static func `default`(for location: OpenAPI.Parameter.Context.Location) -> Self { switch location { case .query: return .form @@ -143,6 +227,28 @@ extension OpenAPI.Parameter.SchemaContext.Style { return .simple case .header: return .simple + case .querystring: + return .simple + } + } + + /// Get the default `Style` for the given context + /// per the OpenAPI Specification. + /// + /// See the `style` fixed field under + /// [OpenAPI Parameter Object](https://spec.openapis.org/oas/v3.1.1.html#parameter-object). + public static func `default`(for context: OpenAPI.Parameter.Context) -> Self { + switch context { + case .query: + return .form + case .cookie: + return .form + case .path: + return .simple + case .header: + return .simple + case .querystring: + return .simple } } @@ -171,7 +277,7 @@ extension OpenAPI.Parameter.SchemaContext { } extension OpenAPI.Parameter.SchemaContext { - public func encode(to encoder: Encoder, for location: OpenAPI.Parameter.Context) throws { + public func encode(to encoder: Encoder, for location: OpenAPI.Parameter.Context.Location) throws { var container = encoder.container(keyedBy: CodingKeys.self) if style != Style.default(for: location) { @@ -197,7 +303,7 @@ extension OpenAPI.Parameter.SchemaContext { } extension OpenAPI.Parameter.SchemaContext { - public init(from decoder: Decoder, for location: OpenAPI.Parameter.Context) throws { + public init(from decoder: Decoder, for location: OpenAPI.Parameter.Context.Location) throws { let container = try decoder.container(keyedBy: CodingKeys.self) schema = try container.decode(Either, JSONSchema>.self, forKey: .schema) @@ -217,6 +323,8 @@ extension OpenAPI.Parameter.SchemaContext { examples = examplesMap example = examplesMap.flatMap(OpenAPI.Content.firstExample(from:)) } + + self.conditionalWarnings = style.conditionalWarnings } } diff --git a/Sources/OpenAPIKit/Parameter/ParameterSchemaContextStyle.swift b/Sources/OpenAPIKit/Parameter/ParameterSchemaContextStyle.swift new file mode 100644 index 000000000..96260473e --- /dev/null +++ b/Sources/OpenAPIKit/Parameter/ParameterSchemaContextStyle.swift @@ -0,0 +1,19 @@ +// +// ParameterSchemaContextStyle.swift +// +// +// Created by Mathew Polzin on 12/18/22. +// + +extension OpenAPI.Parameter.SchemaContext { + public enum Style: String, CaseIterable, Codable, Sendable { + case form + case simple + case matrix + case label + case spaceDelimited + case pipeDelimited + case deepObject + case cookie + } +} diff --git a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift index d9f25538f..cdc11150d 100644 --- a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift @@ -34,6 +34,13 @@ public struct DereferencedPathItem: Equatable { public let patch: DereferencedOperation? /// The dereferenced TRACE operation, if defined. public let trace: DereferencedOperation? + /// The dereferenced QUERY operation, if defined. + public let query: DereferencedOperation? + + /// Additional operations, keyed by all-caps HTTP method names. This + /// map MUST NOT contain any entries that can be represented by the + /// fixed fields on this type (e.g. `post`, `get`, etc.). + public let additionalOperations: OrderedDictionary public subscript(dynamicMember path: KeyPath) -> T { return underlyingPathItem[keyPath: path] @@ -64,6 +71,9 @@ public struct DereferencedPathItem: Equatable { self.head = try pathItem.head.map { try DereferencedOperation($0, resolvingIn: components, following: references) } self.patch = try pathItem.patch.map { try DereferencedOperation($0, resolvingIn: components, following: references) } self.trace = try pathItem.trace.map { try DereferencedOperation($0, resolvingIn: components, following: references) } + self.query = try pathItem.query.map { try DereferencedOperation($0, resolvingIn: components, following: references) } + + self.additionalOperations = try pathItem.additionalOperations.mapValues { try DereferencedOperation($0, resolvingIn: components, following: references) } var pathItem = pathItem if let name { @@ -80,22 +90,20 @@ extension DereferencedPathItem { /// Retrieve the operation for the given verb, if one is set for this path. public func `for`(_ verb: OpenAPI.HttpMethod) -> DereferencedOperation? { switch verb { - case .delete: - return self.delete - case .get: - return self.get - case .head: - return self.head - case .options: - return self.options - case .patch: - return self.patch - case .post: - return self.post - case .put: - return self.put - case .trace: - return self.trace + case .builtin(let builtin): + switch builtin { + case .delete: self.delete + case .get: self.get + case .head: self.head + case .options: self.options + case .patch: self.patch + case .post: self.post + case .put: self.put + case .trace: self.trace + case .query: self.query + } + case .other(let other): + additionalOperations[.other(other)] } } @@ -117,9 +125,11 @@ extension DereferencedPathItem { /// - Returns: An array of `Endpoints` with the method (i.e. `.get`) and the operation for /// the method. public var endpoints: [Endpoint] { - return OpenAPI.HttpMethod.allCases.compactMap { method in - self.for(method).map { .init(method: method, operation: $0) } + let builtins = OpenAPI.BuiltinHttpMethod.allCases.compactMap { method -> Endpoint? in + self.for(.builtin(method)).map { .init(method: .builtin(method), operation: $0) } } + + return builtins + additionalOperations.map { key, value in .init(method: key, operation: value) } } } @@ -151,6 +161,9 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { let oldHead = head let oldPatch = patch let oldTrace = trace + let oldQuery = query + + let oldAdditionalOperations = additionalOperations async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) // async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) @@ -162,6 +175,9 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { async let (newHead, c8, m8) = oldHead.externallyDereferenced(with: loader) async let (newPatch, c9, m9) = oldPatch.externallyDereferenced(with: loader) async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) + async let (newQuery, c11, m11) = oldQuery.externallyDereferenced(with: loader) + + async let (newAdditionalOperations, c12, m12) = oldAdditionalOperations.externallyDereferenced(with: loader) var pathItem = self var newComponents = try await c1 @@ -179,6 +195,8 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { pathItem.head = try await newHead pathItem.patch = try await newPatch pathItem.trace = try await newTrace + pathItem.query = try await newQuery + pathItem.additionalOperations = try await newAdditionalOperations try await newComponents.merge(c3) try await newComponents.merge(c4) @@ -188,6 +206,8 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { try await newComponents.merge(c8) try await newComponents.merge(c9) try await newComponents.merge(c10) + try await newComponents.merge(c11) + try await newComponents.merge(c12) try await newMessages += m3 try await newMessages += m4 @@ -197,6 +217,8 @@ extension OpenAPI.PathItem: ExternallyDereferenceable { try await newMessages += m8 try await newMessages += m9 try await newMessages += m10 + try await newMessages += m11 + try await newMessages += m12 if let oldServers { async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) diff --git a/Sources/OpenAPIKit/Path Item/PathItem.swift b/Sources/OpenAPIKit/Path Item/PathItem.swift index a98654074..dd3445d2a 100644 --- a/Sources/OpenAPIKit/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit/Path Item/PathItem.swift @@ -8,7 +8,9 @@ import OpenAPIKitCore extension OpenAPI { - /// OpenAPI Spec "Path Item Object" + /// OpenAPI Spec "Path Item Object" (although in the spec the Path Item + /// Object also includes reference support which OpenAPIKit implements via + /// the PathItem.Map type) /// /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.1.1.html#path-item-object). /// @@ -21,7 +23,7 @@ extension OpenAPI { /// /// You can access an array of equatable `HttpMethod`/`Operation` paris with the /// `endpoints` property. - public struct PathItem: Equatable, CodableVendorExtendable, Sendable { + public struct PathItem: HasConditionalWarnings, CodableVendorExtendable, Sendable { public var summary: String? public var description: String? public var servers: [OpenAPI.Server]? @@ -52,6 +54,13 @@ extension OpenAPI { public var patch: Operation? /// The `TRACE` endpoint at this path, if one exists. public var trace: Operation? + /// The `QUERY` endpoint at this path, if one exists. + public var query: Operation? + + /// Additional operations, keyed by all-caps HTTP method names. This + /// map MUST NOT contain any entries that can be represented by the + /// fixed fields on this type (e.g. `post`, `get`, etc.). + public var additionalOperations: OrderedDictionary /// Dictionary of vendor extensions. /// @@ -60,6 +69,12 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + /// Warnings that apply conditionally depending on the OpenAPI Document + /// the PathItem belongs to. + /// + /// Check these with the `applicableConditionalWarnings(for:)` method. + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init( summary: String? = nil, description: String? = nil, @@ -73,6 +88,8 @@ extension OpenAPI { head: Operation? = nil, patch: Operation? = nil, trace: Operation? = nil, + query: Operation? = nil, + additionalOperations: OrderedDictionary = [:], vendorExtensions: [String: AnyCodable] = [:] ) { self.summary = summary @@ -88,7 +105,16 @@ extension OpenAPI { self.head = head self.patch = patch self.trace = trace + self.query = query + self.additionalOperations = additionalOperations self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = [ + // If query is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "query", value: query, minimumVersion: .v3_2_0), + // If there are additionalOperations defiend, the document must be OAS version 3.2.0 or greater + nonEmptyVersionWarning(fieldName: "additionalOperations", value: additionalOperations, minimumVersion: .v3_2_0) + ].compactMap { $0 } } /// Set the `GET` endpoint operation. @@ -130,9 +156,52 @@ extension OpenAPI { public mutating func trace(_ op: Operation?) { trace = op } + + /// Set the `QUERY` endpoint operation. + public mutating func query(_ op: Operation?) { + query = op + } + } +} + +extension OpenAPI.PathItem: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.summary == rhs.summary + && lhs.description == rhs.description + && lhs.servers == rhs.servers + && lhs.parameters == rhs.parameters + && lhs.get == rhs.get + && lhs.put == rhs.put + && lhs.post == rhs.post + && lhs.delete == rhs.delete + && lhs.options == rhs.options + && lhs.head == rhs.head + && lhs.patch == rhs.patch + && lhs.trace == rhs.trace + && lhs.query == rhs.query + && lhs.additionalOperations == rhs.additionalOperations + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The PathItem \(fieldName) field" + ) } } +fileprivate func nonEmptyVersionWarning(fieldName: String, value: OrderedDictionary, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + if value.isEmpty { return nil } + + return OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The PathItem \(fieldName) map" + ) +} + extension OpenAPI.PathItem { public typealias Map = OrderedDictionary, OpenAPI.PathItem>> } @@ -148,44 +217,49 @@ extension OpenAPI.PathItem { /// Retrieve the operation for the given verb, if one is set for this path. public func `for`(_ verb: OpenAPI.HttpMethod) -> OpenAPI.Operation? { switch verb { - case .delete: - return self.delete - case .get: - return self.get - case .head: - return self.head - case .options: - return self.options - case .patch: - return self.patch - case .post: - return self.post - case .put: - return self.put - case .trace: - return self.trace + case .builtin(let builtin): + switch builtin { + case .delete: self.delete + case .get: self.get + case .head: self.head + case .options: self.options + case .patch: self.patch + case .post: self.post + case .put: self.put + case .trace: self.trace + case .query: self.query + } + case .other(let other): + additionalOperations[.other(other)] } } /// Set the operation for the given verb, overwriting any already set operation for the same verb. public mutating func set(operation: OpenAPI.Operation?, for verb: OpenAPI.HttpMethod) { switch verb { - case .delete: - self.delete(operation) - case .get: - self.get(operation) - case .head: - self.head(operation) - case .options: - self.options(operation) - case .patch: - self.patch(operation) - case .post: - self.post(operation) - case .put: - self.put(operation) - case .trace: - self.trace(operation) + case .builtin(let builtin): + switch builtin { + case .delete: + self.delete(operation) + case .get: + self.get(operation) + case .head: + self.head(operation) + case .options: + self.options(operation) + case .patch: + self.patch(operation) + case .post: + self.post(operation) + case .put: + self.put(operation) + case .trace: + self.trace(operation) + case .query: + self.query(operation) + } + case .other(let other): + self.additionalOperations[.other(other)] = operation } } @@ -210,9 +284,11 @@ extension OpenAPI.PathItem { /// - Returns: An array of `Endpoints` with the method (i.e. `.get`) and the operation for /// the method. public var endpoints: [Endpoint] { - return OpenAPI.HttpMethod.allCases.compactMap { method in - self.for(method).map { .init(method: method, operation: $0) } + let builtins = OpenAPI.BuiltinHttpMethod.allCases.compactMap { method -> Endpoint? in + self.for(.builtin(method)).map { .init(method: .builtin(method), operation: $0) } } + + return builtins + additionalOperations.map { key, value in .init(method: key, operation: value) } } } @@ -256,6 +332,11 @@ extension OpenAPI.PathItem: Encodable { try container.encodeIfPresent(head, forKey: .head) try container.encodeIfPresent(patch, forKey: .patch) try container.encodeIfPresent(trace, forKey: .trace) + try container.encodeIfPresent(query, forKey: .query) + + if !additionalOperations.isEmpty { + try container.encode(additionalOperations, forKey: .additionalOperations) + } if VendorExtensionsConfiguration.isEnabled(for: encoder) { try encodeExtensions(to: &container) @@ -281,9 +362,37 @@ extension OpenAPI.PathItem: Decodable { head = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .head) patch = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .patch) trace = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .trace) + query = try container.decodeIfPresent(OpenAPI.Operation.self, forKey: .query) + + additionalOperations = try container.decodeIfPresent(OrderedDictionary.self, forKey: .additionalOperations) ?? [:] + + let disallowedMethods = builtinHttpMethods(in: additionalOperations) + if !disallowedMethods.isEmpty { + let disallowedMethodsString = disallowedMethods + .map(\.rawValue) + .joined(separator: ", ") + + throw GenericError(subjectName: "additionalOperations", details: "Additional Operations cannot contain operations that can be set directly on the Path Item. Found the following disallowed additional operations: \(disallowedMethodsString)", codingPath: decoder.codingPath, pathIncludesSubject: false) + } vendorExtensions = try Self.extensions(from: decoder) + + self.conditionalWarnings = [ + // If query is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "query", value: query, minimumVersion: .v3_2_0), + // If there are additionalOperations defiend, the document must be OAS version 3.2.0 or greater + nonEmptyVersionWarning(fieldName: "additionalOperations", value: additionalOperations, minimumVersion: .v3_2_0) + ].compactMap { $0 } } catch let error as DecodingError { + if let underlyingError = error.underlyingError as? KeyDecodingError { + throw OpenAPI.Error.Decoding.Path( + GenericError( + subjectName: error.subjectName, + details: underlyingError.localizedDescription, + codingPath: decoder.codingPath + ) + ) + } throw OpenAPI.Error.Decoding.Path(error) } catch let error as GenericError { @@ -299,6 +408,13 @@ extension OpenAPI.PathItem: Decodable { } } +fileprivate func builtinHttpMethods(in map: OrderedDictionary) -> [OpenAPI.HttpMethod] { + map.keys + .filter { + OpenAPI.BuiltinHttpMethod.allCases.map(\.rawValue).contains($0.rawValue.uppercased()) + } +} + extension OpenAPI.PathItem { internal enum CodingKeys: ExtendableCodingKey { case summary @@ -314,6 +430,9 @@ extension OpenAPI.PathItem { case head case patch case trace + case query + + case additionalOperations case extended(String) @@ -331,7 +450,10 @@ extension OpenAPI.PathItem { .options, .head, .patch, - .trace + .trace, + .query, + + .additionalOperations ] } @@ -365,6 +487,10 @@ extension OpenAPI.PathItem { self = .patch case "trace": self = .trace + case "query": + self = .query + case "additionalOperations": + self = .additionalOperations default: self = .extendedKey(for: stringValue) } @@ -396,6 +522,10 @@ extension OpenAPI.PathItem { return "patch" case .trace: return "trace" + case .query: + return "query" + case .additionalOperations: + return "additionalOperations" case .extended(let key): return key } diff --git a/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift b/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift index ed2a7062c..ed37d68a5 100644 --- a/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift +++ b/Sources/OpenAPIKit/Path Item/ResolvedRoute.swift @@ -64,6 +64,13 @@ public struct ResolvedRoute: Equatable { public let patch: ResolvedEndpoint? /// The HTTP `TRACE` endpoint at this route. public let trace: ResolvedEndpoint? + /// The HTTP `QUERY` endpoint at this route. + public let query: ResolvedEndpoint? + + /// Additional operations, keyed by all-caps HTTP method names. This + /// map MUST NOT contain any entries that can be represented by the + /// fixed fields on this type (e.g. `post`, `get`, etc.). + public let additionalOperations: OrderedDictionary /// Create a ResolvedRoute. /// @@ -83,11 +90,18 @@ public struct ResolvedRoute: Equatable { servers: [OpenAPI.Server], endpoints: [ResolvedEndpoint] ) { - let endpoints = Dictionary( + let builtinEndpoints = Dictionary( endpoints.map { ($0.method, $0) }, uniquingKeysWith: { $1 } ) + let otherEndpoints = endpoints.compactMap { endpoint -> (key: OpenAPI.HttpMethod, value: ResolvedEndpoint)? in + switch endpoint.method { + case .builtin(_): return nil + case .other(_): return (key: endpoint.method, value: endpoint) + } + } + self.summary = summary self.description = description self.vendorExtensions = vendorExtensions @@ -95,19 +109,22 @@ public struct ResolvedRoute: Equatable { self.parameters = parameters self.servers = servers - self.get = endpoints[.get] - self.put = endpoints[.put] - self.post = endpoints[.post] - self.delete = endpoints[.delete] - self.options = endpoints[.options] - self.head = endpoints[.head] - self.patch = endpoints[.patch] - self.trace = endpoints[.trace] + self.get = builtinEndpoints[.builtin(.get)] + self.put = builtinEndpoints[.builtin(.put)] + self.post = builtinEndpoints[.builtin(.post)] + self.delete = builtinEndpoints[.builtin(.delete)] + self.options = builtinEndpoints[.builtin(.options)] + self.head = builtinEndpoints[.builtin(.head)] + self.patch = builtinEndpoints[.builtin(.patch)] + self.trace = builtinEndpoints[.builtin(.trace)] + self.query = builtinEndpoints[.builtin(.query)] + + self.additionalOperations = OrderedDictionary(otherEndpoints, uniquingKeysWith: { $1 }) } /// An array of all endpoints at this route. public var endpoints: [ResolvedEndpoint] { - [ + let builtins = [ self.get, self.put, self.post, @@ -115,29 +132,30 @@ public struct ResolvedRoute: Equatable { self.options, self.head, self.patch, - self.trace + self.trace, + self.query ].compactMap { $0 } + + return builtins + additionalOperations.values } /// Retrieve the endpoint for the given method, if one exists for this route. public func `for`(_ verb: OpenAPI.HttpMethod) -> ResolvedEndpoint? { switch verb { - case .delete: - return self.delete - case .get: - return self.get - case .head: - return self.head - case .options: - return self.options - case .patch: - return self.patch - case .post: - return self.post - case .put: - return self.put - case .trace: - return self.trace + case .builtin(let builtin): + switch builtin { + case .delete: self.delete + case .get: self.get + case .head: self.head + case .options: self.options + case .patch: self.patch + case .post: self.post + case .put: self.put + case .trace: self.trace + case .query: self.query + } + case .other(let other): + self.additionalOperations[.other(other)] } } diff --git a/Sources/OpenAPIKit/Response/Response.swift b/Sources/OpenAPIKit/Response/Response.swift index af5d25365..b03c3ff03 100644 --- a/Sources/OpenAPIKit/Response/Response.swift +++ b/Sources/OpenAPIKit/Response/Response.swift @@ -11,8 +11,10 @@ extension OpenAPI { /// OpenAPI Spec "Response Object" /// /// See [OpenAPI Response Object](https://spec.openapis.org/oas/v3.1.1.html#response-object). - public struct Response: Equatable, CodableVendorExtendable, Sendable { - public var description: String + public struct Response: HasConditionalWarnings, CodableVendorExtendable, Sendable { + public var summary: String? + public var description: String? + public var headers: Header.Map? /// An empty Content map will be omitted from encoding. public var content: Content.Map @@ -26,22 +28,62 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + public init( - description: String, + summary: String? = nil, + description: String? = nil, headers: Header.Map? = nil, content: Content.Map = [:], links: Link.Map = [:], vendorExtensions: [String: AnyCodable] = [:] ) { + self.summary = summary self.description = description self.headers = headers self.content = content self.links = links self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If description is nil, the document must be OAS version 3.2.0 or greater + notOptionalVersionWarning(fieldName: "description", value: description, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } } +extension OpenAPI.Response: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.summary == rhs.summary + && lhs.description == rhs.description + && lhs.headers == rhs.headers + && lhs.content == rhs.content + && lhs.links == rhs.links + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Response \(fieldName) field" + ) + } +} + +fileprivate func notOptionalVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + guard value == nil else { return nil } + + return OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotAllowOptional: "The Response \(fieldName) field" + ) +} + extension OpenAPI.Response { public typealias Map = OrderedDictionary, OpenAPI.Response>> } @@ -72,7 +114,8 @@ extension OrderedDictionary where Key == OpenAPI.Response.StatusCode { extension Either where A == OpenAPI.Reference, B == OpenAPI.Response { public static func response( - description: String, + summary: String? = nil, + description: String? = nil, headers: OpenAPI.Header.Map? = nil, content: OpenAPI.Content.Map = [:], links: OpenAPI.Link.Map = [:] @@ -89,19 +132,27 @@ extension Either where A == OpenAPI.Reference, B == OpenAPI.Re } // MARK: - Describable -extension OpenAPI.Response: OpenAPIDescribable { +extension OpenAPI.Response: OpenAPISummarizable { public func overriddenNonNil(description: String?) -> OpenAPI.Response { guard let description = description else { return self } var response = self response.description = description return response } + + public func overriddenNonNil(summary: String?) -> OpenAPI.Response { + guard let summary = summary else { return self } + var response = self + response.summary = summary + return response + } } // MARK: - Codable extension OpenAPI.Response { internal enum CodingKeys: ExtendableCodingKey { + case summary case description case headers case content @@ -110,6 +161,7 @@ extension OpenAPI.Response { static var allBuiltinKeys: [CodingKeys] { return [ + .summary, .description, .headers, .content, @@ -123,6 +175,8 @@ extension OpenAPI.Response { init?(stringValue: String) { switch stringValue { + case "summary": + self = .summary case "description": self = .description case "headers": @@ -138,6 +192,8 @@ extension OpenAPI.Response { var stringValue: String { switch self { + case .summary: + return "summary" case .description: return "description" case .headers: @@ -157,7 +213,8 @@ extension OpenAPI.Response: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(description, forKey: .description) + try container.encodeIfPresent(summary, forKey: .summary) + try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(headers, forKey: .headers) if !content.isEmpty { @@ -179,13 +236,21 @@ extension OpenAPI.Response: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) do { - description = try container.decode(String.self, forKey: .description) + summary = try container.decodeIfPresent(String.self, forKey: .summary) + description = try container.decodeIfPresent(String.self, forKey: .description) headers = try container.decodeIfPresent(OpenAPI.Header.Map.self, forKey: .headers) content = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) ?? [:] links = try container.decodeIfPresent(OpenAPI.Link.Map.self, forKey: .links) ?? [:] vendorExtensions = try Self.extensions(from: decoder) + conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If description is nil, the document must be OAS version 3.2.0 or greater + notOptionalVersionWarning(fieldName: "description", value: description, minimumVersion: .v3_2_0) + ].compactMap { $0 } + } catch let error as GenericError { throw OpenAPI.Error.Decoding.Response(error) diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift index 443a15b42..b340f8557 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift @@ -1042,7 +1042,7 @@ extension JSONSchema.CoreContext: Decodable { .underlyingError( GenericError( subjectName: "OpenAPI Schema", - details: "Found 'nullable' property. This property is not supported by OpenAPI v3.1.x. OpenAPIKit has translated it into 'type: [\"null\", ...]'.", + details: "Found 'nullable' property. This property is not supported by OpenAPI v3.1.x. OpenAPIKit has translated it into 'type: [\"null\", ...]'", codingPath: container.codingPath ) ) diff --git a/Sources/OpenAPIKit/Tag.swift b/Sources/OpenAPIKit/Tag.swift index 47393cecb..27d9adf92 100644 --- a/Sources/OpenAPIKit/Tag.swift +++ b/Sources/OpenAPIKit/Tag.swift @@ -11,10 +11,20 @@ extension OpenAPI { /// OpenAPI Spec "Tag Object" /// /// See [OpenAPI Tag Object](https://spec.openapis.org/oas/v3.1.1.html#tag-object). - public struct Tag: Equatable, CodableVendorExtendable, Sendable { + public struct Tag: HasConditionalWarnings, CodableVendorExtendable, Sendable { public let name: String + /// Summary of the tag. Available for OAS 3.2.0 and greater. + public let summary: String? public let description: String? public let externalDocs: ExternalDocumentation? + /// The tag this tag is nested under. + public let parent: String? + /// A machine-readable string to categorize what sort of tag this is. + /// Any string value can be used, but some common options are provided + /// on OpenAPIKit's `Tag.Kind` type as static properties and more can + /// be found in the public registry: + /// https://spec.openapis.org/registry/tag-kind + public let kind: Kind? /// Dictionary of vendor extensions. /// @@ -23,17 +33,89 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + /// Warnings that apply conditionally depending on the OpenAPI Document + /// the Tag belongs to. + /// + /// Check these with the `applicableConditionalWarnings(for:)` method. + public let conditionalWarnings: [(any Condition, Warning)] + public init( name: String, + summary: String? = nil, description: String? = nil, externalDocs: ExternalDocumentation? = nil, + parent: String? = nil, + kind: Kind? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.name = name + self.summary = summary self.description = description self.externalDocs = externalDocs + self.parent = parent + self.kind = kind self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If parent is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "parent", value: parent, minimumVersion: .v3_2_0), + // If kind is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "kind", value: kind, minimumVersion: .v3_2_0) + ].compactMap { $0 } + } + } +} + +extension OpenAPI.Tag { + public struct Kind : ExpressibleByStringLiteral, Codable, Equatable, Sendable { + public let rawValue: String + + public init(stringLiteral: String) { + self.rawValue = stringLiteral } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.rawValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(rawValue) + } + } +} + +extension OpenAPI.Tag.Kind { + /// See https://spec.openapis.org/registry/tag-kind/audience.html + public static let audience: OpenAPI.Tag.Kind = "audience" + /// See https://spec.openapis.org/registry/tag-kind/badge.html + public static let badge: OpenAPI.Tag.Kind = "badge" + /// See https://spec.openapis.org/registry/tag-kind/nav.html + public static let nav: OpenAPI.Tag.Kind = "nav" +} + +fileprivate func nonNilVersionWarning(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? { + value.map { _ in + OpenAPI.Document.ConditionalWarnings.version( + lessThan: minimumVersion, + doesNotSupport: "The Tag \(fieldName) field" + ) + } +} + +extension OpenAPI.Tag: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.name == rhs.name + && lhs.summary == rhs.summary + && lhs.description == rhs.description + && lhs.externalDocs == rhs.externalDocs + && lhs.parent == rhs.parent + && lhs.kind == rhs.kind + && lhs.vendorExtensions == rhs.vendorExtensions } } @@ -43,15 +125,31 @@ extension OpenAPI.Tag: ExpressibleByStringLiteral { } } -// MARK: - Describable +// MARK: - Describable & Summarizable -extension OpenAPI.Tag : OpenAPIDescribable { +extension OpenAPI.Tag : OpenAPISummarizable { public func overriddenNonNil(description: String?) -> OpenAPI.Tag { guard let description = description else { return self } return OpenAPI.Tag( name: name, + summary: summary, + description: description, + externalDocs: externalDocs, + parent: parent, + kind: kind, + vendorExtensions: vendorExtensions + ) + } + + public func overriddenNonNil(summary: String?) -> OpenAPI.Tag { + guard let summary = summary else { return self } + return OpenAPI.Tag( + name: name, + summary: summary, description: description, externalDocs: externalDocs, + parent: parent, + kind: kind, vendorExtensions: vendorExtensions ) } @@ -65,10 +163,16 @@ extension OpenAPI.Tag: Encodable { try container.encode(name, forKey: .name) + try container.encodeIfPresent(summary, forKey: .summary) + try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(externalDocs, forKey: .externalDocs) + try container.encodeIfPresent(parent, forKey: .parent) + + try container.encodeIfPresent(kind, forKey: .kind) + if VendorExtensionsConfiguration.isEnabled(for: encoder) { try encodeExtensions(to: &container) } @@ -81,26 +185,47 @@ extension OpenAPI.Tag: Decodable { name = try container.decode(String.self, forKey: .name) + summary = try container.decodeIfPresent(String.self, forKey: .summary) + description = try container.decodeIfPresent(String.self, forKey: .description) externalDocs = try container.decodeIfPresent(OpenAPI.ExternalDocumentation.self, forKey: .externalDocs) + parent = try container.decodeIfPresent(String.self, forKey: .parent) + + kind = try container.decodeIfPresent(Kind.self, forKey: .kind) + vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = [ + // If summary is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "summary", value: summary, minimumVersion: .v3_2_0), + // If parent is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "parent", value: parent, minimumVersion: .v3_2_0), + // If kind is non-nil, the document must be OAS version 3.2.0 or greater + nonNilVersionWarning(fieldName: "kind", value: kind, minimumVersion: .v3_2_0) + ].compactMap { $0 } } } extension OpenAPI.Tag { internal enum CodingKeys: ExtendableCodingKey { case name + case summary case description case externalDocs + case parent + case kind case extended(String) static var allBuiltinKeys: [CodingKeys] { return [ .name, + .summary, .description, - .externalDocs + .externalDocs, + .parent, + .kind ] } @@ -112,10 +237,16 @@ extension OpenAPI.Tag { switch stringValue { case "name": self = .name + case "summary": + self = .summary case "description": self = .description case "externalDocs": self = .externalDocs + case "parent": + self = .parent + case "kind": + self = .kind default: self = .extendedKey(for: stringValue) } @@ -125,10 +256,16 @@ extension OpenAPI.Tag { switch self { case .name: return "name" + case .summary: + return "summary" case .description: return "description" case .externalDocs: return "externalDocs" + case .parent: + return "parent" + case .kind: + return "kind" case .extended(let key): return key } diff --git a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index 5df0cb34b..def7c7f79 100644 --- a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift @@ -505,6 +505,58 @@ extension Validation { } ) } + + /// Validate the OpenAPI Document's `Parameter`s all have styles that are + /// compatible with their locations per the table found at + /// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.2.0.md#style-values + /// + /// - Important: This is included in validation by default. + public static var parameterStyleAndLocationAreCompatible: Validation { + .init( + check: all( + Validation( + description: "the matrix style can only be used for the path location", + check: \.context.location == .path, + when: \.schemaStyle == .matrix + ), + Validation( + description: "the label style can only be used for the path location", + check: \.context.location == .path, + when: \.schemaStyle == .label + ), + Validation( + description: "the simple style can only be used for the path and header locations", + check: \.context.location == .path || \.context.location == .header, + when: \.schemaStyle == .simple + ), + Validation( + description: "the form style can only be used for the query and cookie locations", + check: \.context.location == .query || \.context.location == .cookie, + when: \.schemaStyle == .form + ), + Validation( + description: "the spaceDelimited style can only be used for the query location", + check: \.context.location == .query, + when: \.schemaStyle == .spaceDelimited + ), + Validation( + description: "the pipeDelimited style can only be used for the query location", + check: \.context.location == .query, + when: \.schemaStyle == .pipeDelimited + ), + Validation( + description: "the deepObject style can only be used for the query location", + check: \.context.location == .query, + when: \.schemaStyle == .deepObject + ), + Validation( + description: "the cookie style can only be used for the cookie location", + check: \.context.location == .cookie, + when: \.schemaStyle == .cookie + ) + ) + ) + } } /// Used by both the Path Item parameter check and the diff --git a/Sources/OpenAPIKit/Validator/Validation.swift b/Sources/OpenAPIKit/Validator/Validation.swift index 3e9851d42..b60d88f6f 100644 --- a/Sources/OpenAPIKit/Validator/Validation.swift +++ b/Sources/OpenAPIKit/Validator/Validation.swift @@ -128,10 +128,12 @@ public struct ValidationError: Swift.Error, CustomStringConvertible, PathContext public var localizedDescription: String { description } public var description: String { + let reasonStr: any StringProtocol = + reason.last == "." ? reason.dropLast() : reason guard !codingPath.isEmpty else { - return "\(reason) at root of document" + return "\(reasonStr) at root of document" } - return "\(reason) at path: \(codingPath.stringValue)" + return "\(reasonStr) at path: \(codingPath.stringValue)" } } diff --git a/Sources/OpenAPIKit/Validator/Validator.swift b/Sources/OpenAPIKit/Validator/Validator.swift index 21373a22d..66fcd8611 100644 --- a/Sources/OpenAPIKit/Validator/Validator.swift +++ b/Sources/OpenAPIKit/Validator/Validator.swift @@ -170,12 +170,12 @@ public final class Validator { /// - Parameters are unique within each Path Item. /// - Parameters are unique within each Operation. /// - Operation Ids are unique across the whole Document. - /// - All OpenAPI.References that refer to components in this - /// document can be found in the components dictionary. - /// - `Enum` must not be empty in the document's - /// Server Variable. - /// - `Default` must exist in the enum values in the document's - /// Server Variable. + /// - All OpenAPI.References that refer to components in this document can + /// be found in the components dictionary. + /// - `Enum` must not be empty in the document's Server Variable. + /// - `Default` must exist in the enum values in the document's Server + /// Variable. + /// - `Parameter` styles and locations are compatible with each other. /// public convenience init() { self.init(validations: [ @@ -193,7 +193,8 @@ public final class Validator { .init(.callbacksReferencesAreValid), .init(.pathItemReferencesAreValid), .init(.serverVariableEnumIsValid), - .init(.serverVariableDefaultExistsInEnum) + .init(.serverVariableDefaultExistsInEnum), + .init(.parameterStyleAndLocationAreCompatible) ]) } @@ -545,9 +546,15 @@ extension _Validator: SingleValueEncodingContainer { fileprivate func collectWarnings(from value: Encodable, atKey key: CodingKey? = nil) { let pathTail = key.map { [$0] } ?? [] + var localWarnings = [Warning]() if let warnable = value as? HasWarnings { - warnings += warnable.warnings.map(contextualize(at: codingPath + pathTail)) + localWarnings += warnable.warnings } + if let conditionalWarnable = value as? HasConditionalWarnings { + localWarnings += conditionalWarnable.applicableConditionalWarnings(for: document) + } + + warnings += localWarnings.map(contextualize(at: codingPath + pathTail)) } } diff --git a/Sources/OpenAPIKit/_CoreReExport.swift b/Sources/OpenAPIKit/_CoreReExport.swift index 8f7ed5d46..17e2b37ea 100644 --- a/Sources/OpenAPIKit/_CoreReExport.swift +++ b/Sources/OpenAPIKit/_CoreReExport.swift @@ -15,6 +15,7 @@ import OpenAPIKitCore public extension OpenAPI { + typealias BuiltinHttpMethod = OpenAPIKitCore.Shared.BuiltinHttpMethod typealias HttpMethod = OpenAPIKitCore.Shared.HttpMethod typealias ContentType = OpenAPIKitCore.Shared.ContentType typealias Error = OpenAPIKitCore.Error @@ -30,14 +31,6 @@ public extension OpenAPI.SecurityScheme { typealias Location = OpenAPIKitCore.Shared.SecuritySchemeLocation } -public extension OpenAPI.Parameter.Context { - typealias Location = OpenAPIKitCore.Shared.ParameterContextLocation -} - -public extension OpenAPI.Parameter.SchemaContext { - typealias Style = OpenAPIKitCore.Shared.ParameterSchemaContextStyle -} - public extension OpenAPI.Response { typealias StatusCode = OpenAPIKitCore.Shared.ResponseStatusCode } diff --git a/Sources/OpenAPIKit30/Document/Document.swift b/Sources/OpenAPIKit30/Document/Document.swift index 0cbab85c5..a47cafaee 100644 --- a/Sources/OpenAPIKit30/Document/Document.swift +++ b/Sources/OpenAPIKit30/Document/Document.swift @@ -685,7 +685,7 @@ internal func validateSecurityRequirements(in paths: OpenAPI.PathItem.Map, again } } -internal func validate(securityRequirements: [OpenAPI.SecurityRequirement], at path: OpenAPI.Path, for verb: OpenAPI.HttpMethod, against components: OpenAPI.Components) throws { +internal func validate(securityRequirements: [OpenAPI.SecurityRequirement], at path: OpenAPI.Path, for verb: OpenAPI.BuiltinHttpMethod, against components: OpenAPI.Components) throws { let securitySchemes = securityRequirements.flatMap { $0.keys } for securityScheme in securitySchemes { diff --git a/Sources/OpenAPIKit30/Operation/ResolvedEndpoint.swift b/Sources/OpenAPIKit30/Operation/ResolvedEndpoint.swift index ed33c127e..9d12735d3 100644 --- a/Sources/OpenAPIKit30/Operation/ResolvedEndpoint.swift +++ b/Sources/OpenAPIKit30/Operation/ResolvedEndpoint.swift @@ -52,7 +52,7 @@ public struct ResolvedEndpoint: Equatable { /// The HTTP method of this endpoint. /// /// e.g. GET, POST, PUT, PATCH, etc. - public let method: OpenAPI.HttpMethod + public let method: OpenAPI.BuiltinHttpMethod /// The path for this endpoint. public let path: OpenAPI.Path /// The parameters this endpoint accepts. diff --git a/Sources/OpenAPIKit30/Parameter/ParameterContextLocation.swift b/Sources/OpenAPIKit30/Parameter/ParameterContextLocation.swift new file mode 100644 index 000000000..89d323021 --- /dev/null +++ b/Sources/OpenAPIKit30/Parameter/ParameterContextLocation.swift @@ -0,0 +1,19 @@ +// +// ParameterContextLocation.swift +// +// +// Created by Mathew Polzin on 12/24/22. +// + +import OpenAPIKitCore + +extension OpenAPI.Parameter.Context { + public enum Location: String, CaseIterable, Codable { + case query + case header + case path + case cookie + } +} + +extension OpenAPI.Parameter.Context.Location: Validatable {} diff --git a/Sources/OpenAPIKitCore/Shared/ParameterSchemaContextStyle.swift b/Sources/OpenAPIKit30/Parameter/ParameterSchemaContextStyle.swift similarity index 70% rename from Sources/OpenAPIKitCore/Shared/ParameterSchemaContextStyle.swift rename to Sources/OpenAPIKit30/Parameter/ParameterSchemaContextStyle.swift index a3166d6dc..41d236e51 100644 --- a/Sources/OpenAPIKitCore/Shared/ParameterSchemaContextStyle.swift +++ b/Sources/OpenAPIKit30/Parameter/ParameterSchemaContextStyle.swift @@ -5,8 +5,8 @@ // Created by Mathew Polzin on 12/18/22. // -extension Shared { - public enum ParameterSchemaContextStyle: String, CaseIterable, Codable, Sendable { +extension OpenAPI.Parameter.SchemaContext { + public enum Style: String, CaseIterable, Codable, Sendable { case form case simple case matrix diff --git a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift index 542b8aa54..ae9d6e3be 100644 --- a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift @@ -77,7 +77,7 @@ public struct DereferencedPathItem: Equatable { extension DereferencedPathItem { /// Retrieve the operation for the given verb, if one is set for this path. - public func `for`(_ verb: OpenAPI.HttpMethod) -> DereferencedOperation? { + public func `for`(_ verb: OpenAPI.BuiltinHttpMethod) -> DereferencedOperation? { switch verb { case .delete: return self.delete @@ -95,10 +95,12 @@ extension DereferencedPathItem { return self.put case .trace: return self.trace + case .query: + return nil } } - public subscript(verb: OpenAPI.HttpMethod) -> DereferencedOperation? { + public subscript(verb: OpenAPI.BuiltinHttpMethod) -> DereferencedOperation? { get { return `for`(verb) } @@ -107,7 +109,7 @@ extension DereferencedPathItem { /// An `Endpoint` is the combination of an /// HTTP method and an operation. public struct Endpoint: Equatable { - public let method: OpenAPI.HttpMethod + public let method: OpenAPI.BuiltinHttpMethod public let operation: DereferencedOperation } @@ -116,7 +118,7 @@ extension DereferencedPathItem { /// - Returns: An array of `Endpoints` with the method (i.e. `.get`) and the operation for /// the method. public var endpoints: [Endpoint] { - return OpenAPI.HttpMethod.allCases.compactMap { method in + return OpenAPI.BuiltinHttpMethod.allCases.compactMap { method in self.for(method).map { .init(method: method, operation: $0) } } } diff --git a/Sources/OpenAPIKit30/Path Item/PathItem.swift b/Sources/OpenAPIKit30/Path Item/PathItem.swift index 3cbc5abc2..cceafda35 100644 --- a/Sources/OpenAPIKit30/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/PathItem.swift @@ -19,7 +19,7 @@ extension OpenAPI { /// The `GET` operation, for example, is accessed via the `.get` property. You can /// also use the subscript operator, passing it the `HTTPMethod` you want to access. /// - /// You can access an array of equatable `HttpMethod`/`Operation` paris with the + /// You can access an array of equatable `BuiltinHttpMethod`/`Operation` paris with the /// `endpoints` property. public struct PathItem: Equatable, CodableVendorExtendable, Sendable { public var summary: String? @@ -146,7 +146,7 @@ extension OrderedDictionary where Key == OpenAPI.Path { extension OpenAPI.PathItem { /// Retrieve the operation for the given verb, if one is set for this path. - public func `for`(_ verb: OpenAPI.HttpMethod) -> OpenAPI.Operation? { + public func `for`(_ verb: OpenAPI.BuiltinHttpMethod) -> OpenAPI.Operation? { switch verb { case .delete: return self.delete @@ -164,11 +164,13 @@ extension OpenAPI.PathItem { return self.put case .trace: return self.trace + case .query: + return nil } } /// Set the operation for the given verb, overwriting any already set operation for the same verb. - public mutating func set(operation: OpenAPI.Operation?, for verb: OpenAPI.HttpMethod) { + public mutating func set(operation: OpenAPI.Operation?, for verb: OpenAPI.BuiltinHttpMethod) { switch verb { case .delete: self.delete(operation) @@ -186,10 +188,13 @@ extension OpenAPI.PathItem { self.put(operation) case .trace: self.trace(operation) + case .query: + // not representable + print("The QUERY operation was not directly representable in the OAS standard until version 3.2.0") } } - public subscript(verb: OpenAPI.HttpMethod) -> OpenAPI.Operation? { + public subscript(verb: OpenAPI.BuiltinHttpMethod) -> OpenAPI.Operation? { get { return `for`(verb) } @@ -201,7 +206,7 @@ extension OpenAPI.PathItem { /// An `Endpoint` is the combination of an /// HTTP method and an operation. public struct Endpoint: Equatable { - public let method: OpenAPI.HttpMethod + public let method: OpenAPI.BuiltinHttpMethod public let operation: OpenAPI.Operation } @@ -210,7 +215,7 @@ extension OpenAPI.PathItem { /// - Returns: An array of `Endpoints` with the method (i.e. `.get`) and the operation for /// the method. public var endpoints: [Endpoint] { - return OpenAPI.HttpMethod.allCases.compactMap { method in + return OpenAPI.BuiltinHttpMethod.allCases.compactMap { method in self.for(method).map { .init(method: method, operation: $0) } } } diff --git a/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift b/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift index ed2a7062c..4d678b228 100644 --- a/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift +++ b/Sources/OpenAPIKit30/Path Item/ResolvedRoute.swift @@ -120,7 +120,7 @@ public struct ResolvedRoute: Equatable { } /// Retrieve the endpoint for the given method, if one exists for this route. - public func `for`(_ verb: OpenAPI.HttpMethod) -> ResolvedEndpoint? { + public func `for`(_ verb: OpenAPI.BuiltinHttpMethod) -> ResolvedEndpoint? { switch verb { case .delete: return self.delete @@ -138,10 +138,12 @@ public struct ResolvedRoute: Equatable { return self.put case .trace: return self.trace + case .query: + return nil } } - public subscript(verb: OpenAPI.HttpMethod) -> ResolvedEndpoint? { + public subscript(verb: OpenAPI.BuiltinHttpMethod) -> ResolvedEndpoint? { get { return `for`(verb) } diff --git a/Sources/OpenAPIKit30/_CoreReExport.swift b/Sources/OpenAPIKit30/_CoreReExport.swift index 8f7ed5d46..17e2b37ea 100644 --- a/Sources/OpenAPIKit30/_CoreReExport.swift +++ b/Sources/OpenAPIKit30/_CoreReExport.swift @@ -15,6 +15,7 @@ import OpenAPIKitCore public extension OpenAPI { + typealias BuiltinHttpMethod = OpenAPIKitCore.Shared.BuiltinHttpMethod typealias HttpMethod = OpenAPIKitCore.Shared.HttpMethod typealias ContentType = OpenAPIKitCore.Shared.ContentType typealias Error = OpenAPIKitCore.Error @@ -30,14 +31,6 @@ public extension OpenAPI.SecurityScheme { typealias Location = OpenAPIKitCore.Shared.SecuritySchemeLocation } -public extension OpenAPI.Parameter.Context { - typealias Location = OpenAPIKitCore.Shared.ParameterContextLocation -} - -public extension OpenAPI.Parameter.SchemaContext { - typealias Style = OpenAPIKitCore.Shared.ParameterSchemaContextStyle -} - public extension OpenAPI.Response { typealias StatusCode = OpenAPIKitCore.Shared.ResponseStatusCode } diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 88192c352..2ab98b06c 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -100,16 +100,8 @@ extension OpenAPIKit30.OpenAPI.Server: To31 { extension OpenAPIKit30.OpenAPI.Header: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Header { - let newSchemaOrContent: Either - switch schemaOrContent { - case .a(let context): - newSchemaOrContent = .a(context.to31()) - case .b(let contentMap): - newSchemaOrContent = .b(contentMap.mapValues { $0.to31() }) - } - - return OpenAPIKit.OpenAPI.Header( - schemaOrContent: newSchemaOrContent, + OpenAPIKit.OpenAPI.Header( + schemaOrContent: schemaOrContent.to31(), description: description, required: `required`, deprecated: deprecated, @@ -118,17 +110,28 @@ extension OpenAPIKit30.OpenAPI.Header: To31 { } } -extension OpenAPIKit30.OpenAPI.Parameter.Context: To31 { - fileprivate func to31() -> OpenAPIKit.OpenAPI.Parameter.Context { +extension Either where A == OpenAPIKit30.OpenAPI.Parameter.SchemaContext, B == OpenAPIKit30.OpenAPI.Content.Map { + fileprivate func to31() -> Either { + switch self { + case .a(let context): + .a(context.to31()) + case .b(let contentMap): + .b(contentMap.mapValues { $0.to31() }) + } + } +} + +extension OpenAPIKit30.OpenAPI.Parameter.Context { + fileprivate func to31(with schemaOrContent: Either) -> OpenAPIKit.OpenAPI.Parameter.Context { switch self { case .query(required: let required, allowEmptyValue: let allowEmptyValue): - return .query(required: required, allowEmptyValue: allowEmptyValue) + return .query(required: required, allowEmptyValue: allowEmptyValue, schemaOrContent: schemaOrContent.to31()) case .header(required: let required): - return .header(required: required) + return .header(required: required, schemaOrContent: schemaOrContent.to31()) case .path: - return .path + return .path(schemaOrContent: schemaOrContent.to31()) case .cookie(required: let required): - return .cookie(required: required) + return .cookie(required: required, schemaOrContent: schemaOrContent.to31()) } } } @@ -172,7 +175,7 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { if let newExamples { return OpenAPIKit.OpenAPI.Parameter.SchemaContext( schemaReference: .init(ref.to31()), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved, examples: newExamples @@ -180,7 +183,7 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { } else { return OpenAPIKit.OpenAPI.Parameter.SchemaContext( schemaReference: .init(ref.to31()), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved, example: example @@ -190,7 +193,7 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { if let newExamples { return OpenAPIKit.OpenAPI.Parameter.SchemaContext( schema.to31(), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved, examples: newExamples @@ -198,7 +201,7 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { } else { return OpenAPIKit.OpenAPI.Parameter.SchemaContext( schema.to31(), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved, example: example @@ -208,12 +211,26 @@ extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext: To31 { } } +extension OpenAPIKit30.OpenAPI.Parameter.SchemaContext.Style: To31 { + fileprivate func to31() -> OpenAPIKit.OpenAPI.Parameter.SchemaContext.Style { + switch self { + case .form: .form + case .simple: .simple + case .matrix: .matrix + case .label: .label + case .spaceDelimited: .spaceDelimited + case .pipeDelimited: .pipeDelimited + case .deepObject: .deepObject + } + } +} + extension OpenAPIKit30.OpenAPI.Content.Encoding: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Content.Encoding { OpenAPIKit.OpenAPI.Content.Encoding( contentTypes: [contentType].compactMap { $0 }, headers: headers?.mapValues(eitherRefTo31), - style: style, + style: style.to31(), explode: explode, allowReserved: allowReserved ) @@ -242,18 +259,9 @@ extension OpenAPIKit30.OpenAPI.Content: To31 { extension OpenAPIKit30.OpenAPI.Parameter: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Parameter { - let newSchemaOrContent: Either - switch schemaOrContent { - case .a(let context): - newSchemaOrContent = .a(context.to31()) - case .b(let contentMap): - newSchemaOrContent = .b(contentMap.mapValues { $0.to31() }) - } - return OpenAPIKit.OpenAPI.Parameter( name: name, - context: context.to31(), - schemaOrContent: newSchemaOrContent, + context: context.to31(with: schemaOrContent), description: description, deprecated: deprecated, vendorExtensions: vendorExtensions @@ -430,8 +438,10 @@ extension OpenAPIKit30.OpenAPI.Tag: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Tag { OpenAPIKit.OpenAPI.Tag( name: name, + summary: nil, description: description, externalDocs: externalDocs?.to31(), + parent: nil, vendorExtensions: vendorExtensions ) } @@ -642,14 +652,14 @@ extension OpenAPIKit30.OpenAPI.Components: To31 { fileprivate func to31() -> OpenAPIKit.OpenAPI.Components { OpenAPIKit.OpenAPI.Components( schemas: schemas.mapValues { $0.to31() }, - responses: responses.mapValues { $0.to31() }, - parameters: parameters.mapValues { $0.to31() }, - examples: examples.mapValues { $0.to31() }, - requestBodies: requestBodies.mapValues { $0.to31() }, - headers: headers.mapValues { $0.to31() }, - securitySchemes: securitySchemes.mapValues { $0.to31() }, - links: links.mapValues { $0.to31() }, - callbacks: callbacks.mapValues { $0.to31() }, + responses: responses.mapValues { .b($0.to31()) }, + parameters: parameters.mapValues { .b($0.to31()) }, + examples: examples.mapValues { .b($0.to31()) }, + requestBodies: requestBodies.mapValues { .b($0.to31()) }, + headers: headers.mapValues { .b($0.to31()) }, + securitySchemes: securitySchemes.mapValues { .b($0.to31()) }, + links: links.mapValues { .b($0.to31()) }, + callbacks: callbacks.mapValues { .b($0.to31()) }, vendorExtensions: vendorExtensions ) } diff --git a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/DecodingErrorExtensions.swift b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/DecodingErrorExtensions.swift index f203b8f3b..7d98b7388 100644 --- a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/DecodingErrorExtensions.swift +++ b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/DecodingErrorExtensions.swift @@ -112,3 +112,11 @@ internal struct DecodingErrorWrapper: OpenAPIError { var codingPath: [CodingKey] { decodingError.codingPath } } + +public extension ArraySlice where Element == any CodingKey { + mutating func removeFirstPathComponentString() -> String { + guard !isEmpty else { return "" } + + return removeFirst().stringValue + } +} diff --git a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIDecodingErrors.swift b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIDecodingErrors.swift index 4111ab04c..760122c9d 100644 --- a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIDecodingErrors.swift +++ b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIDecodingErrors.swift @@ -10,7 +10,7 @@ extension Error { public enum Decoding {} } -public enum ErrorCategory { +public enum ErrorCategory: Sendable { /// The type with the given name was expected but not found. case typeMismatch(expectedTypeName: String) /// One of two possible types were expected but neither was found. @@ -22,7 +22,7 @@ public enum ErrorCategory { /// Something inconsistent or disallowed according the OpenAPI Specification was found. case inconsistency(details: String) - public enum KeyValue { + public enum KeyValue: Sendable { case key case value } diff --git a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIWarning.swift b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIWarning.swift index 21aa92406..9ee5df67f 100644 --- a/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIWarning.swift +++ b/Sources/OpenAPIKitCore/Encoding and Decoding Errors And Warnings/OpenAPIWarning.swift @@ -65,5 +65,6 @@ extension Warning: CustomStringConvertible { } public protocol HasWarnings { + /// Warnings generated while decoding an OpenAPI type. var warnings: [Warning] { get } } diff --git a/Sources/OpenAPIKitCore/Shared/ContentType.swift b/Sources/OpenAPIKitCore/Shared/ContentType.swift index 24880dde4..ab70fa763 100644 --- a/Sources/OpenAPIKitCore/Shared/ContentType.swift +++ b/Sources/OpenAPIKitCore/Shared/ContentType.swift @@ -305,7 +305,7 @@ extension Shared.ContentType.Builtin: RawRepresentable { case .woff: return "font/woff" case .woff2: return "font/woff2" case .xml: return "application/xml" - case .yaml: return "application/x-yaml" + case .yaml: return "application/yaml" case .zip: return "application/zip" case .anyApplication: return "application/*" @@ -359,6 +359,7 @@ extension Shared.ContentType.Builtin: RawRepresentable { case "font/woff2": self = .woff2 case "application/xml": self = .xml case "application/x-yaml": self = .yaml + case "application/yaml": self = .yaml case "application/zip": self = .zip case "application/*": self = .anyApplication diff --git a/Sources/OpenAPIKitCore/Shared/HttpMethod.swift b/Sources/OpenAPIKitCore/Shared/HttpMethod.swift index 57481265e..f6f0f0d7a 100644 --- a/Sources/OpenAPIKitCore/Shared/HttpMethod.swift +++ b/Sources/OpenAPIKitCore/Shared/HttpMethod.swift @@ -9,10 +9,10 @@ extension Shared { /// Represents the HTTP methods supported by the /// OpenAPI Specification. /// - /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.0.4.html#path-item-object) because the supported - /// HTTP methods are enumerated as properties on that + /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.2.0.html#path-item-object) + /// because the supported HTTP methods are enumerated as properties on that /// object. - public enum HttpMethod: String, CaseIterable, Sendable { + public enum BuiltinHttpMethod: String, CaseIterable, Sendable { case get = "GET" case post = "POST" case patch = "PATCH" @@ -21,5 +21,102 @@ extension Shared { case head = "HEAD" case options = "OPTIONS" case trace = "TRACE" + case query = "QUERY" + } + + /// Represents an HTTP method. + /// + /// See [OpenAPI Path Item Object](https://spec.openapis.org/oas/v3.2.0.html#path-item-object). + /// + /// Methods are split into builtin methods (those representable as + /// properties on a Path Item Object) and other methods (those that can be + /// added to the `additionalOperations` of a Path Item Object). + /// + /// `HttpMethod` is `ExpressibleByStringLiteral` so you can write a + /// non-builtin method like "LINK" as: + /// `let linkMethod : OpenAPI.HttpMethod = "LINK"` + public enum HttpMethod: ExpressibleByStringLiteral, RawRepresentable, Equatable, Hashable, Codable, Sendable { + case builtin(BuiltinHttpMethod) + case other(String) + + public static let get = Self.builtin(.get) + public static let post = Self.builtin(.post) + public static let patch = Self.builtin(.patch) + public static let put = Self.builtin(.put) + public static let delete = Self.builtin(.delete) + public static let head = Self.builtin(.head) + public static let options = Self.builtin(.options) + public static let trace = Self.builtin(.trace) + public static let query = Self.builtin(.query) + + public var rawValue: String { + switch self { + case .builtin(let builtin): builtin.rawValue + case .other(let other): other + } + } + + public init?(rawValue: String) { + if let builtin = BuiltinHttpMethod.init(rawValue: rawValue) { + self = .builtin(builtin) + return + } + + let uppercasedValue = rawValue.uppercased() + if Self.additionalKnownUppercaseMethods.contains(uppercasedValue) && rawValue != uppercasedValue { + return nil + } + + // we accept that we do not know the correct capitalization for all + // possible method names and fall back to whatever the user has + // entered. + self = .other(rawValue) + } + + public init(stringLiteral value: String) { + if let valid = Self.init(rawValue: value) { + self = valid + return + } + // we accept that a value may be invalid if it has been hard coded + // as a literal because there is no compile-time evaluation and so + // no way to prevent this without sacrificing code cleanliness. + self = .other(value) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + let attemptedMethod = try container.decode(String.self) + + if let value = Self.init(rawValue: attemptedMethod) { + self = value + return + } + + throw GenericError(subjectName: "HTTP Method", details: "Failed to decode an HTTP method from \(attemptedMethod). This method name must be an uppercased string", codingPath: decoder.codingPath) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(self.rawValue) + } + + internal static let additionalKnownUppercaseMethods = [ + "LINK", + "CONNECT" + ] + } +} + +extension Shared.HttpMethod: StringConvertibleHintProvider { + public static func problem(with proposedString: String) -> String? { + let uppercasedValue = proposedString.uppercased() + if Self.additionalKnownUppercaseMethods.contains(uppercasedValue) && proposedString != uppercasedValue { + return "'\(proposedString)' must be uppercased" + } + + return nil } } diff --git a/Sources/OpenAPIKitCore/Shared/ParameterContextLocation.swift b/Sources/OpenAPIKitCore/Shared/ParameterContextLocation.swift deleted file mode 100644 index 1a36cc8e0..000000000 --- a/Sources/OpenAPIKitCore/Shared/ParameterContextLocation.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// ParameterContextLocation.swift -// -// -// Created by Mathew Polzin on 12/24/22. -// - -extension Shared { - public enum ParameterContextLocation: String, CaseIterable, Codable { - case query - case header - case path - case cookie - } -} - -extension Shared.ParameterContextLocation: Validatable {} diff --git a/Tests/OpenAPIKit30Tests/Validator/ValidatorTests.swift b/Tests/OpenAPIKit30Tests/Validator/ValidatorTests.swift index d8e3730af..dfd584fd8 100644 --- a/Tests/OpenAPIKit30Tests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKit30Tests/Validator/ValidatorTests.swift @@ -1067,7 +1067,7 @@ final class ValidatorTests: XCTestCase { let validator = Validator.blank .validating( - "All server arrays have not in operations have more than 1 server", + "All server arrays not in operations have more than 1 server", check: \[OpenAPI.Server].count > 1, when: \.codingPath.count == 1 // server array is under root document (coding path count 1) || take(\.codingPath) { codingPath in @@ -1075,7 +1075,7 @@ final class ValidatorTests: XCTestCase { guard codingPath.count > 1 else { return false } let secondToLastPathComponent = codingPath.suffix(2).first!.stringValue - let httpMethods = OpenAPI.HttpMethod.allCases.map { $0.rawValue.lowercased() } + let httpMethods = OpenAPI.BuiltinHttpMethod.allCases.map { $0.rawValue.lowercased() } return !httpMethods.contains(secondToLastPathComponent) } diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 100430d22..9568e15e1 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -834,8 +834,8 @@ final class DocumentConversionTests: XCTestCase { try assertEqualNewToOld(newDoc,oldDoc) - let newParameter1 = try XCTUnwrap(newDoc.components.parameters["param1"]) - let newParameter2 = try XCTUnwrap(newDoc.components.parameters["param2"]) + let newParameter1 = try XCTUnwrap(newDoc.components.parameters["param1"]?.b) + let newParameter2 = try XCTUnwrap(newDoc.components.parameters["param2"]?.b) try assertEqualNewToOld(newParameter1, parameter1) try assertEqualNewToOld(newParameter2, parameter2) @@ -996,14 +996,14 @@ fileprivate func assertEqualNewToOld(_ newParam: OpenAPIKit.OpenAPI.Parameter, _ fileprivate func assertEqualNewToOld(_ newParamContext: OpenAPIKit.OpenAPI.Parameter.Context, _ oldParamContext: OpenAPIKit30.OpenAPI.Parameter.Context) { switch (newParamContext, oldParamContext) { - case (.query(required: let req, allowEmptyValue: let empty), .query(required: let req2, allowEmptyValue: let empty2)): + case (.query(required: let req, allowEmptyValue: let empty, schemaOrContent: _), .query(required: let req2, allowEmptyValue: let empty2)): XCTAssertEqual(req, req2) XCTAssertEqual(empty, empty2) - case (.header(required: let req), .header(required: let req2)): + case (.header(required: let req, schemaOrContent: _), .header(required: let req2)): XCTAssertEqual(req, req2) case (.path, .path): break - case (.cookie(required: let req), .cookie(required: let req2)): + case (.cookie(required: let req, schemaOrContent: _), .cookie(required: let req2)): XCTAssertEqual(req, req2) default: XCTFail("Parameter contexts are not equal. \(newParamContext) / \(oldParamContext)") @@ -1137,6 +1137,7 @@ fileprivate func assertEqualNewToOld(_ newSchema: OpenAPIKit.JSONSchema, _ oldSc case .number(let coreContext, let numericContext): let newNumericContext = try XCTUnwrap(newSchema.numberContext) // TODO: compare number contexts + // try assertEqualNewToOld(newNumericContext, numericContext) try assertEqualNewToOld(newCoreContext, coreContext) case .integer(let coreContext, let integerContext): @@ -1253,11 +1254,29 @@ fileprivate func assertEqualNewToOld(_ newEncoding: OpenAPIKit.OpenAPI.Content.E } else { XCTAssertNil(oldEncoding.headers) } - XCTAssertEqual(newEncoding.style, oldEncoding.style) + try assertEqualNewToOld(newEncoding.style, oldEncoding.style) XCTAssertEqual(newEncoding.explode, oldEncoding.explode) XCTAssertEqual(newEncoding.allowReserved, oldEncoding.allowReserved) } +fileprivate func assertEqualNewToOld(_ newStyle: OpenAPIKit.OpenAPI.Parameter.SchemaContext.Style, _ oldStyle: OpenAPIKit30.OpenAPI.Parameter.SchemaContext.Style) throws { + let equal: Bool + switch (newStyle, oldStyle) { + case (.form, .form): equal = true + case (.simple, .simple): equal = true + case (.matrix, .matrix): equal = true + case (.label, .label): equal = true + case (.spaceDelimited, .spaceDelimited): equal = true + case (.pipeDelimited, .pipeDelimited): equal = true + case (.deepObject, .deepObject): equal = true + default: equal = false + } + + if !equal { + XCTFail("New \(newStyle) is not equivalent to old \(oldStyle)") + } +} + fileprivate func assertEqualNewToOld(_ newHeader: OpenAPIKit.OpenAPI.Header, _ oldHeader: OpenAPIKit30.OpenAPI.Header) throws { XCTAssertEqual(newHeader.description, oldHeader.description) XCTAssertEqual(newHeader.required, oldHeader.required) @@ -1274,7 +1293,7 @@ fileprivate func assertEqualNewToOld(_ newHeader: OpenAPIKit.OpenAPI.Header, _ o } fileprivate func assertEqualNewToOld(_ newSchemaContext: OpenAPIKit.OpenAPI.Parameter.SchemaContext, _ oldSchemaContext: OpenAPIKit30.OpenAPI.Parameter.SchemaContext) throws { - XCTAssertEqual(newSchemaContext.style, oldSchemaContext.style) + try assertEqualNewToOld(newSchemaContext.style, oldSchemaContext.style) XCTAssertEqual(newSchemaContext.explode, oldSchemaContext.explode) XCTAssertEqual(newSchemaContext.allowReserved, oldSchemaContext.allowReserved) switch (newSchemaContext.schema, oldSchemaContext.schema) { @@ -1478,36 +1497,68 @@ fileprivate func assertEqualNewToOld(_ newComponents: OpenAPIKit.OpenAPI.Compone let oldSchema = try XCTUnwrap(oldComponents.schemas[key]) try assertEqualNewToOld(newSchema, oldSchema) } - for (key, newResponse) in newComponents.responses { + for (key, maybeNewResponse) in newComponents.responses { let oldResponse = try XCTUnwrap(oldComponents.responses[key]) + guard case let .b(newResponse) = maybeNewResponse else { + XCTFail("Found a reference to a response where one was not expected") + return + } try assertEqualNewToOld(newResponse, oldResponse) } - for (key, newParameter) in newComponents.parameters { + for (key, maybeNewParameter) in newComponents.parameters { let oldParameter = try XCTUnwrap(oldComponents.parameters[key]) + guard case let .b(newParameter) = maybeNewParameter else { + XCTFail("Found a reference to a parameter where one was not expected") + return + } try assertEqualNewToOld(newParameter, oldParameter) } - for (key, newExample) in newComponents.examples { + for (key, maybeNewExample) in newComponents.examples { let oldExample = try XCTUnwrap(oldComponents.examples[key]) + guard case let .b(newExample) = maybeNewExample else { + XCTFail("Found a reference to an example where one was not expected") + return + } assertEqualNewToOld(newExample, oldExample) } - for (key, newRequest) in newComponents.requestBodies { + for (key, maybeNewRequest) in newComponents.requestBodies { let oldRequest = try XCTUnwrap(oldComponents.requestBodies[key]) + guard case let .b(newRequest) = maybeNewRequest else { + XCTFail("Found a reference to a request where one was not expected") + return + } try assertEqualNewToOld(newRequest, oldRequest) } - for (key, newHeader) in newComponents.headers { + for (key, maybeNewHeader) in newComponents.headers { let oldHeader = try XCTUnwrap(oldComponents.headers[key]) + guard case let .b(newHeader) = maybeNewHeader else { + XCTFail("Found a reference to a header where one was not expected") + return + } try assertEqualNewToOld(newHeader, oldHeader) } - for (key, newSecurity) in newComponents.securitySchemes { + for (key, maybeNewSecurity) in newComponents.securitySchemes { let oldSecurity = try XCTUnwrap(oldComponents.securitySchemes[key]) + guard case let .b(newSecurity) = maybeNewSecurity else { + XCTFail("Found a reference to a security scheme where one was not expected") + return + } try assertEqualNewToOld(newSecurity, oldSecurity) } - for (key, newLink) in newComponents.links { + for (key, maybeNewLink) in newComponents.links { let oldLink = try XCTUnwrap(oldComponents.links[key]) + guard case let .b(newLink) = maybeNewLink else { + XCTFail("Found a reference to a link where one was not expected") + return + } try assertEqualNewToOld(newLink, oldLink) } - for (key, newCallbacks) in newComponents.callbacks { + for (key, maybeNewCallbacks) in newComponents.callbacks { let oldCallbacks = try XCTUnwrap(oldComponents.callbacks[key]) + guard case let .b(newCallbacks) = maybeNewCallbacks else { + XCTFail("Found a reference to a callbacks object where one was not expected") + return + } for (key, newCallback) in newCallbacks { let oldPathItem = try XCTUnwrap(oldCallbacks[key]) switch (newCallback) { diff --git a/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift b/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift index d3ed74883..e4c201153 100644 --- a/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift +++ b/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift @@ -66,6 +66,17 @@ final class ContentTypeTests: XCTestCase { } } + func test_x_yaml() { + // test that we support old x-yaml type but also prefer new official media type + let type1 = Shared.ContentType.init(rawValue: "application/yaml") + let type2 = Shared.ContentType.init(rawValue: "application/x-yaml") + + XCTAssertEqual(type1?.rawValue, "application/yaml") + XCTAssertEqual(type1, .yaml) + XCTAssertEqual(type2?.rawValue, "application/yaml") + XCTAssertEqual(type2, .yaml) + } + func test_goodParam() { let type = Shared.ContentType.init(rawValue: "text/html; charset=utf8") XCTAssertEqual(type?.warnings.count, 0) diff --git a/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift index fba1f7fd3..ff178666a 100644 --- a/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift @@ -52,36 +52,6 @@ final class ResponseErrorTests: XCTestCase { } } - func test_missingDescriptionResponseObject() { - let documentYML = - """ - openapi: "3.1.0" - info: - title: test - version: 1.0 - paths: - /hello/world: - get: - responses: - '200': - not-a-thing: hi - """ - - XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in - - let openAPIError = OpenAPI.Error(from: error) - - XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Response in .responses.200 for the **GET** endpoint under `/hello/world`. \n\nResponse could not be decoded because:\nExpected to find `description` key but it is missing..") - XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ - "paths", - "/hello/world", - "get", - "responses", - "200" - ]) - } - } - func test_badResponseExtension() { let documentYML = """ diff --git a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift index deeb1f6cb..4aaca5ff4 100644 --- a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift @@ -76,7 +76,7 @@ final class SchemaErrorTests: XCTestCase { XCTAssertEqual(openAPIError.localizedDescription, """ - Problem encountered when parsing `OpenAPI Schema`: Found 'nullable' property. This property is not supported by OpenAPI v3.1.x. OpenAPIKit has translated it into 'type: ["null", ...]'.. at path: .paths['/hello/world'].get.responses.200.content['application/json'].schema + Problem encountered when parsing `OpenAPI Schema`: Found 'nullable' property. This property is not supported by OpenAPI v3.1.x. OpenAPIKit has translated it into 'type: ["null", ...]' at path: .paths['/hello/world'].get.responses.200.content['application/json'].schema """) XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "paths", diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 00c5b10bd..3a7ad1d35 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -25,11 +25,111 @@ final class ComponentsTests: XCTestCase { XCTAssertFalse(c3.isEmpty) } + func test_directConstructor() { + let c1 = OpenAPI.Components( + schemas: [ + "one": .string + ], + responses: [ + "two": .response(.init(description: "hello", content: [:])) + ], + parameters: [ + "three": .parameter(.init(name: "hi", context: .query(content: [:]))) + ], + examples: [ + "four": .example(.init(value: .init(URL(string: "http://address.com")!))) + ], + requestBodies: [ + "five": .request(.init(content: [:])) + ], + headers: [ + "six": .header(.init(schema: .string)) + ], + securitySchemes: [ + "seven": .securityScheme(.http(scheme: "cool")) + ], + links: [ + "eight": .link(.init(operationId: "op1")) + ], + callbacks: [ + "nine": .callbacks([ + OpenAPI.CallbackURL(rawValue: "{$request.query.queryUrl}")!: .pathItem( + .init( + post: .init( + responses: [ + 200: .response( + description: "callback successfully processed" + ) + ] + ) + ) + ) + ]) + ], + pathItems: [ + "ten": .init(get: .init(responses: [200: .response(description: "response")])) + ], + vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] + ) + + let c2 = OpenAPI.Components.direct( + schemas: [ + "one": .string + ], + responses: [ + "two": .init(description: "hello", content: [:]) + ], + parameters: [ + "three": .init(name: "hi", context: .query(content: [:])) + ], + examples: [ + "four": .init(value: .init(URL(string: "http://address.com")!)) + ], + requestBodies: [ + "five": .init(content: [:]) + ], + headers: [ + "six": .init(schema: .string) + ], + securitySchemes: [ + "seven": .http(scheme: "cool") + ], + links: [ + "eight": .init(operationId: "op1") + ], + callbacks: [ + "nine": [ + OpenAPI.CallbackURL(rawValue: "{$request.query.queryUrl}")!: .pathItem( + .init( + post: .init( + responses: [ + 200: .response( + description: "callback successfully processed" + ) + ] + ) + ) + ) + ] + ], + pathItems: [ + "ten": .init(get: .init(responses: [200: .response(description: "response")])) + ], + vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] + ) + + XCTAssertEqual(c1, c2) + } + func test_referenceLookup() throws { let components = OpenAPI.Components( schemas: [ "hello": .string, "world": .integer(required: false) + ], + parameters: [ + "my_direct_param": .parameter(.cookie(name: "my_param", schema: .string)), + "my_param": .reference(.component(named: "my_direct_param")) ] ) @@ -50,11 +150,30 @@ final class ComponentsTests: XCTestCase { XCTAssertNil(components[ref5]) XCTAssertNil(components[ref6]) - let ref7 = JSONReference.external(URL(string: "hello.json")!) + let ref7 = JSONReference.component(named: "my_param") + + XCTAssertEqual(components[ref7], .cookie(name: "my_param", schema: .string)) + + let ref8 = JSONReference.external(URL(string: "hello.json")!) + + XCTAssertNil(components[ref8]) + + XCTAssertThrowsError(try components.contains(ref8)) + } + + func test_lookupOnce() throws { + let components = OpenAPI.Components( + parameters: [ + "my_direct_param": .parameter(.cookie(name: "my_param", schema: .string)), + "my_param": .reference(.component(named: "my_direct_param")) + ] + ) - XCTAssertNil(components[ref7]) + let ref1 = JSONReference.component(named: "my_param") + let ref2 = JSONReference.component(named: "my_direct_param") - XCTAssertThrowsError(try components.contains(ref7)) + XCTAssertEqual(try components.lookupOnce(ref1), .reference(.component(named: "my_direct_param"))) + XCTAssertEqual(try components.lookupOnce(ref2), .parameter(.cookie(name: "my_param", schema: .string))) } func test_failedExternalReferenceLookup() { @@ -91,7 +210,7 @@ final class ComponentsTests: XCTestCase { } func test_lookupEachType() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( schemas: [ "one": .string ], @@ -99,7 +218,7 @@ final class ComponentsTests: XCTestCase { "two": .init(description: "hello", content: [:]) ], parameters: [ - "three": .init(name: "hello", context: .query, schema: .string) + "three": .init(name: "hello", context: .query(schema: .string)) ], examples: [ "four": .init(value: .init(URL(string: "hello.com/hello")!)) @@ -139,7 +258,7 @@ final class ComponentsTests: XCTestCase { XCTAssertEqual(components[ref1], .string) XCTAssertEqual(components[ref2], .init(description: "hello", content: [:])) - XCTAssertEqual(components[ref3], .init(name: "hello", context: .query, schema: .string)) + XCTAssertEqual(components[ref3], .init(name: "hello", context: .query(schema: .string))) XCTAssertEqual(components[ref4], .init(value: .init(URL(string: "hello.com/hello")!))) XCTAssertEqual(components[ref5], .init(content: [:])) XCTAssertEqual(components[ref6], .init(schema: .string)) @@ -184,7 +303,10 @@ final class ComponentsTests: XCTestCase { "hello": .boolean ], links: [ - "linky": .init(operationId: "op 1") + "linky": .link(.init(operationId: "op 1")), + "linky_ref": .reference(.component(named: "linky")), + "cycle_start": .reference(.component(named: "cycle_end")), + "cycle_end": .reference(.component(named: "cycle_start")) ] ) @@ -214,6 +336,20 @@ final class ComponentsTests: XCTestCase { XCTAssertEqual((error as? OpenAPI.Components.ReferenceError)?.description, "Failed to look up a JSON Reference. 'hello' was not found in links.") } + let link2: Either, OpenAPI.Link> = .reference(.component(named: "linky")) + + XCTAssertEqual(try components.lookup(link2), .init(operationId: "op 1")) + + let link3: Either, OpenAPI.Link> = .reference(.component(named: "linky_ref")) + + XCTAssertEqual(try components.lookup(link3), .init(operationId: "op 1")) + + let link4: Either, OpenAPI.Link> = .reference(.component(named: "cycle_start")) + + XCTAssertThrowsError(try components.lookup(link4)) { error in + XCTAssertEqual((error as? OpenAPI.Components.ReferenceCycleError)?.description, "Encountered a JSON Schema $ref cycle that prevents fully resolving a reference at \'#/components/links/cycle_start\'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy or fully looking up references in cycles. `lookupOnce()` is your best option in this case.") + } + let reference1: JSONReference = .component(named: "hello") let resolvedSchema2 = try components.lookup(reference1) @@ -276,7 +412,7 @@ extension ComponentsTests { } func test_maximal_encode() throws { - let t1 = OpenAPI.Components( + let t1 = OpenAPI.Components.direct( schemas: [ "one": .string ], @@ -284,7 +420,7 @@ extension ComponentsTests { "two": .init(description: "hello", content: [:]) ], parameters: [ - "three": .init(name: "hi", context: .query, content: [:]) + "three": .init(name: "hi", context: .query(content: [:])) ], examples: [ "four": .init(value: .init(URL(string: "http://address.com")!)) @@ -498,7 +634,7 @@ extension ComponentsTests { XCTAssertEqual( decoded, - OpenAPI.Components( + OpenAPI.Components.direct( schemas: [ "one": .string ], @@ -506,7 +642,7 @@ extension ComponentsTests { "two": .init(description: "hello", content: [:]) ], parameters: [ - "three": .init(name: "hi", context: .query, content: [:]) + "three": .init(name: "hi", context: .query(content: [:])) ], examples: [ "four": .init(value: .init(URL(string: "http://address.com")!)) @@ -581,7 +717,8 @@ extension ComponentsTests { options: op, head: op, patch: op, - trace: op + trace: op, + query: op ) ] ) @@ -614,6 +751,9 @@ extension ComponentsTests { }, "put" : { + }, + "query" : { + }, "trace" : { @@ -646,6 +786,8 @@ extension ComponentsTests { "put" : { }, "trace" : { + }, + "query" : { } } } @@ -667,7 +809,8 @@ extension ComponentsTests { options: op, head: op, patch: op, - trace: op + trace: op, + query: op ) ] ) diff --git a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift index e3238b256..bf4ae9bb2 100644 --- a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift @@ -16,7 +16,7 @@ final class DereferencedContentTests: XCTestCase { } func test_oneExampleReferenced() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( examples: ["test": .init(value: .init("hello world"))] ) let t1 = try OpenAPI.Content( @@ -31,7 +31,7 @@ final class DereferencedContentTests: XCTestCase { } func test_multipleExamplesReferenced() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( examples: [ "test1": .init(value: .init("hello world")), "test2": .init(value: .a(URL(string: "http://website.com")!)) @@ -130,7 +130,7 @@ final class DereferencedContentTests: XCTestCase { } func test_referencedHeaderInEncoding() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( headers: [ "test": OpenAPI.Header(schema: .string) ] diff --git a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift index 678223292..777b51e50 100644 --- a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift @@ -64,7 +64,7 @@ final class DereferencedDocumentTests: XCTestCase { } func test_noSecurityReferencedResponseInPath() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "test": .init(description: "success") ] @@ -92,7 +92,7 @@ final class DereferencedDocumentTests: XCTestCase { } func test_securityAndReferencedResponseInPath() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "test": .init(description: "success") ], diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index 985c9faed..18d493db8 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -20,6 +20,7 @@ final class DocumentTests: XCTestCase { let _ = OpenAPI.Document( openAPIVersion: .v3_1_0, + selfURI: .init(string: "https://example.com/openapi")!, info: .init(title: "hi", version: "1.0"), servers: [ .init(url: URL(string: "https://google.com")!) @@ -48,24 +49,45 @@ final class DocumentTests: XCTestCase { let t2 = OpenAPI.Document.Version.v3_1_1 XCTAssertEqual(t2.rawValue, "3.1.1") - let t3 = OpenAPI.Document.Version.v3_1_x(x: 2) + let t3 = OpenAPI.Document.Version.v3_1_2 XCTAssertEqual(t3.rawValue, "3.1.2") let t4 = OpenAPI.Document.Version.v3_1_x(x: 8) XCTAssertEqual(t4.rawValue, "3.1.8") - let t5 = OpenAPI.Document.Version(rawValue: "3.1.0") - XCTAssertEqual(t5, .v3_1_0) + let t5 = OpenAPI.Document.Version.v3_2_0 + XCTAssertEqual(t5.rawValue, "3.2.0") - let t6 = OpenAPI.Document.Version(rawValue: "3.1.1") - XCTAssertEqual(t6, .v3_1_1) + let t6 = OpenAPI.Document.Version(rawValue: "3.1.0") + XCTAssertEqual(t6, .v3_1_0) - let t7 = OpenAPI.Document.Version(rawValue: "3.1.2") - XCTAssertEqual(t7, .v3_1_x(x: 2)) + let t7 = OpenAPI.Document.Version(rawValue: "3.1.1") + XCTAssertEqual(t7, .v3_1_1) + + let t8 = OpenAPI.Document.Version(rawValue: "3.1.2") + XCTAssertEqual(t8, .v3_1_2) // not a known version: - let t8 = OpenAPI.Document.Version(rawValue: "3.1.8") - XCTAssertNil(t8) + let t9 = OpenAPI.Document.Version(rawValue: "3.1.8") + XCTAssertNil(t9) + + let t10 = OpenAPI.Document.Version(rawValue: "3.2.8") + XCTAssertNil(t10) + } + + func test_compareOASVersions() { + let versions: [OpenAPI.Document.Version] = [ + .v3_1_0, + .v3_1_1, + .v3_1_2, + .v3_2_0 + ] + + for v1Idx in 0...(versions.count - 2) { + for v2Idx in (v1Idx + 1)...(versions.count - 1) { + XCTAssert(versions[v1Idx] < versions[v2Idx]) + } + } } func test_getRoutes() { @@ -114,7 +136,12 @@ final class DocumentTests: XCTestCase { "/hello": .init( get: .init(operationId: nil, responses: [:])), "/hello/world": .init( - put: .init(operationId: nil, responses: [:])) + put: .init(operationId: nil, responses: [:])), + "/hi/mom": .init( + additionalOperations: [ + "LINK": .init(operationId: nil, responses: [:]) + ] + ) ], components: .noComponents ) @@ -129,7 +156,12 @@ final class DocumentTests: XCTestCase { "/hello": .init( get: .init(operationId: "test", responses: [:])), "/hello/world": .init( - put: .init(operationId: nil, responses: [:])) + put: .init(operationId: nil, responses: [:])), + "/hi/mom": .init( + additionalOperations: [ + "LINK": .init(operationId: nil, responses: [:]) + ] + ) ], components: .noComponents ) @@ -144,12 +176,17 @@ final class DocumentTests: XCTestCase { "/hello": .init( get: .init(operationId: "test", responses: [:])), "/hello/world": .init( - put: .init(operationId: "two", responses: [:])) + put: .init(operationId: "two", responses: [:])), + "/hi/mom": .init( + additionalOperations: [ + "LINK": .init(operationId: "three", responses: [:]) + ] + ) ], components: .noComponents ) - XCTAssertEqual(t3.allOperationIds, ["test", "two"]) + XCTAssertEqual(t3.allOperationIds, ["test", "two", "three"]) // paths, one operation id (first one nil), no components, no webhooks let t4 = OpenAPI.Document( @@ -159,7 +196,12 @@ final class DocumentTests: XCTestCase { "/hello": .init( get: .init(operationId: nil, responses: [:])), "/hello/world": .init( - put: .init(operationId: "two", responses: [:])) + put: .init(operationId: "two", responses: [:])), + "/hi/mom": .init( + additionalOperations: [ + "LINK": .init(operationId: nil, responses: [:]) + ] + ) ], components: .noComponents ) @@ -669,6 +711,60 @@ extension DocumentTests { ) } + func test_specifySelfURI_encode() throws { + let document = OpenAPI.Document( + selfURI: .init(string: "https://example.com/openapi")!, + info: .init(title: "API", version: "1.0"), + servers: [], + paths: [:], + components: .noComponents + ) + let encodedDocument = try orderUnstableTestStringFromEncoding(of: document) + + assertJSONEquivalent( + encodedDocument, + """ + { + "$self" : "https:\\/\\/example.com\\/openapi", + "info" : { + "title" : "API", + "version" : "1.0" + }, + "openapi" : "3.1.1" + } + """ + ) + } + + func test_specifySelfURI_decode() throws { + let documentData = + """ + { + "$self": "https://example.com/openapi", + "info" : { + "title" : "API", + "version" : "1.0" + }, + "openapi" : "3.1.1", + "paths" : { + + } + } + """.data(using: .utf8)! + let document = try orderUnstableDecode(OpenAPI.Document.self, from: documentData) + + XCTAssertEqual( + document, + OpenAPI.Document( + selfURI: .init(string: "https://example.com/openapi")!, + info: .init(title: "API", version: "1.0"), + servers: [], + paths: [:], + components: .noComponents + ) + ) + } + func test_specifyPaths_encode() throws { let document = OpenAPI.Document( info: .init(title: "API", version: "1.0"), @@ -731,7 +827,7 @@ extension DocumentTests { info: .init(title: "API", version: "1.0"), servers: [], paths: [:], - components: .init( + components: .direct( securitySchemes: ["security": .init(type: .apiKey(name: "key", location: .header))] ), security: [[.component( named: "security"):[]]] @@ -806,7 +902,7 @@ extension DocumentTests { info: .init(title: "API", version: "1.0"), servers: [], paths: [:], - components: .init( + components: .direct( securitySchemes: ["security": .init(type: .apiKey(name: "key", location: .header))] ), security: [[.component( named: "security"):[]]] @@ -1044,7 +1140,7 @@ extension DocumentTests { func test_webhooks_encode() throws { let op = OpenAPI.Operation(responses: [:]) - let pathItem: OpenAPI.PathItem = .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op) + let pathItem: OpenAPI.PathItem = .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op, query: op) let pathItemTest: Either, OpenAPI.PathItem> = .pathItem(pathItem) let document = OpenAPI.Document( @@ -1092,6 +1188,9 @@ extension DocumentTests { }, "put" : { + }, + "query" : { + }, "trace" : { @@ -1106,7 +1205,7 @@ extension DocumentTests { func test_webhooks_encode_decode() throws { let op = OpenAPI.Operation(responses: [:]) - let pathItem = OpenAPI.PathItem(get: op, put: op, post: op, options: op, head: op, patch: op, trace: op) + let pathItem = OpenAPI.PathItem(get: op, put: op, post: op, options: op, head: op, patch: op, trace: op, query: op) let document = OpenAPI.Document( info: .init(title: "API", version: "1.0"), @@ -1157,6 +1256,8 @@ extension DocumentTests { "put": { }, "trace": { + }, + "query": { } } } @@ -1172,7 +1273,7 @@ extension DocumentTests { servers: [], paths: [:], webhooks: [ - "webhook-test": .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op) + "webhook-test": .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op, query: op) ], components: .noComponents, externalDocs: .init(url: URL(string: "http://google.com")!) @@ -1182,7 +1283,7 @@ extension DocumentTests { func test_webhooks_noPaths_encode() throws { let op = OpenAPI.Operation(responses: [:]) - let pathItem: OpenAPI.PathItem = .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op) + let pathItem: OpenAPI.PathItem = .init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op, query: op) let pathItemTest: Either, OpenAPI.PathItem> = .pathItem(pathItem) let document = OpenAPI.Document( @@ -1230,6 +1331,9 @@ extension DocumentTests { }, "put" : { + }, + "query" : { + }, "trace" : { @@ -1271,6 +1375,8 @@ extension DocumentTests { "put": { }, "trace": { + }, + "query": { } } } @@ -1286,7 +1392,7 @@ extension DocumentTests { servers: [], paths: [:], webhooks: [ - "webhook-test": .pathItem(.init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op)) + "webhook-test": .pathItem(.init(get: op, put: op, post: op, delete: op, options: op, head: op, patch: op, trace: op, query: op)) ], components: .noComponents, externalDocs: .init(url: URL(string: "http://google.com")!) diff --git a/Tests/OpenAPIKitTests/Document/ResolvedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/ResolvedDocumentTests.swift index 76aebf84b..06b538539 100644 --- a/Tests/OpenAPIKitTests/Document/ResolvedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/ResolvedDocumentTests.swift @@ -29,7 +29,7 @@ final class ResolvedDocumentTests: XCTestCase { } func test_documentWithSecurity() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( securitySchemes: [ "test": .apiKey(name: "api-key", location: .cookie) ] diff --git a/Tests/OpenAPIKitTests/EaseOfUseTests.swift b/Tests/OpenAPIKitTests/EaseOfUseTests.swift index 45854c598..b6e59547b 100644 --- a/Tests/OpenAPIKitTests/EaseOfUseTests.swift +++ b/Tests/OpenAPIKitTests/EaseOfUseTests.swift @@ -39,8 +39,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { parameters: [ .parameter( name: "param", - context: .path, - schema: .string + context: .path(schema: .string) ) ], get: .init( @@ -51,12 +50,14 @@ final class DeclarativeEaseOfUseTests: XCTestCase { .reference(.component( named: "filter")), .parameter( name: "Content-Type", - context: .header(required: false), - schema: .string( - allowedValues: [ - .init(OpenAPI.ContentType.json.rawValue), - .init(OpenAPI.ContentType.txt.rawValue) - ] + context: .header( + required: false, + schema: .string( + allowedValues: [ + .init(OpenAPI.ContentType.json.rawValue), + .init(OpenAPI.ContentType.txt.rawValue) + ] + ) ) ) ], @@ -109,23 +110,25 @@ final class DeclarativeEaseOfUseTests: XCTestCase { ) ) ], - components: .init( + components: .direct( schemas: [ "string_schema": .string ], parameters: [ "filter": .init( name: "filter", - context: .query(required: false), - schema: .init( - .object( - properties: [ - "size": .integer, - "shape": .string(allowedValues: [ "round", "square" ]) - ] - ), - style: .deepObject, - explode: true + context: .query( + required: false, + schemaOrContent: .schema(.init( + .object( + properties: [ + "size": .integer, + "shape": .string(allowedValues: [ "round", "square" ]) + ] + ), + style: .deepObject, + explode: true + )) ) ) ] @@ -168,12 +171,13 @@ final class DeclarativeEaseOfUseTests: XCTestCase { .reference(.component( named: "filter")), .parameter( name: "Content-Type", - context: .header(required: false), - schema: .string( - allowedValues: [ - .init(OpenAPI.ContentType.json.rawValue), - .init(OpenAPI.ContentType.txt.rawValue) - ] + context: .header(required: false, + schema: .string( + allowedValues: [ + .init(OpenAPI.ContentType.json.rawValue), + .init(OpenAPI.ContentType.txt.rawValue) + ] + ) ) ) ], @@ -232,23 +236,22 @@ final class DeclarativeEaseOfUseTests: XCTestCase { parameters: [ .parameter( name: "param", - context: .path, - schema: .string + context: .path(schema: .string) ) ], get: testSHOW_endpoint, post: testCREATE_endpoint ) - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( schemas: [ "string_schema": .string ], parameters: [ - "filter": .init( + "filter": .query( name: "filter", - context: .query(required: false), - schema: .init( + required: false, + schemaOrContent: .schema(.init( .object( properties: [ "size": .integer, @@ -258,7 +261,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { style: .deepObject, explode: true ) - ) + )) ] ) @@ -340,7 +343,7 @@ final class DeclarativeEaseOfUseTests: XCTestCase { } func test_securityRequirements() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( securitySchemes: [ "basic_auth": .init( type: .http(scheme: "basic", bearerFormat: nil), @@ -488,7 +491,7 @@ fileprivate let testWidgetSchema = JSONSchema.object( ] ) -fileprivate let testComponents = OpenAPI.Components( +fileprivate let testComponents = OpenAPI.Components.direct( schemas: [ "testWidgetSchema": testWidgetSchema ], @@ -517,8 +520,7 @@ fileprivate let testDocument = OpenAPI.Document( parameters: [ .parameter( name: "id", - context: .path, - schema: .string + context: .path(schema: .string) ) ], get: OpenAPI.Operation( diff --git a/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift b/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift index 925885bcc..63110fe53 100644 --- a/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift +++ b/Tests/OpenAPIKitTests/OpenAPIReferenceTests.swift @@ -76,7 +76,7 @@ final class OpenAPIReferenceTests: XCTestCase { } func test_summaryAndDescriptionOverrides() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( schemas: [ "hello": .string(description: "description") ], @@ -84,7 +84,7 @@ final class OpenAPIReferenceTests: XCTestCase { "hello": .init(description: "description") ], parameters: [ - "hello": .init(name: "name", context: .path, content: [:], description: "description") + "hello": .path(name: "name", content: [:], description: "description") ], examples: [ "hello": .init(summary: "summary", description: "description", value: .b("")) diff --git a/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift b/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift index 611528269..cc4cbc2c9 100644 --- a/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift +++ b/Tests/OpenAPIKitTests/Operation/DereferencedOperationTests.swift @@ -25,8 +25,7 @@ final class DereferencedOperationTests: XCTestCase { parameters: [ .parameter( name: "test", - context: .header, - schema: .string + context: .header(schema: .string) ) ], requestBody: OpenAPI.Request(content: [.json: .init(schema: .string)]), @@ -42,11 +41,10 @@ final class DereferencedOperationTests: XCTestCase { } func test_parameterReference() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( parameters: [ - "test": .init( + "test": .header( name: "test", - context: .header, schema: .string ) ] @@ -59,9 +57,8 @@ final class DereferencedOperationTests: XCTestCase { ).dereferenced(in: components) XCTAssertEqual( t1.parameters.first?.underlyingParameter, - .init( + .header( name: "test", - context: .header, schema: .string, vendorExtensions: ["x-component-name": "test"] ) @@ -80,7 +77,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_requestReference() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( requestBodies: [ "test": OpenAPI.Request(content: [.json: .init(schema: .string)]) ] @@ -112,7 +109,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_responseReference() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "test": .init(description: "test") ] @@ -142,7 +139,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_securityReference() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( securitySchemes: ["requirement": .apiKey(name: "Api-Key", location: .header)] ) let t1 = try OpenAPI.Operation( @@ -166,7 +163,7 @@ final class DereferencedOperationTests: XCTestCase { } func test_dereferencedCallback() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( callbacks: [ "callback": [ OpenAPI.CallbackURL(rawValue: "{$url}")!: .pathItem( diff --git a/Tests/OpenAPIKitTests/Operation/OperationTests.swift b/Tests/OpenAPIKitTests/Operation/OperationTests.swift index a5bea626f..6cd175652 100644 --- a/Tests/OpenAPIKitTests/Operation/OperationTests.swift +++ b/Tests/OpenAPIKitTests/Operation/OperationTests.swift @@ -23,7 +23,7 @@ final class OperationTests: XCTestCase { description: "description", externalDocs: .init(url: URL(string: "https://google.com")!), operationId: "123", - parameters: [.parameter(name: "hi", context: .query, schema: .string)], + parameters: [.parameter(name: "hi", context: .query(schema: .string))], requestBody: .init(content: [:]), responses: [:], callbacks: [:], diff --git a/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift b/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift index 6cd40b9ef..15e32f4fc 100644 --- a/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift +++ b/Tests/OpenAPIKitTests/Operation/ResolvedEndpointTests.swift @@ -205,14 +205,14 @@ final class ResolvedEndpointTests: XCTestCase { "/hello/world": .init( summary: "routeSummary", description: "routeDescription", - parameters: [.parameter(name: "one", context: .header, schema: .string)], + parameters: [.parameter(name: "one", context: .header(schema: .string))], get: .init( tags: "a", "b", summary: "endpointSummary", description: "endpointDescription", externalDocs: .init(url: URL(string: "http://website.com")!), operationId: "hi there", - parameters: [.parameter(name: "two", context: .query, schema: .string)], + parameters: [.parameter(name: "two", context: .query(schema: .string))], requestBody: .init(description: "requestBody", content: [:]), responses: [200: .response(description: "hello world")], deprecated: true, @@ -243,14 +243,14 @@ final class ResolvedEndpointTests: XCTestCase { "/hello/world": .init( summary: "routeSummary", description: "routeDescription", - parameters: [.parameter(name: "one", context: .header, schema: .string)], + parameters: [.parameter(name: "one", context: .header(schema: .string))], get: .init( tags: "a", "b", summary: "endpointSummary", description: "endpointDescription", externalDocs: .init(url: URL(string: "http://website.com")!), operationId: "hi there", - parameters: [.parameter(name: "one", context: .header, schema: .integer)], + parameters: [.parameter(name: "one", context: .header(schema: .integer))], requestBody: .init(description: "requestBody", content: [:]), responses: [200: .response(description: "hello world")], deprecated: true, @@ -302,7 +302,7 @@ final class ResolvedEndpointTests: XCTestCase { ] ) ], - components: .init( + components: .direct( securitySchemes: [ "secure1": .apiKey(name: "hi", location: .cookie), "secure2": .oauth2( @@ -357,7 +357,7 @@ final class ResolvedEndpointTests: XCTestCase { ] ) ], - components: .init( + components: .direct( securitySchemes: [ "secure1": .apiKey(name: "hi", location: .cookie), "secure2": .oauth2( @@ -411,7 +411,7 @@ final class ResolvedEndpointTests: XCTestCase { ] ) ], - components: .init( + components: .direct( securitySchemes: [ "secure1": .apiKey(name: "hi", location: .cookie), "secure2": .oauth2( @@ -445,14 +445,14 @@ final class ResolvedEndpointTests: XCTestCase { "/hello/world": .init( summary: "routeSummary", description: "routeDescription", - parameters: [.parameter(name: "one", context: .header(required: true), schema: .string)], + parameters: [.parameter(name: "one", context: .header(required: true, schema: .string))], get: .init( tags: "a", "b", summary: "endpointSummary", description: "endpointDescription", externalDocs: .init(url: URL(string: "http://website.com")!), operationId: "hi there", - parameters: [.parameter(name: "two", context: .query, schema: .string)], + parameters: [.parameter(name: "two", context: .query(schema: .string))], requestBody: .init(description: "requestBody", content: [:]), responses: [200: .response(description: "hello world")], deprecated: true, @@ -510,7 +510,7 @@ final class ResolvedEndpointTests: XCTestCase { ] ) ], - components: .init( + components: .direct( securitySchemes: [ "secure1": .apiKey(name: "hi", location: .cookie), "secure2": .oauth2( diff --git a/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift b/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift index 126d3acc0..559491a89 100644 --- a/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/DereferencedParameterTests.swift @@ -10,14 +10,13 @@ import OpenAPIKit final class DereferencedParameterTests: XCTestCase { func test_inlineSchemaParameter() throws { - let t1 = try OpenAPI.Parameter( + let t1 = try OpenAPI.Parameter.header( name: "test", - context: .header, schema: .string ).dereferenced(in: .noComponents) XCTAssertEqual(t1.name, "test") - XCTAssertEqual(t1.context, .header) + XCTAssertEqual(t1.context, .header(schema: .string)) XCTAssertEqual( t1.schemaOrContent.schemaContextValue, try OpenAPI.Parameter.SchemaContext.header(.string).dereferenced(in: .noComponents) @@ -25,16 +24,17 @@ final class DereferencedParameterTests: XCTestCase { XCTAssertEqual(t1.schemaOrContent.schemaValue?.jsonSchema, .string) XCTAssertNil(t1.schemaOrContent.contentValue) - let t2 = try OpenAPI.Parameter( + let t2 = try OpenAPI.Parameter.path( name: "test2", - context: .path, content: [ .anyText: .init(schema: .string) ] ).dereferenced(in: .noComponents) XCTAssertEqual(t2.name, "test2") - XCTAssertEqual(t2.context, .path) + XCTAssertEqual(t2.context, .path(content: [ + .anyText: .init(schema: .string) + ])) XCTAssertEqual( t2.schemaOrContent.contentValue, [ @@ -46,9 +46,8 @@ final class DereferencedParameterTests: XCTestCase { } func test_inlineContentParameter() throws { - let t1 = try OpenAPI.Parameter( + let t1 = try OpenAPI.Parameter.header( name: "test", - context: .header, content: [ .json: .init(schema: .string) ] @@ -63,9 +62,8 @@ final class DereferencedParameterTests: XCTestCase { "test": .string ] ) - let t1 = try OpenAPI.Parameter( + let t1 = try OpenAPI.Parameter.header( name: "test", - context: .header, schemaReference: .component(named: "test") ).dereferenced(in: components) @@ -81,9 +79,8 @@ final class DereferencedParameterTests: XCTestCase { "test": .string ] ) - let t1 = try OpenAPI.Parameter( + let t1 = try OpenAPI.Parameter.header( name: "test", - context: .header, content: [.json: .init(schemaReference: .component(named: "test"))] ).dereferenced(in: components) diff --git a/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift b/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift index 5b6f7e85d..f7e07a8c0 100644 --- a/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/DereferencedSchemaContextTests.swift @@ -22,7 +22,7 @@ final class DereferencedSchemaContextTests: XCTestCase { } func test_oneExampleReferenced() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( examples: ["test": .init(value: .init("hello world"))] ) let t1 = try OpenAPI.Parameter.SchemaContext( @@ -38,7 +38,7 @@ final class DereferencedSchemaContextTests: XCTestCase { } func test_multipleExamplesReferenced() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( examples: [ "test1": .init(value: .init("hello world")), "test2": .init(value: .a(URL(string: "http://website.com")!)) diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterContextTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterContextTests.swift index 1e75cd68f..ad93d2b0d 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterContextTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterContextTests.swift @@ -12,24 +12,24 @@ final class ParameterContextTests: XCTestCase { typealias Context = OpenAPI.Parameter.Context func test_query() { - let t1: Context = .query - XCTAssertEqual(t1, Context.query(required: false, allowEmptyValue: false)) + let t1: Context = .query(schema: .string) + XCTAssertEqual(t1, Context.query(required: false, allowEmptyValue: false, schema: .string)) XCTAssertFalse(t1.required) XCTAssertTrue(t1.inQuery) XCTAssertFalse(t1.inHeader) XCTAssertFalse(t1.inPath) XCTAssertFalse(t1.inCookie) - let t2: Context = .query(allowEmptyValue: true) - XCTAssertEqual(t2, Context.query(required: false, allowEmptyValue: true)) + let t2: Context = .query(allowEmptyValue: true, schema: .string) + XCTAssertEqual(t2, Context.query(required: false, allowEmptyValue: true, schema: .string)) XCTAssertFalse(t2.required) XCTAssertTrue(t2.inQuery) XCTAssertFalse(t2.inHeader) XCTAssertFalse(t2.inPath) XCTAssertFalse(t2.inCookie) - let t3: Context = .query(required: true) - XCTAssertEqual(t3, Context.query(required: true, allowEmptyValue: false)) + let t3: Context = .query(required: true, schema: .string) + XCTAssertEqual(t3, Context.query(required: true, allowEmptyValue: false, schema: .string)) XCTAssertTrue(t3.required) XCTAssertTrue(t3.inQuery) XCTAssertFalse(t3.inHeader) @@ -38,31 +38,31 @@ final class ParameterContextTests: XCTestCase { } func test_header() { - let t1: Context = .header - XCTAssertEqual(t1, Context.header(required: false)) + let t1: Context = .header(schema: .string) + XCTAssertEqual(t1, Context.header(required: false, schema: .string)) XCTAssertFalse(t1.required) XCTAssertTrue(t1.inHeader) XCTAssertFalse(t1.inQuery) XCTAssertFalse(t1.inPath) XCTAssertFalse(t1.inCookie) - XCTAssertTrue(Context.header(required: true).required) + XCTAssertTrue(Context.header(required: true, schema: .string).required) } func test_cookie() { - let t1: Context = .cookie - XCTAssertEqual(t1, Context.cookie(required: false)) + let t1: Context = .cookie(schema: .string) + XCTAssertEqual(t1, Context.cookie(required: false, schema: .string)) XCTAssertFalse(t1.required) XCTAssertTrue(t1.inCookie) XCTAssertFalse(t1.inQuery) XCTAssertFalse(t1.inPath) XCTAssertFalse(t1.inHeader) - XCTAssertTrue(Context.cookie(required: true).required) + XCTAssertTrue(Context.cookie(required: true, schema: .string).required) } func test_path() { - let t1: Context = .path + let t1: Context = .path(schema: .string) XCTAssertTrue(t1.required) XCTAssertTrue(t1.inPath) XCTAssertFalse(t1.inQuery) @@ -71,10 +71,10 @@ final class ParameterContextTests: XCTestCase { } func test_location() { - let t1: Context = .cookie - let t2: Context = .header - let t3: Context = .path - let t4: Context = .query + let t1: Context = .cookie(schema: .string) + let t2: Context = .header(schema: .string) + let t3: Context = .path(schema: .string) + let t4: Context = .query(schema: .string) XCTAssertEqual(t1.location, .cookie) XCTAssertEqual(t2.location, .header) diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift index 35ea9da84..0ac00d6f0 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterSchemaTests.swift @@ -210,6 +210,11 @@ final class ParameterSchemaTests: XCTestCase { let t7 = Schema(.string, style: .deepObject) XCTAssertFalse(t7.explode) } + + public func test_cookie_style() { + let t1 = Schema(.string, style: .cookie) + XCTAssertEqual(t1.conditionalWarnings.count, 1) + } } // MARK: - Codable Tests @@ -531,7 +536,7 @@ fileprivate struct SchemaWrapper: Codable { let location: TestLocation let schema: OpenAPI.Parameter.SchemaContext - init(location: OpenAPI.Parameter.Context, schema: OpenAPI.Parameter.SchemaContext) { + init(location: OpenAPI.Parameter.Context.Location, schema: OpenAPI.Parameter.SchemaContext) { self.location = .init(location) self.schema = schema } @@ -546,22 +551,25 @@ fileprivate struct SchemaWrapper: Codable { case header case path case cookie + case querystring - var paramLoc: OpenAPI.Parameter.Context { + var paramLoc: OpenAPI.Parameter.Context.Location { switch self { - case .query: return .query - case .header: return .header - case .path: return .path - case .cookie: return .cookie + case .query: .query + case .header: .header + case .path: .path + case .cookie: .cookie + case .querystring: .querystring } } - init(_ paramLoc: OpenAPI.Parameter.Context) { + init(_ paramLoc: OpenAPI.Parameter.Context.Location) { switch paramLoc { case .query: self = .query case .header: self = .header case .path: self = .path case .cookie: self = .cookie + case .querystring: self = .querystring } } } diff --git a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift index 6e86c40aa..554779cde 100644 --- a/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift +++ b/Tests/OpenAPIKitTests/Parameter/ParameterTests.swift @@ -10,18 +10,18 @@ import OpenAPIKit final class ParameterTests: XCTestCase { func test_initialize() { - let t1 = OpenAPI.Parameter( + let t1 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: true), + required: true, schemaOrContent: .init([.json: OpenAPI.Content(schema: .string)]), description: "hi", deprecated: true ) XCTAssertTrue(t1.required) - let t2 = OpenAPI.Parameter( + let t2 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: true), + required: true, schemaOrContent: .content([.json: OpenAPI.Content(schema: .string)]), description: "hi", deprecated: true @@ -29,46 +29,21 @@ final class ParameterTests: XCTestCase { XCTAssertTrue(t2.deprecated) XCTAssertEqual(t1, t2) - let t4 = OpenAPI.Parameter( + let t6 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: false), - schema: .init(.string, style: .default(for: .cookie)), - description: "hi", - deprecated: false - ) - XCTAssertFalse(t4.required) - - let t5 = OpenAPI.Parameter( - name: "hello", - context: .cookie, - schema: .string - ) - XCTAssertFalse(t5.deprecated) - - let t6 = OpenAPI.Parameter( - name: "hello", - context: .cookie, schemaOrContent: .schema(.init(.string, style: .default(for: .cookie))) ) - XCTAssertEqual(t5, t6) + XCTAssertFalse(t6.required) - let _ = OpenAPI.Parameter( + let _ = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schemaReference: .component( named: "hello") ) - - let _ = OpenAPI.Parameter( - name: "hello", - context: .cookie, - content: [.json: OpenAPI.Content(schema: .string)] - ) } func test_schemaAccess() { - let t1 = OpenAPI.Parameter( + let t1 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schemaOrContent: .schema(.init(.string, style: .default(for: .cookie))) ) @@ -79,9 +54,8 @@ final class ParameterTests: XCTestCase { XCTAssertEqual(t1.schemaOrContent.schemaContextValue, .init(.string, style: .default(for: .cookie))) XCTAssertEqual(t1.schemaOrContent.schemaContextValue?.schema.schemaValue, t1.schemaOrContent.schemaValue) - let t2 = OpenAPI.Parameter( + let t2 = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schemaReference: .component( named: "hello") ) @@ -91,9 +65,8 @@ final class ParameterTests: XCTestCase { XCTAssertEqual(t2.schemaOrContent.schemaReference, .component( named: "hello")) XCTAssertEqual(t2.schemaOrContent.schemaContextValue?.schema.reference, t2.schemaOrContent.schemaReference) - let t3 = OpenAPI.Parameter( + let t3 = OpenAPI.Parameter.path( name: "hello", - context: .path, content: [:] ) @@ -105,10 +78,10 @@ final class ParameterTests: XCTestCase { func test_parameterArray() { let t1: OpenAPI.Parameter.Array = [ - .parameter(OpenAPI.Parameter(name: "hello", context: .cookie, schema: .string)), - .parameter(name: "hello", context: .cookie, schema: .string), - .parameter(OpenAPI.Parameter(name: "hello", context: .cookie, content: [.json: OpenAPI.Content(schema: .string)])), - .parameter(name: "hello", context: .cookie, content: [.json: OpenAPI.Content(schema: .string)]), + .parameter(OpenAPI.Parameter.cookie(name: "hello", schema: .string)), + .parameter(name: "hello", context: .cookie(schema: .string)), + .parameter(OpenAPI.Parameter.cookie(name: "hello", content: [.json: OpenAPI.Content(schema: .string)])), + .parameter(name: "hello", context: .cookie(content: [.json: OpenAPI.Content(schema: .string)])), .reference(.component( named: "hello")) ] @@ -119,17 +92,21 @@ final class ParameterTests: XCTestCase { XCTAssertNotEqual(t1[4], t1[2]) XCTAssertNotEqual(t1[4], t1[3]) - XCTAssertEqual(t1[0].parameterValue, OpenAPI.Parameter(name: "hello", context: .cookie, schema: .string)) + XCTAssertEqual(t1[0].parameterValue, OpenAPI.Parameter.cookie(name: "hello", schema: .string)) XCTAssertEqual(t1[4].reference, .component( named: "hello")) } + + func test_querystringLocation() { + let t1 = OpenAPI.Parameter.querystring(name: "string", content: [:]) + XCTAssertEqual(t1.conditionalWarnings.count, 1) + } } // MARK: - Codable Tests extension ParameterTests { func test_minimalContent_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, content: [ .json: .init(schema: .string)] ) @@ -175,9 +152,8 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, content: [ .json: .init(schema: .string)] ) ) @@ -185,9 +161,8 @@ extension ParameterTests { } func test_minimalSchema_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string ) @@ -225,18 +200,16 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string ) ) } func test_queryParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.query( name: "hello", - context: .query, schema: .string ) @@ -273,9 +246,8 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .query) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.query( name: "hello", - context: .query, schema: .string ) ) @@ -287,9 +259,9 @@ extension ParameterTests { } func test_queryParamAllowEmpty_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.query( name: "hello", - context: .query(allowEmptyValue: true), + allowEmptyValue: true, schema: .string ) @@ -327,18 +299,18 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.query( name: "hello", - context: .query(allowEmptyValue: true), + allowEmptyValue: true, schema: .string ) ) } func test_requiredQueryParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.query( name: "hello", - context: .query(required: true), + required: true, schema: .string ) @@ -376,18 +348,17 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.query( name: "hello", - context: .query(required: true), + required: true, schema: .string ) ) } func test_headerParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.header( name: "hello", - context: .header, schema: .string ) @@ -424,18 +395,17 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .header) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.header( name: "hello", - context: .header, schema: .string ) ) } func test_requiredHeaderParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), + required: true, schema: .string ) @@ -473,18 +443,17 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), + required: true, schema: .string ) ) } func test_cookieParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schema: .string ) @@ -521,18 +490,17 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .cookie) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.cookie( name: "hello", - context: .cookie, schema: .string ) ) } func test_requiredCookieParam_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: true), + required: true, schema: .string ) @@ -570,18 +538,17 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.cookie( name: "hello", - context: .cookie(required: true), + required: true, schema: .string ) ) } func test_deprecated_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, deprecated: true ) @@ -622,9 +589,8 @@ extension ParameterTests { XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, deprecated: true ) @@ -632,9 +598,8 @@ extension ParameterTests { } func test_description_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, description: "world" ) @@ -676,9 +641,8 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .path) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, description: "world" ) @@ -686,13 +650,15 @@ extension ParameterTests { } func test_example_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), - schema: .init( - .string, - style: .default(for: .header), - example: "hello string" + required: true, + schemaOrContent: .schema( + .init( + .string, + style: .default(for: .header), + example: "hello string" + ) ) ) @@ -733,29 +699,33 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .header) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), - schema: .init( - .string, - style: .default(for: .header), - example: "hello string" + required: true, + schemaOrContent: .schema( + .init( + .string, + style: .default(for: .header), + example: "hello string" + ) ) ) ) } func test_examples_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), - schema: .init( - .string, - style: .default(for: .header), - allowReserved: true, - examples: [ - "test": .example(value: .init(URL(string: "http://website.com")!)) - ] + required: true, + schemaOrContent: .schema( + .init( + .string, + style: .default(for: .header), + allowReserved: true, + examples: [ + "test": .example(value: .init(URL(string: "http://website.com")!)) + ] + ) ) ) @@ -806,25 +776,26 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .header) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.header( name: "hello", - context: .header(required: true), - schema: .init( - .string, - style: .default(for: .header), - allowReserved: true, - examples: [ - "test": .example(value: .init(URL(string: "http://website.com")!)) - ] + required: true, + schemaOrContent: .schema( + .init( + .string, + style: .default(for: .header), + allowReserved: true, + examples: [ + "test": .example(value: .init(URL(string: "http://website.com")!)) + ] + ) ) ) ) } func test_vendorExtension_encode() throws { - let parameter = OpenAPI.Parameter( + let parameter = OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, description: "world", vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] @@ -875,9 +846,8 @@ extension ParameterTests { XCTAssertEqual(parameter.location, .path) XCTAssertEqual( parameter, - OpenAPI.Parameter( + OpenAPI.Parameter.path( name: "hello", - context: .path, schema: .string, description: "world", vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] diff --git a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift index c968f1408..465d69d59 100644 --- a/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/DereferencedPathItemTests.swift @@ -24,6 +24,8 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertNil(t1[.post]) XCTAssertNil(t1[.put]) XCTAssertNil(t1[.trace]) + XCTAssertNil(t1[.query]) + XCTAssertEqual(t1.additionalOperations, [:]) // test dynamic member lookup XCTAssertEqual(t1.summary, "test") @@ -32,7 +34,7 @@ final class DereferencedPathItemTests: XCTestCase { func test_inlinedOperationsAndParameters() throws { let t1 = try OpenAPI.PathItem( parameters: [ - .parameter(name: "param", context: .header, schema: .string) + .parameter(name: "param", context: .header(schema: .string)) ], get: .init(tags: "get op", responses: [:]), put: .init(tags: "put op", responses: [:]), @@ -41,10 +43,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [:]), head: .init(tags: "head op", responses: [:]), patch: .init(tags: "patch op", responses: [:]), - trace: .init(tags: "trace op", responses: [:]) + trace: .init(tags: "trace op", responses: [:]), + query: .init(tags: "query op", responses: [:]), + additionalOperations: [ + "LINK": .init(tags: "link op", responses: [:]) + ] ).dereferenced(in: .noComponents) - XCTAssertEqual(t1.endpoints.count, 8) + XCTAssertEqual(t1.endpoints.count, 10) XCTAssertEqual(t1.parameters.map { $0.schemaOrContent.schemaValue?.jsonSchema }, [.string]) XCTAssertEqual(t1[.delete]?.tags, ["delete op"]) XCTAssertEqual(t1[.get]?.tags, ["get op"]) @@ -54,12 +60,14 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertEqual(t1[.post]?.tags, ["post op"]) XCTAssertEqual(t1[.put]?.tags, ["put op"]) XCTAssertEqual(t1[.trace]?.tags, ["trace op"]) + XCTAssertEqual(t1[.query]?.tags, ["query op"]) + XCTAssertEqual(t1[.other("LINK")]?.tags, ["link op"]) } func test_referencedParameter() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( parameters: [ - "test": .init(name: "param", context: .header, schema: .string) + "test": .init(name: "param", context: .header(schema: .string)) ] ) let t1 = try OpenAPI.PathItem( @@ -92,7 +100,7 @@ final class DereferencedPathItemTests: XCTestCase { } func test_referencedOperations() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -101,7 +109,9 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp"), + "link": .init(description: "link resp") ] ) let t1 = try OpenAPI.PathItem( @@ -112,10 +122,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]), + additionalOperations: [ + "LINK": .init(tags: "link op", responses: [200: .reference(.component(named: "link"))]) + ] ).dereferenced(in: components) - XCTAssertEqual(t1.endpoints.count, 8) + XCTAssertEqual(t1.endpoints.count, 10) XCTAssertEqual(t1[.delete]?.tags, ["delete op"]) XCTAssertEqual(t1[.delete]?.responses[status: 200]?.description, "delete resp") XCTAssertEqual(t1[.get]?.tags, ["get op"]) @@ -132,10 +146,14 @@ final class DereferencedPathItemTests: XCTestCase { XCTAssertEqual(t1[.put]?.responses[status: 200]?.description, "put resp") XCTAssertEqual(t1[.trace]?.tags, ["trace op"]) XCTAssertEqual(t1[.trace]?.responses[status: 200]?.description, "trace resp") + XCTAssertEqual(t1[.query]?.tags, ["query op"]) + XCTAssertEqual(t1[.query]?.responses[status: 200]?.description, "query resp") + XCTAssertEqual(t1[.other("LINK")]?.tags, ["link op"]) + XCTAssertEqual(t1[.other("LINK")]?.responses[status: 200]?.description, "link resp") } func test_missingReferencedGetResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "put": .init(description: "put resp"), "post": .init(description: "post resp"), @@ -143,7 +161,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -155,13 +174,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } func test_missingReferencedPutResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "post": .init(description: "post resp"), @@ -169,7 +189,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -181,13 +202,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } func test_missingReferencedPostResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -195,7 +217,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -207,13 +230,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } func test_missingReferencedDeleteResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -221,7 +245,8 @@ final class DereferencedPathItemTests: XCTestCase { "options": .init(description: "options resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -233,13 +258,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } func test_missingReferencedOptionsResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -247,7 +273,8 @@ final class DereferencedPathItemTests: XCTestCase { "delete": .init(description: "delete resp"), "head": .init(description: "head resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -259,13 +286,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } func test_missingReferencedHeadResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -273,7 +301,8 @@ final class DereferencedPathItemTests: XCTestCase { "delete": .init(description: "delete resp"), "options": .init(description: "options resp"), "patch": .init(description: "patch resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -285,13 +314,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } func test_missingReferencedPatchResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -299,7 +329,8 @@ final class DereferencedPathItemTests: XCTestCase { "delete": .init(description: "delete resp"), "options": .init(description: "options resp"), "head": .init(description: "head resp"), - "trace": .init(description: "trace resp") + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -311,13 +342,14 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) ).dereferenced(in: components) ) } func test_missingReferencedTraceResp() { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( responses: [ "get": .init(description: "get resp"), "put": .init(description: "put resp"), @@ -325,7 +357,8 @@ final class DereferencedPathItemTests: XCTestCase { "delete": .init(description: "delete resp"), "options": .init(description: "options resp"), "head": .init(description: "head resp"), - "patch": .init(description: "patch resp") + "patch": .init(description: "patch resp"), + "query": .init(description: "query resp") ] ) XCTAssertThrowsError( @@ -337,7 +370,68 @@ final class DereferencedPathItemTests: XCTestCase { options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), - trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]) + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) + ).dereferenced(in: components) + ) + } + + func test_missingReferencedQueryResp() { + let components = OpenAPI.Components.direct( + responses: [ + "get": .init(description: "get resp"), + "put": .init(description: "put resp"), + "post": .init(description: "post resp"), + "delete": .init(description: "delete resp"), + "options": .init(description: "options resp"), + "head": .init(description: "head resp"), + "patch": .init(description: "patch resp"), + "trace": .init(description: "trace resp") + ] + ) + XCTAssertThrowsError( + try OpenAPI.PathItem( + get: .init(tags: "get op", responses: [200: .reference(.component(named: "get"))]), + put: .init(tags: "put op", responses: [200: .reference(.component(named: "put"))]), + post: .init(tags: "post op", responses: [200: .reference(.component(named: "post"))]), + delete: .init(tags: "delete op", responses: [200: .reference(.component(named: "delete"))]), + options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), + head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), + patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]) + ).dereferenced(in: components) + ) + } + + func test_missingReferencedAdditionalOperationResp() { + let components = OpenAPI.Components.direct( + responses: [ + "get": .init(description: "get resp"), + "put": .init(description: "put resp"), + "post": .init(description: "post resp"), + "delete": .init(description: "delete resp"), + "options": .init(description: "options resp"), + "head": .init(description: "head resp"), + "patch": .init(description: "patch resp"), + "trace": .init(description: "trace resp"), + "query": .init(description: "query resp") + ] + ) + XCTAssertThrowsError( + try OpenAPI.PathItem( + get: .init(tags: "get op", responses: [200: .reference(.component(named: "get"))]), + put: .init(tags: "put op", responses: [200: .reference(.component(named: "put"))]), + post: .init(tags: "post op", responses: [200: .reference(.component(named: "post"))]), + delete: .init(tags: "delete op", responses: [200: .reference(.component(named: "delete"))]), + options: .init(tags: "options op", responses: [200: .reference(.component(named: "options"))]), + head: .init(tags: "head op", responses: [200: .reference(.component(named: "head"))]), + patch: .init(tags: "patch op", responses: [200: .reference(.component(named: "patch"))]), + trace: .init(tags: "trace op", responses: [200: .reference(.component(named: "trace"))]), + query: .init(tags: "query op", responses: [200: .reference(.component(named: "query"))]), + additionalOperations: [ + "LINK": .init(tags: "link op", responses: [200: .reference(.component(named: "link"))]), + ] ).dereferenced(in: components) ) } diff --git a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift index 2a4000777..367aff363 100644 --- a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift @@ -46,7 +46,7 @@ final class PathItemTests: XCTestCase { summary: "summary", description: "description", servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], - parameters: [.parameter(name: "hello", context: .query, schema: .string)], + parameters: [.parameter(name: "hello", context: .query(schema: .string))], get: op, put: op, post: op, @@ -54,7 +54,11 @@ final class PathItemTests: XCTestCase { options: op, head: op, patch: op, - trace: op + trace: op, + query: op, + additionalOperations: [ + "LINK": op + ] ) } @@ -71,6 +75,7 @@ final class PathItemTests: XCTestCase { XCTAssertNil(pathItem.head) XCTAssertNil(pathItem.patch) XCTAssertNil(pathItem.trace) + XCTAssertNil(pathItem.query) pathItem.get(op) XCTAssertEqual(pathItem.get, op) @@ -99,6 +104,9 @@ final class PathItemTests: XCTestCase { pathItem.trace(op) XCTAssertEqual(pathItem.trace, op) + pathItem.query(op) + XCTAssertEqual(pathItem.query, op) + // for/set/subscript pathItem = .init() XCTAssertNil(pathItem[.get]) @@ -109,6 +117,7 @@ final class PathItemTests: XCTestCase { XCTAssertNil(pathItem[.head]) XCTAssertNil(pathItem[.patch]) XCTAssertNil(pathItem[.trace]) + XCTAssertNil(pathItem[.query]) pathItem[.get] = op XCTAssertEqual(pathItem.for(.get), op) @@ -133,6 +142,9 @@ final class PathItemTests: XCTestCase { pathItem[.trace] = op XCTAssertEqual(pathItem.for(.trace), op) + + pathItem[.query] = op + XCTAssertEqual(pathItem.for(.query), op) } func test_initializePathItemMap() { @@ -140,6 +152,57 @@ final class PathItemTests: XCTestCase { "hello/world": .init(), ] } + + func test_endpointsAccessor() { + let op = OpenAPI.Operation(responses: [:]) + let pathItem = OpenAPI.PathItem( + summary: "summary", + description: "description", + servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], + parameters: [.parameter(name: "hello", context: .query(schema: .string))], + get: op, + put: op, + post: op, + delete: op, + options: op, + head: op, + patch: op, + trace: op, + query: op, + additionalOperations: [ + "LINK": op + ] + ) + + let expectedEndpoints : [EquatableEndpoint] = [ + .init(method: .get, operation: op), + .init(method: .put, operation: op), + .init(method: .post, operation: op), + .init(method: .delete, operation: op), + .init(method: .options, operation: op), + .init(method: .head, operation: op), + .init(method: .patch, operation: op), + .init(method: .trace, operation: op), + .init(method: .query, operation: op), + .init(method: "LINK", operation: op) + ] + + let actualEndpoints = pathItem.endpoints.map(equatableEndpoint) + + XCTAssertEqual(actualEndpoints.count, expectedEndpoints.count) + for endpoint in expectedEndpoints { + XCTAssert(actualEndpoints.contains(endpoint)) + } + } +} + +fileprivate struct EquatableEndpoint: Equatable { + let method: OpenAPI.HttpMethod + let operation: OpenAPI.Operation +} + +fileprivate func equatableEndpoint(_ endpoint: OpenAPI.PathItem.Endpoint) -> EquatableEndpoint { + return .init(method: endpoint.method, operation: endpoint.operation) } // MARK: Codable Tests @@ -177,7 +240,7 @@ extension PathItemTests { summary: "summary", description: "description", servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], - parameters: [.parameter(name: "hello", context: .query, schema: .string)], + parameters: [.parameter(name: "hello", context: .query(schema: .string))], vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] ) @@ -247,7 +310,7 @@ extension PathItemTests { summary: "summary", description: "description", servers: [OpenAPI.Server(url: URL(string: "http://google.com")!)], - parameters: [.parameter(name: "hello", context: .query, schema: .string)], + parameters: [.parameter(name: "hello", context: .query(schema: .string))], vendorExtensions: ["x-specialFeature": .init(["hello", "world"])] ) ) @@ -264,7 +327,11 @@ extension PathItemTests { options: op, head: op, patch: op, - trace: op + trace: op, + query: op, + additionalOperations: [ + "LINK": op + ] ) let encodedPathItem = try orderUnstableTestStringFromEncoding(of: pathItem) @@ -273,6 +340,11 @@ extension PathItemTests { encodedPathItem, """ { + "additionalOperations" : { + "LINK" : { + + } + }, "delete" : { }, @@ -293,6 +365,9 @@ extension PathItemTests { }, "put" : { + }, + "query" : { + }, "trace" : { @@ -321,11 +396,21 @@ extension PathItemTests { "put" : { }, "trace" : { + }, + "query" : { + }, + "additionalOperations": { + "LINK": { + }, + "CONNECT": { + }, + "unknown_method": { + }, } } """.data(using: .utf8)! - let pathItem = try orderUnstableDecode(OpenAPI.PathItem.self, from: pathItemData) + let pathItem = try orderStableDecode(OpenAPI.PathItem.self, from: pathItemData) let op = OpenAPI.Operation(responses: [:]) @@ -339,11 +424,85 @@ extension PathItemTests { options: op, head: op, patch: op, - trace: op + trace: op, + query: op, + additionalOperations: [ + "LINK": op, + "CONNECT": op, + "unknown_method": op + ] ) ) } + func test_disallowedAdditionalOperations_decode() throws { + // NOTE the one allowed method in the following is LINK which is there + // to ensure allowed methods do not show up in the error output. + let pathItemData = + """ + { + "additionalOperations": { + "LINK": { + }, + "DELETE" : { + }, + "GET" : { + }, + "HEAD" : { + }, + "OPTIONS" : { + }, + "PATCH" : { + }, + "POST" : { + }, + "PUT" : { + }, + "TRACE" : { + }, + "QUERY" : { + } + } + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try orderStableDecode(OpenAPI.PathItem.self, from: pathItemData)) { error in + XCTAssertEqual(String(describing: OpenAPI.Error(from: error)), "Problem encountered when parsing `additionalOperations` under the `/` path: Additional Operations cannot contain operations that can be set directly on the Path Item. Found the following disallowed additional operations: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE, QUERY.") + } + } + + func test_invalidAdditionalOperation1_decode() throws { + let pathItemData = + """ + { + "additionalOperations": { + "connect": { + } + } + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.PathItem.self, from: pathItemData)) { error in + XCTAssertEqual(String(describing: OpenAPI.Error(from: error)), "Problem encountered when parsing `connect` under the `/` path: 'connect' must be uppercased.") + } + } + + func test_invalidAdditionalOperation2_decode() throws { + let pathItemData = + """ + { + "additionalOperations": { + "link": { + } + } + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.PathItem.self, from: pathItemData)) { error in + XCTAssertEqual(String(describing: OpenAPI.Error(from: error)), "Problem encountered when parsing `link` under the `/` path: 'link' must be uppercased.") + } + } + func test_pathComponents_encode() throws { let test: [OpenAPI.Path] = ["/hello/world", "hi/there"] diff --git a/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift b/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift index 362b72eac..5b611aa9b 100644 --- a/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/ResolvedRouteTests.swift @@ -18,7 +18,7 @@ final class ResolvedRouteTests: XCTestCase { summary: "routeSummary", description: "routeDescription", servers: [], - parameters: [.parameter(name: "id", context: .path, schema: .integer)], + parameters: [.parameter(name: "id", context: .path(schema: .integer))], get: .init( summary: "get", responses: [200: .response(description: "hello world")] @@ -51,6 +51,16 @@ final class ResolvedRouteTests: XCTestCase { summary: "trace", responses: [200: .response(description: "hello world")] ), + query: .init( + summary: "query", + responses: [200: .response(description: "hello world")] + ), + additionalOperations: [ + "LINK": .init( + summary: "link", + responses: [200: .response(description: "hello world")] + ) + ], vendorExtensions: [ "test": "route" ] @@ -76,8 +86,10 @@ final class ResolvedRouteTests: XCTestCase { XCTAssertEqual(routes.first?.head?.endpointSummary, "head") XCTAssertEqual(routes.first?.patch?.endpointSummary, "patch") XCTAssertEqual(routes.first?.trace?.endpointSummary, "trace") + XCTAssertEqual(routes.first?.query?.endpointSummary, "query") + XCTAssertEqual(routes.first?.additionalOperations["LINK"]?.endpointSummary, "link") - XCTAssertEqual(routes.first?.endpoints.count, 8) + XCTAssertEqual(routes.first?.endpoints.count, 10) XCTAssertEqual(routes.first?.get, routes.first?[.get]) XCTAssertEqual(routes.first?.put, routes.first?[.put]) @@ -87,6 +99,8 @@ final class ResolvedRouteTests: XCTestCase { XCTAssertEqual(routes.first?.head, routes.first?[.head]) XCTAssertEqual(routes.first?.patch, routes.first?[.patch]) XCTAssertEqual(routes.first?.trace, routes.first?[.trace]) + XCTAssertEqual(routes.first?.query, routes.first?[.query]) + XCTAssertEqual(routes.first?.additionalOperations["LINK"], routes.first?[.other("LINK")]) } func test_pathServersTakePrecedence() throws { diff --git a/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift b/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift index a712027f8..8d15a86ff 100644 --- a/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/DereferencedResponseTests.swift @@ -36,7 +36,7 @@ final class DereferencedResponseTests: XCTestCase { } func test_referencedHeader() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( headers: [ "test": .init(schema: .string) ] @@ -94,7 +94,7 @@ final class DereferencedResponseTests: XCTestCase { } func test_referencedLink() throws { - let components = OpenAPI.Components( + let components = OpenAPI.Components.direct( links: [ "link1": .init(operationId: "linka") ] diff --git a/Tests/OpenAPIKitTests/Response/ResponseTests.swift b/Tests/OpenAPIKitTests/Response/ResponseTests.swift index 1f4367ff7..0d85c85ae 100644 --- a/Tests/OpenAPIKitTests/Response/ResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/ResponseTests.swift @@ -25,6 +25,28 @@ final class ResponseTests: XCTestCase { XCTAssertEqual(r2.description, "") XCTAssertEqual(r2.headers?["hello"]?.headerValue, header) XCTAssertEqual(r2.content, [.json: content]) + XCTAssertEqual(r2.conditionalWarnings.count, 0) + + // two OAS 3.2.0 warnings: summary is used and description is not + let r3 = OpenAPI.Response(summary: "", + content: [:]) + XCTAssertEqual(r3.summary, "") + XCTAssertNil(r3.description) + XCTAssertEqual(r3.conditionalWarnings.count, 2) + + // one OAS 3.2.0 warnings: summary is used + let r4 = OpenAPI.Response(summary: "", + description: "", + content: [:]) + XCTAssertEqual(r4.summary, "") + XCTAssertEqual(r4.description, "") + XCTAssertEqual(r4.conditionalWarnings.count, 1) + + // one OAS 3.2.0 warnings: description is not used + let r5 = OpenAPI.Response(content: [:]) + XCTAssertNil(r5.summary) + XCTAssertNil(r5.description) + XCTAssertEqual(r5.conditionalWarnings.count, 1) } func test_responseMap() { @@ -122,6 +144,18 @@ extension ResponseTests { } """ ) + + let response3 = OpenAPI.Response(summary: "", content: [:]) + let encodedResponse3 = try! orderUnstableTestStringFromEncoding(of: response3) + + assertJSONEquivalent( + encodedResponse3, + """ + { + "summary" : "" + } + """ + ) } func test_emptyDescriptionEmptyContent_decode() { @@ -157,6 +191,16 @@ extension ResponseTests { let response3 = try! orderUnstableDecode(OpenAPI.Response.self, from: responseData3) XCTAssertEqual(response3, OpenAPI.Response(description: "", headers: [:], content: [:])) + + let responseData4 = + """ + { + "summary" : "" + } + """.data(using: .utf8)! + let response4 = try! orderUnstableDecode(OpenAPI.Response.self, from: responseData4) + + XCTAssertEqual(response4, OpenAPI.Response(summary: "", content: [:])) } func test_populatedDescriptionPopulatedContent_encode() { diff --git a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift index 7126bb331..21502fbeb 100644 --- a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift @@ -508,7 +508,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { XCTAssertThrowsError(try JSONSchema.reference(.component(named: "test")).dereferenced(in: components)) { error in XCTAssertEqual( String(describing: error), - "Encountered a JSON Schema $ref cycle that prevents fully dereferencing document at '#/components/schemas/test'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy." + "Encountered a JSON Schema $ref cycle that prevents fully resolving a reference at '#/components/schemas/test'. This type of reference cycle is not inherently problematic for JSON Schemas, but it does mean OpenAPIKit cannot fully resolve references because attempting to do so results in an infinite loop over any reference cycles. You should still be able to parse the document, just avoid requesting a `locallyDereferenced()` copy or fully looking up references in cycles. `lookupOnce()` is your best option in this case." ) } } diff --git a/Tests/OpenAPIKitTests/TagTests.swift b/Tests/OpenAPIKitTests/TagTests.swift index 52251a3de..892aa2951 100644 --- a/Tests/OpenAPIKitTests/TagTests.swift +++ b/Tests/OpenAPIKitTests/TagTests.swift @@ -11,12 +11,16 @@ import OpenAPIKit final class TagTests: XCTestCase { func test_init() { let t1 = OpenAPI.Tag(name: "hello") + XCTAssertNil(t1.summary) XCTAssertNil(t1.description) XCTAssertNil(t1.externalDocs) + XCTAssertEqual(t1.conditionalWarnings.count, 0) - let t2 = OpenAPI.Tag(name: "hello", description: "world") + let t2 = OpenAPI.Tag(name: "hello", summary: "hi", description: "world") + XCTAssertEqual(t2.summary, "hi") XCTAssertEqual(t2.description, "world") XCTAssertNil(t2.externalDocs) + XCTAssertEqual(t2.conditionalWarnings.count, 1) let t3 = OpenAPI.Tag( name: "hello", @@ -28,11 +32,25 @@ final class TagTests: XCTestCase { let t4 = OpenAPI.Tag( name: "tag", + summary: "first", description: "orig" ).overriddenNonNil(description: "new") - .overriddenNonNil(summary: "no-op") + .overriddenNonNil(summary: "cool") .overriddenNonNil(description: nil) // no effect + XCTAssertEqual(t4.summary, "cool") XCTAssertEqual(t4.description, "new") + + let t5 = OpenAPI.Tag( + name: "hello", + parent: "otherTag" + ) + XCTAssertEqual(t5.parent, "otherTag") + + let t6 = OpenAPI.Tag( + name: "hello", + kind: .nav + ) + XCTAssertEqual(t6.kind, .nav) } } @@ -63,6 +81,40 @@ extension TagTests { let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) XCTAssertEqual(tag, OpenAPI.Tag(name: "hello")) + XCTAssertEqual(tag.conditionalWarnings.count, 0) + } + + func test_nameAndSummary_encode() throws { + let tag = OpenAPI.Tag( + name: "hello", + summary: "world" + ) + let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) + + assertJSONEquivalent( + encodedTag, + """ + { + "name" : "hello", + "summary" : "world" + } + """ + ) + } + + func test_nameAndSummary_decode() throws { + let tagData = + """ + { + "name": "hello", + "summary": "world" + } + """.data(using: .utf8)! + + let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) + + XCTAssertEqual(tag, OpenAPI.Tag(name: "hello", summary: "world")) + XCTAssertEqual(tag.conditionalWarnings.count, 1) } func test_nameAndDescription_encode() throws { @@ -95,15 +147,85 @@ extension TagTests { let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) XCTAssertEqual(tag, OpenAPI.Tag(name: "hello", description: "world")) + XCTAssertEqual(tag.conditionalWarnings.count, 0) + } + + func test_nameAndParent_encode() throws { + let tag = OpenAPI.Tag( + name: "hello", + parent: "otherTag" + ) + let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) + + assertJSONEquivalent( + encodedTag, + """ + { + "name" : "hello", + "parent" : "otherTag" + } + """ + ) + } + + func test_nameAndParent_decode() throws { + let tagData = + """ + { + "name": "hello", + "parent": "otherTag" + } + """.data(using: .utf8)! + + let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) + + XCTAssertEqual(tag, OpenAPI.Tag(name: "hello", parent: "otherTag")) + XCTAssertEqual(tag.conditionalWarnings.count, 1) + } + + func test_nameAndKind_encode() throws { + let tag = OpenAPI.Tag( + name: "hello", + kind: .badge + ) + let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) + + assertJSONEquivalent( + encodedTag, + """ + { + "kind" : "badge", + "name" : "hello" + } + """ + ) + } + + func test_nameAndKind_decode() throws { + let tagData = + """ + { + "name": "hello", + "kind": "audience" + } + """.data(using: .utf8)! + + let tag = try orderUnstableDecode(OpenAPI.Tag.self, from: tagData) + + XCTAssertEqual(tag, OpenAPI.Tag(name: "hello", kind: .audience)) + XCTAssertEqual(tag.conditionalWarnings.count, 1) } func test_allFields_encode() throws { let tag = OpenAPI.Tag( name: "hello", + summary: "sum", description: "world", externalDocs: .init( url: URL(string: "http://google.com")! ), + parent: "otherTag", + kind: "mytag", vendorExtensions: ["x-specialFeature": false] ) let encodedTag = try orderUnstableTestStringFromEncoding(of: tag) @@ -116,7 +238,10 @@ extension TagTests { "externalDocs" : { "url" : "http:\\/\\/google.com" }, + "kind" : "mytag", "name" : "hello", + "parent" : "otherTag", + "summary" : "sum", "x-specialFeature" : false } """ @@ -128,10 +253,13 @@ extension TagTests { """ { "name": "hello", + "summary": "sum", "description": "world", "externalDocs": { "url": "http://google.com" }, + "parent": "otherTag", + "kind": "mytag", "x-specialFeature" : false } """.data(using: .utf8)! @@ -142,10 +270,14 @@ extension TagTests { tag, OpenAPI.Tag( name: "hello", + summary: "sum", description: "world", externalDocs: .init(url: URL(string: "http://google.com")!), + parent: "otherTag", + kind: "mytag", vendorExtensions: ["x-specialFeature": false] ) ) + XCTAssertEqual(tag.conditionalWarnings.count, 3) } } diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index a7a07cc51..0f9bac4fa 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -207,7 +207,7 @@ final class BuiltinValidationTests: XCTestCase { paths: [ "/hello/world/{idx}": .init( parameters: [ - .parameter(name: "idx", context: .path, schema: .string) + .parameter(name: "idx", context: .path(schema: .string)) ], get: .init( responses: [:] @@ -229,7 +229,7 @@ final class BuiltinValidationTests: XCTestCase { "/hello/world/{idx}": .init( get: .init( parameters: [ - .parameter(name: "idx", context: .path, schema: .string) + .parameter(name: "idx", context: .path(schema: .string)) ], responses: [:] ) @@ -496,8 +496,8 @@ final class BuiltinValidationTests: XCTestCase { "/hello": .init( get: .init( parameters: [ - .parameter(name: "hiya", context: .path, schema: .string), - .parameter(name: "hiya", context: .path, schema: .string) + .parameter(name: "hiya", context: .path(schema: .string)), + .parameter(name: "hiya", context: .path(schema: .string)) ], responses: [ 200: .response(description: "hi") @@ -524,9 +524,9 @@ final class BuiltinValidationTests: XCTestCase { "/hello": .init( get: .init( parameters: [ - .parameter(name: "hiya", context: .query, schema: .string), - .parameter(name: "hiya", context: .path, schema: .string), // changes parameter location but not name - .parameter(name: "cool", context: .path, schema: .string) // changes parameter name but not location + .parameter(name: "hiya", context: .query(schema: .string)), + .parameter(name: "hiya", context: .path(schema: .string)), // changes parameter location but not name + .parameter(name: "cool", context: .path(schema: .string)) // changes parameter name but not location ], responses: [ 200: .response(description: "hi") @@ -646,8 +646,8 @@ final class BuiltinValidationTests: XCTestCase { paths: [ "/hello": .init( parameters: [ - .parameter(name: "hiya", context: .query, schema: .string), - .parameter(name: "hiya", context: .query, schema: .string) + .parameter(name: "hiya", context: .query(schema: .string)), + .parameter(name: "hiya", context: .query(schema: .string)) ], get: .init( responses: [ @@ -674,9 +674,9 @@ final class BuiltinValidationTests: XCTestCase { paths: [ "/hello": .init( parameters: [ - .parameter(name: "hiya", context: .query, schema: .string), - .parameter(name: "hiya", context: .path, schema: .string), // changes parameter location but not name - .parameter(name: "cool", context: .path, schema: .string) // changes parameter name but not location + .parameter(name: "hiya", context: .query(schema: .string)), + .parameter(name: "hiya", context: .path(schema: .string)), // changes parameter location but not name + .parameter(name: "cool", context: .path(schema: .string)) // changes parameter name but not location ], get: .init( responses: [ @@ -837,7 +837,7 @@ final class BuiltinValidationTests: XCTestCase { "/world": .reference(.component(named: "path1")), "/external": .reference(.external(URL(string: "https://other-world.com")!)) ], - components: .init( + components: .direct( schemas: [ "schema1": .object ], @@ -845,7 +845,7 @@ final class BuiltinValidationTests: XCTestCase { "response1": .init(description: "test") ], parameters: [ - "parameter1": .init(name: "test", context: .header, schema: .string) + "parameter1": .init(name: "test", context: .header(schema: .string)) ], examples: [ "example1": .init(value: .b("hello")) @@ -909,7 +909,7 @@ final class BuiltinValidationTests: XCTestCase { ) ) ], - components: .init( + components: .direct( links: [ "testLink": link ] @@ -936,7 +936,7 @@ final class BuiltinValidationTests: XCTestCase { ) ) ], - components: .init( + components: .direct( links: [ "testLink": link ] @@ -951,4 +951,260 @@ final class BuiltinValidationTests: XCTestCase { XCTAssertTrue((errorCollection?.values.first?.codingPath.map { $0.stringValue }.joined(separator: ".") ?? "").contains("testLink")) } } + + func test_badMatrixStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.query( + name: "test", + schemaOrContent: .schema(.init(.string, style: .matrix)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the matrix style can only be used for the path location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badLabelStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.query( + name: "test", + schemaOrContent: .schema(.init(.string, style: .label)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the label style can only be used for the path location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badSimpleStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.query( + name: "test", + schemaOrContent: .schema(.init(.string, style: .simple)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the simple style can only be used for the path and header locations") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badFormStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .form)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the form style can only be used for the query and cookie locations") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badSpaceDelimitedStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .spaceDelimited)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the spaceDelimited style can only be used for the query location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badPipeDelimitedStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .pipeDelimited)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the pipeDelimited style can only be used for the query location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badDeepObjectStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .deepObject)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the deepObject style can only be used for the query location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } + + func test_badCookieStyleLocation_fails() throws { + let parameter = OpenAPI.Parameter.header( + name: "test", + schemaOrContent: .schema(.init(.string, style: .cookie)) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + operationId: "testOperation", + parameters: [ + .parameter(parameter) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.parameterStyleAndLocationAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Failed to satisfy: the cookie style can only be used for the cookie location") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") + } + } } diff --git a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift index 61f028303..ae9704fb7 100644 --- a/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift +++ b/Tests/OpenAPIKitTests/Validator/Validation+ConvenienceTests.swift @@ -294,10 +294,10 @@ final class ValidationConvenienceTests: XCTestCase { ] ) ], - components: .init( + components: .direct( parameters: [ - "test1": .init(name: "test", context: .header, content: [:]), - "test2": .init(name: "test2", context: .query, content: [:]) + "test1": .init(name: "test", context: .header(content: [:])), + "test2": .init(name: "test2", context: .query(content: [:])) ] ) ) @@ -336,10 +336,10 @@ final class ValidationConvenienceTests: XCTestCase { ] ) ], - components: .init( + components: .direct( parameters: [ - "test1": .init(name: "test", context: .header, content: [:]), - "test2": .init(name: "test2", context: .query, content: [:]) + "test1": .init(name: "test", context: .header(content: [:])), + "test2": .init(name: "test2", context: .query(content: [:])) ] ) ) diff --git a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift index e5ed9f59f..6c17f8b29 100644 --- a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift @@ -1067,7 +1067,7 @@ final class ValidatorTests: XCTestCase { let validator = Validator.blank .validating( - "All server arrays have not in operations have more than 1 server", + "All server arrays not in operations have more than 1 server", check: \[OpenAPI.Server].count > 1, when: \.codingPath.count == 1 // server array is under root document (coding path count 1) || take(\.codingPath) { codingPath in @@ -1075,7 +1075,7 @@ final class ValidatorTests: XCTestCase { guard codingPath.count > 1 else { return false } let secondToLastPathComponent = codingPath.suffix(2).first!.stringValue - let httpMethods = OpenAPI.HttpMethod.allCases.map { $0.rawValue.lowercased() } + let httpMethods = OpenAPI.BuiltinHttpMethod.allCases.map { $0.rawValue.lowercased() } return !httpMethods.contains(secondToLastPathComponent) } @@ -1484,9 +1484,68 @@ final class ValidatorTests: XCTestCase { XCTAssertEqual(errors?.values.count, 1) XCTAssertEqual( errors?.localizedDescription, - "Problem encountered when parsing ``: \'gzip\' could not be parsed as a Content Type. Content Types should have the format \'/\'. at path: .paths[\'/test\'].get.responses.200.content" + "Problem encountered when parsing ``: \'gzip\' could not be parsed as a Content Type. Content Types should have the format \'/\' at path: .paths[\'/test\'].get.responses.200.content" ) XCTAssertEqual(errors?.values.first?.codingPathString, ".paths[\'/test\'].get.responses.200.content") } } + + func test_collectsConditionalTagWarningNotStrict() throws { + let docData = """ + { + "info": {"title": "test", "version": "1.0"}, + "openapi": "3.1.0", + "tags": [ {"name": "hi", "summary": "sum"} ] + } + """.data(using: .utf8)! + + let doc = try orderUnstableDecode(OpenAPI.Document.self, from: docData) + + XCTAssertEqual( + doc.tags?.first?.applicableConditionalWarnings(for: doc).first?.localizedDescription, + "The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later" + ) + + let warnings = try doc.validate(strict: false) + + XCTAssertEqual(warnings.count, 1) + XCTAssertEqual( + warnings.first?.localizedDescription, + "Problem encountered when parsing ``: The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later." + ) + XCTAssertEqual(warnings.first?.codingPathString, ".tags[0]") + + // now test that the warning does not apply for v3.2.0 and above + var doc2 = doc + doc2.openAPIVersion = .v3_2_0 + + try XCTAssertEqual(doc2.validate(strict: false).count, 0) + } + + func test_collectsConditionalTagWarningStrict() throws { + let docData = """ + { + "info": {"title": "test", "version": "1.0"}, + "openapi": "3.1.0", + "tags": [ {"name": "hi", "summary": "sum"} ] + } + """.data(using: .utf8)! + + let doc = try orderUnstableDecode(OpenAPI.Document.self, from: docData) + + XCTAssertEqual( + doc.tags?.first?.applicableConditionalWarnings(for: doc).first?.localizedDescription, + "The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later" + ) + + XCTAssertThrowsError(try doc.validate(strict: true)) { error in + let errors = error as? ValidationErrorCollection + XCTAssertEqual(errors?.values.count, 1) + XCTAssertEqual( + errors?.localizedDescription, + "Problem encountered when parsing ``: The Tag summary field is only supported for OpenAPI document versions 3.2.0 and later at path: .tags[0]" + ) + XCTAssertEqual(errors?.values.first?.codingPathString, ".tags[0]") + } + } } diff --git a/documentation/v2_migration_guide.md b/documentation/migration_guides/v2_migration_guide.md similarity index 100% rename from documentation/v2_migration_guide.md rename to documentation/migration_guides/v2_migration_guide.md diff --git a/documentation/v3_migration_guide.md b/documentation/migration_guides/v3_migration_guide.md similarity index 100% rename from documentation/v3_migration_guide.md rename to documentation/migration_guides/v3_migration_guide.md diff --git a/documentation/v4_migration_guide.md b/documentation/migration_guides/v4_migration_guide.md similarity index 100% rename from documentation/v4_migration_guide.md rename to documentation/migration_guides/v4_migration_guide.md diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md new file mode 100644 index 000000000..096fac59d --- /dev/null +++ b/documentation/migration_guides/v5_migration_guide.md @@ -0,0 +1,221 @@ +## OpenAPIKit v5 Migration Guide +For general information on the v5 release, see the release notes on GitHub. The +rest of this guide will be formatted as a series of changes and what options you +have to migrate code from v4 to v5. You can also refer back to the release notes +for each of the v4 pre-releases for the most thorough look at what changed. + +This guide will not spend time on strictly additive features of version 5. See +the release notes, README, and documentation for information on new features. + +### Swift version support +OpenAPIKit v5.0 drops support for Swift versions prior to 5.10 (i.e. it supports +v5.10 and greater). + +### MacOS version support +Only relevant when compiling OpenAPIKit on iOS: Now v12+ is required. + +### OpenAPI Specification Versions +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +specification) in this section. + +The OpenAPIKit module's `OpenAPI.Document.Version` enum gained `v3_1_2`, +`v3_2_0` and `v3_2_x(x: Int)`. + +If you have exhaustive switches over values of those types then your switch +statements will need to be updated. + +If you use `v3_1_x(x: 2)` you should replace it with `v3_1_2`. + +### Content Types +The `application/x-yaml` media type is officially superseded by +`application/yaml`. OpenAPIKit will continue to support reading the +`application/x-yaml` media type, but it will always choose to encode the YAML +media type as `application/yaml`. + +### Http Methods +The `OpenAPIKit30` module's `OpenAPI.HttpMethod` type has been renamed to +`OpenAPI.BuiltinHttpMethod` and gained the `.query` method (though this method +cannot be represented on the OAS 3.0.x Path Item Object). + +The `OpenAPI` module's `OpenAPI.HttpMethod` type has been updated to support +non-builtin HTTP methods with the pre-existing HTTP methods moving to the +`OpenAPI.BuiltinHttpMethod` type and `HttpMethod` having just two cases: +`.builtin(BuiltinHttpMethod)` and `.other(String)`. + +Switch statements over `OpenAPI.HttpMethod` should be updated to first check if +the method is builtin or not: +```swift +switch httpMethod { +case .builtin(let builtin): + switch builtin { + case .delete: // ... + case .get: // ... + case .head: // ... + case .options: // ... + case .patch: // ... + case .post: // ... + case .put: // ... + case .trace: // ... + case .query: // ... + } +case .other(let other): + // new stuff to handle here +} +``` + +You can continue to use static constructors on `OpenAPI.HttpMethod` to construct +builtin methods so the following code _does not need to change_: +```swift +let httpMethod : OpenAPI.HttpMethod = .post +``` + +### Parameters +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +specification) in this section. + +For the `OpenAPIKit` module (OAS 3.1.x and 3.2.x versions) read on. + +An additional parameter location of `querystring` has been added. This is a +breaking change to code that exhaustively switches on `OpenAPI.Parameter.Context` +or `OpenAPI.Parameter.Context.Location`. + +To support the new `querystring` location, `schemaOrContent` has been moved into +the `OpenAPI.Parameter.Context` because it only applies to locations other than +`querystring`. You can still access `schemaOrContent` as a property on the +`Parameter`. Code that pattern matches on cases of `OpenAPI.Parameter.Context` +will need to add the new `schemaOrContent` values associated with each case. + +```swift +// BEFORE +switch parameter.context { +case .query(required: _) +} + +// AFTER +switch parameter.context { +case .query(required: _, schemaOrContent: _) +} +``` + +#### Constructors +The following only applies if you construct parameters in-code (use Swift to +build an OpenAPI Document). + +Unfortunately, the change that made `schemaOrContent` not apply to all possible +locations means that the existing convenience constructors and static functions +that created parameters in-code do not make sense anymore. There were fairly +substantial changes to what is available with an aim to continue to offer +simular convenience as before. + +Following are a few changes you made need to make with examples. + +Code that populates the `parameters` array of the `OpenAPI.Operation` type with the +`.parameter(name:,context:,schema:)` function needs to be updated. The `schema` +has moved into the `context` so you change your code in the following way: +```swift +// BEFORE +.parameter( + name: "name", + context: .header, + schema: .string +) + +// AFTER +.parameter( + name: "name", + context: .header(schema: .string) +) +``` + +Code that initializes `OpenAPI.Parameter` via one of its `init` functions will +most likely need to change. Many of the initializers have been removed but you can +replace `.init(name:,context:,schema:)` or similar initializers with +`.header(name:,schema:)` (same goes for `query`, `path`, and `cookie`). So you change +your code in the following way: +```swift +// BEFORE +.init( + name: "name", + context: .header, + schema: .string +) + +// AFTER +.header( + name: "name", + schema: .string +) +``` + +Because the `ParameterContext` has taken on the `schemaOrContent` of the +`Parameter`, convenience constructors like `ParameterContext.header` (and +similar for the other locations) no longer make sense and have been removed. You +must also specify the schema or content, e.g. `ParameterContext.header(schema: .string)`. + +### Parameter Styles +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +specification) in this section. + +A new `cookie` style has been added. Code that exhaustively switches on the +`OpenAPI.Parameter.SchemaContext.Style` enum will need to be updated. + +### Response Objects +There are no breaking changes for the `OpenAPIKit30` module (OAS 3.0.x +specification) in this section. + +The Response Object `description` field is not optional so code may need to +change to account for it possibly being `nil`. + +### Components Object +There are changes for the `OpenAPIKit30` module (OAS 3.0.x specification) in +this section. + +Entries in the Components Object's `responses`, `parameters`, `examples`, +`requestBodies`, `headers`, `securitySchemes`, `links`, and `callbacks` +dictionaries have all gained support for references. Note that `pathItems` and +`schemas` still do not support references (per the specification), though +`schemas` can be JSON references by their very nature already. + +This change fixes a gap in OpenAPIKit's ability to represent valid documents. + +If you are using subscript access or `lookup()` functions to retrieve entries +from the Components Object, you do _not_ need to change that code. These +functions have learned how to follow references they encounter until they land +on the type of entity being looked up. If you want the behavior of just +doing a regular lookup and passing the result back even if it is a reference, +you can use the new `lookupOnce()` function. The existing `lookup()` functions +can now throw an error they would never throw before: `ReferenceCycleError`. + +Error message phrasing has changed subtly which is unlikely to cause problems +but if you have tests that compare exact error messages then you may need to +update the test expectations. + +If you construct `Components` in-code then you have two options. You can swap +out existing calls to the `Components` `init()` initializer with calls to the +new `Components.direct()` convenience constructor or you can nest each component +entry in an `Either` like follows: +```swift +// BEFORE +Components( + parameters: [ + "param1": .cookie(name: "cookie", schema: .string) + ] +) + +// AFTER +Components( + parameters: [ + "param1": .parameter(.cookie(name: "cookie", schema: .string)) + ] +) +``` + +If your code uses the `static` `openAPIComponentsKeyPath` variable on types that +can be found in the Components Object (likely very uncommon), you will now need +to handle two possibilities: the key path either refers to an object (of generic +type `T`) or it refers to an `Either, T>`. + +### Errors +Some error messages have been tweaked in small ways. If you match on the +string descriptions of any OpenAPIKit errors, you may need to update the +expected values. diff --git a/documentation/specification_coverage.md b/documentation/specification_coverage.md index e390c7f65..92af640d8 100644 --- a/documentation/specification_coverage.md +++ b/documentation/specification_coverage.md @@ -112,6 +112,8 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] head - [x] patch - [x] trace +- [x] query +- [x] additionalOperations - [x] servers - [x] parameters - [x] specification extensions (`vendorExtensions`) @@ -220,8 +222,11 @@ For more information on the OpenAPIKit types, see the [full type documentation]( ### Tag Object (`OpenAPI.Tag`) - [x] name +- [x] summary - [x] description - [x] externalDocs +- [x] parent +- [x] kind - [x] specification extensions (`vendorExtensions`) ### Reference Object (`OpenAPI.Reference`)