diff --git a/README.md b/README.md index 5740b87..b89f9b9 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ The FeatherOpenAPI library makes it easy to define OpenAPI specifications using Swift in a type-safe way. [ - ![Release: 1.0.0-rc.1](https://img.shields.io/badge/Release-1%2E0%2E0--rc%2E1-F05138) + ![Release: 1.0.0-rc.2](https://img.shields.io/badge/Release-1%2E0%2E0--rc%2E2-F05138) ]( - https://github.com/feather-framework/feather-openapi/releases/tag/1.0.0-rc.1 + https://github.com/feather-framework/feather-openapi/releases/tag/1.0.0-rc.2 ) ## Features @@ -35,7 +35,7 @@ The FeatherOpenAPI library makes it easy to define OpenAPI specifications using Use Swift Package Manager; add the dependency to your `Package.swift` file: ```swift -.package(url: "https://github.com/feather-framework/feather-openapi", exact: "1.0.0-rc.1"), +.package(url: "https://github.com/feather-framework/feather-openapi", exact: "1.0.0-rc.2"), ``` Then add `FeatherOpenAPI` to your target dependencies: diff --git a/Sources/FeatherOpenAPI/Content/ContentMap.swift b/Sources/FeatherOpenAPI/Content/ContentMap.swift index 9c89c64..78f9031 100644 --- a/Sources/FeatherOpenAPI/Content/ContentMap.swift +++ b/Sources/FeatherOpenAPI/Content/ContentMap.swift @@ -5,10 +5,28 @@ // Created by Tibor Bödecs on 2026. 01. 23. // -public import OpenAPIKit30 +import OpenAPIKit30 /// Ordered map of content types to content definitions. -public typealias ContentMap = OrderedDictionary< - OpenAPI.ContentType, +public typealias ContentMap = OrderedMap< + ContentTypeKey, any ContentRepresentable > + +extension OrderedMap +where Key == ContentTypeKey, Value == any ContentRepresentable { + var openAPIContentMap: + OrderedDictionary< + OpenAPI.ContentType, + OpenAPI.Content + > + { + var results: OrderedDictionary = + [:] + for (key, content) in self { + results[OpenAPI.ContentType(rawValue: key.rawValue)!] = + content.openAPIContent() + } + return results + } +} diff --git a/Sources/FeatherOpenAPI/Content/ContentTypeKey.swift b/Sources/FeatherOpenAPI/Content/ContentTypeKey.swift new file mode 100644 index 0000000..3edff7a --- /dev/null +++ b/Sources/FeatherOpenAPI/Content/ContentTypeKey.swift @@ -0,0 +1,27 @@ +// +// ContentTypeKey.swift +// feather-openapi +// + +/// A media type key that does not expose OpenAPIKit types to API generators. +public struct ContentTypeKey: Hashable, Sendable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.init(value) + } + + public static let json = Self("application/json") + public static let xml = Self("application/xml") + public static let form = Self("application/x-www-form-urlencoded") + public static let multipartForm = Self("multipart/form-data") + public static let octetStream = Self("application/octet-stream") + + public static func other(_ rawValue: String) -> Self { + Self(rawValue) + } +} diff --git a/Sources/FeatherOpenAPI/Document/DocumentRepresentable.swift b/Sources/FeatherOpenAPI/Document/DocumentRepresentable.swift index fde5682..c8d32c3 100644 --- a/Sources/FeatherOpenAPI/Document/DocumentRepresentable.swift +++ b/Sources/FeatherOpenAPI/Document/DocumentRepresentable.swift @@ -79,7 +79,7 @@ extension DocumentRepresentable { openAPIVersion: .v3_0_0, info: info.openAPIInfo(), servers: servers.map { $0.openAPIServer() }, - paths: paths.mapValues { .init($0.openAPIPathItem()) }, + paths: openAPIPaths, components: components.openAPIComponents(), security: referencedSecurityRequirements.map { $0.openAPISecurityRequirement() @@ -89,4 +89,23 @@ extension DocumentRepresentable { vendorExtensions: vendorExtensions ) } + + private var openAPIPaths: + OrderedDictionary< + OpenAPI.Path, + Either, OpenAPI.PathItem> + > + { + var results: + OrderedDictionary< + OpenAPI.Path, + Either, OpenAPI.PathItem> + > = [:] + for (path, item) in paths { + results[OpenAPI.Path(rawValue: path)] = .init( + item.openAPIPathItem() + ) + } + return results + } } diff --git a/Sources/FeatherOpenAPI/Header/HeaderMap.swift b/Sources/FeatherOpenAPI/Header/HeaderMap.swift index 9402015..fee6b51 100644 --- a/Sources/FeatherOpenAPI/Header/HeaderMap.swift +++ b/Sources/FeatherOpenAPI/Header/HeaderMap.swift @@ -5,10 +5,8 @@ // Created by Tibor Bödecs on 2026. 01. 23. // -public import OpenAPIKit30 - /// Ordered map of header names to header definitions. -public typealias HeaderMap = OrderedDictionary< +public typealias HeaderMap = OrderedMap< String, any HeaderRepresentable > diff --git a/Sources/FeatherOpenAPI/Map/OrderedMap.swift b/Sources/FeatherOpenAPI/Map/OrderedMap.swift new file mode 100644 index 0000000..bef9d91 --- /dev/null +++ b/Sources/FeatherOpenAPI/Map/OrderedMap.swift @@ -0,0 +1,72 @@ +// +// OrderedMap.swift +// feather-openapi +// + +import OpenAPIKit30 + +/// An insertion-ordered map used by FeatherOpenAPI's public abstractions. +public struct OrderedMap: + ExpressibleByDictionaryLiteral, + Sequence +{ + private var storage: OrderedDictionary + + var orderedDictionary: OrderedDictionary { storage } + + public init(dictionaryLiteral elements: (Key, Value)...) { + self.storage = [:] + for (key, value) in elements { + self.storage[key] = value + } + } + + public init() { + self.storage = [:] + } + + public var keys: [Key] { Array(storage.keys) } + public var values: [Value] { Array(storage.values) } + public var isEmpty: Bool { storage.isEmpty } + public var count: Int { storage.count } + + public subscript(key: Key) -> Value? { + get { storage[key] } + set { storage[key] = newValue } + } + + public mutating func merge( + _ other: Self, + uniquingKeysWith combine: (Value, Value) throws -> Value + ) rethrows { + try storage.merge(other.storage, uniquingKeysWith: combine) + } + + public func mapValues( + _ transform: (Value) throws -> Transformed + ) rethrows -> OrderedMap { + var result = OrderedMap() + for (key, value) in storage { + result[key] = try transform(value) + } + return result + } + + public struct Iterator: IteratorProtocol { + private var iterator: IndexingIterator<[(Key, Value)]> + + fileprivate init(_ elements: [(Key, Value)]) { + self.iterator = elements.makeIterator() + } + + public mutating func next() -> (Key, Value)? { + iterator.next() + } + } + + public func makeIterator() -> Iterator { + Iterator(storage.map { ($0.key, $0.value) }) + } +} + +extension OrderedMap: Sendable where Key: Sendable, Value: Sendable {} diff --git a/Sources/FeatherOpenAPI/Operation/OperationRepresentable.swift b/Sources/FeatherOpenAPI/Operation/OperationRepresentable.swift index 5a94f36..365909f 100644 --- a/Sources/FeatherOpenAPI/Operation/OperationRepresentable.swift +++ b/Sources/FeatherOpenAPI/Operation/OperationRepresentable.swift @@ -95,7 +95,18 @@ extension OperationRepresentable { tags.isEmpty ? nil : tags.map { $0.name } } - private var openAPISecurityRequirements: [OpenAPI.SecurityRequirement]? { + private func openAPIResponses( + _ responseMap: ResponseMap + ) -> OrderedDictionary< + OpenAPI.Response.StatusCode, + Either, OpenAPI.Response> + > { + responseMap.openAPIResponseMap + } + + private func openAPISecurityRequirements( + _ security: [any SecurityRequirementRepresentable]? + ) -> [OpenAPI.SecurityRequirement]? { guard let security, !security.isEmpty else { return nil } @@ -106,6 +117,14 @@ extension OperationRepresentable { /// Builds an OpenAPI operation. /// - Returns: The OpenAPI operation. public func openAPIOperation() -> OpenAPI.Operation { + openAPIOperation(responseMap: responseMap, security: security) + } + + /// Builds an OpenAPI operation using custom responses and security. + public func openAPIOperation( + responseMap: ResponseMap, + security: [any SecurityRequirementRepresentable]? + ) -> OpenAPI.Operation { if let requestBody { return .init( tags: openAPITags, @@ -115,10 +134,10 @@ extension OperationRepresentable { operationId: operationId, parameters: parameters.map { $0.openAPIParameter() }, requestBody: requestBody.openAPIRequestBody(), - responses: responseMap.mapValues { $0.openAPIResponse() }, + responses: openAPIResponses(responseMap), callbacks: [:], deprecated: deprecated, - security: openAPISecurityRequirements, + security: openAPISecurityRequirements(security), servers: servers?.map { $0.openAPIServer() }, vendorExtensions: vendorExtensions ) @@ -130,10 +149,10 @@ extension OperationRepresentable { externalDocs: nil, operationId: operationId, parameters: parameters.map { $0.openAPIParameter() }, - responses: responseMap.mapValues { $0.openAPIResponse() }, + responses: openAPIResponses(responseMap), callbacks: [:], deprecated: deprecated, - security: openAPISecurityRequirements, + security: openAPISecurityRequirements(security), servers: servers?.map { $0.openAPIServer() }, vendorExtensions: vendorExtensions ) diff --git a/Sources/FeatherOpenAPI/PathItem/PathMap.swift b/Sources/FeatherOpenAPI/PathItem/PathMap.swift index 9f8254f..f80de9b 100644 --- a/Sources/FeatherOpenAPI/PathItem/PathMap.swift +++ b/Sources/FeatherOpenAPI/PathItem/PathMap.swift @@ -5,10 +5,8 @@ // Created by Tibor Bödecs on 2026. 01. 23. // -public import OpenAPIKit30 - /// Ordered map of OpenAPI paths to path items. -public typealias PathMap = OrderedDictionary< - OpenAPI.Path, +public typealias PathMap = OrderedMap< + String, any PathItemRepresentable > diff --git a/Sources/FeatherOpenAPI/RequestBody/Abstraction/RequestBodyRepresentable.swift b/Sources/FeatherOpenAPI/RequestBody/Abstraction/RequestBodyRepresentable.swift index 29c69a4..eb56865 100644 --- a/Sources/FeatherOpenAPI/RequestBody/Abstraction/RequestBodyRepresentable.swift +++ b/Sources/FeatherOpenAPI/RequestBody/Abstraction/RequestBodyRepresentable.swift @@ -37,7 +37,7 @@ extension RequestBodyRepresentable { .init( .init( description: description, - content: contentMap.mapValues { $0.openAPIContent() }, + content: contentMap.openAPIContentMap, required: `required`, vendorExtensions: vendorExtensions ) diff --git a/Sources/FeatherOpenAPI/RequestBody/BinaryRequestBodyRepresentable.swift b/Sources/FeatherOpenAPI/RequestBody/BinaryRequestBodyRepresentable.swift index fc37f99..4f34b0c 100644 --- a/Sources/FeatherOpenAPI/RequestBody/BinaryRequestBodyRepresentable.swift +++ b/Sources/FeatherOpenAPI/RequestBody/BinaryRequestBodyRepresentable.swift @@ -5,8 +5,6 @@ // Created by Tibor Bödecs on 2026. 01. 22. // -public import OpenAPIKit30 - /// Request body with binary content. public protocol BinaryRequestBodyRepresentable: RequestBodyRepresentable { diff --git a/Sources/FeatherOpenAPI/RequestBody/FormRequestBodyRepresentable.swift b/Sources/FeatherOpenAPI/RequestBody/FormRequestBodyRepresentable.swift index 1a8ba4b..ed70be2 100644 --- a/Sources/FeatherOpenAPI/RequestBody/FormRequestBodyRepresentable.swift +++ b/Sources/FeatherOpenAPI/RequestBody/FormRequestBodyRepresentable.swift @@ -5,8 +5,6 @@ // Created by Tibor Bödecs on 2026. 01. 22. // -public import OpenAPIKit30 - /// Request body with form-encoded content. public protocol FormRequestBodyRepresentable: RequestBodyRepresentable { /// The schema type used in the form content. diff --git a/Sources/FeatherOpenAPI/RequestBody/JSONRequestBodyRepresentable.swift b/Sources/FeatherOpenAPI/RequestBody/JSONRequestBodyRepresentable.swift index f600cf2..2c5a863 100644 --- a/Sources/FeatherOpenAPI/RequestBody/JSONRequestBodyRepresentable.swift +++ b/Sources/FeatherOpenAPI/RequestBody/JSONRequestBodyRepresentable.swift @@ -5,8 +5,6 @@ // Created by Tibor Bödecs on 2026. 01. 22. // -public import OpenAPIKit30 - /// Request body with JSON content. public protocol JSONRequestBodyRepresentable: RequestBodyRepresentable { /// The JSON schema type used in the request body. diff --git a/Sources/FeatherOpenAPI/Response/Abstraction/ResponseMap.swift b/Sources/FeatherOpenAPI/Response/Abstraction/ResponseMap.swift index 8c3fab4..6296d83 100644 --- a/Sources/FeatherOpenAPI/Response/Abstraction/ResponseMap.swift +++ b/Sources/FeatherOpenAPI/Response/Abstraction/ResponseMap.swift @@ -5,10 +5,31 @@ // Created by Tibor Bödecs on 2026. 01. 23. // -public import OpenAPIKit30 +import OpenAPIKit30 /// Ordered map of response status codes to response definitions. -public typealias ResponseMap = OrderedDictionary< - OpenAPI.Response.StatusCode, +public typealias ResponseMap = OrderedMap< + ResponseStatusKey, any ResponseRepresentable > + +extension ResponseMap { + var openAPIResponseMap: OpenAPI.Response.Map { + var results: OpenAPI.Response.Map = [:] + for (key, response) in self { + let statusCode: OpenAPI.Response.StatusCode + switch key { + case .status(let value): + statusCode = .status(code: value) + case .default: + statusCode = .default + case .range(let range): + statusCode = .range( + OpenAPI.Response.StatusCode.Range(rawValue: range.rawValue)! + ) + } + results[statusCode] = response.openAPIResponse() + } + return results + } +} diff --git a/Sources/FeatherOpenAPI/Response/Abstraction/ResponseRepresentable.swift b/Sources/FeatherOpenAPI/Response/Abstraction/ResponseRepresentable.swift index 42a4818..74eb6cd 100644 --- a/Sources/FeatherOpenAPI/Response/Abstraction/ResponseRepresentable.swift +++ b/Sources/FeatherOpenAPI/Response/Abstraction/ResponseRepresentable.swift @@ -38,13 +38,17 @@ extension ResponseRepresentable { JSONReference, OpenAPI.Response > { let responseHeaders: OpenAPI.Header.Map? = - headerMap.isEmpty ? nil : headerMap.mapValues { $0.openAPIHeader() } + headerMap.isEmpty + ? nil + : headerMap.orderedDictionary.mapValues { + $0.openAPIHeader() + } return .init( .init( description: description, headers: responseHeaders, - content: contentMap.mapValues { $0.openAPIContent() }, + content: contentMap.openAPIContentMap, links: [:], vendorExtensions: vendorExtensions ) diff --git a/Sources/FeatherOpenAPI/Response/Abstraction/ResponseStatusKey.swift b/Sources/FeatherOpenAPI/Response/Abstraction/ResponseStatusKey.swift new file mode 100644 index 0000000..4338078 --- /dev/null +++ b/Sources/FeatherOpenAPI/Response/Abstraction/ResponseStatusKey.swift @@ -0,0 +1,25 @@ +// +// ResponseStatusKey.swift +// feather-openapi +// + +/// A response status code or range, independent of OpenAPIKit. +public enum ResponseStatusKey: Hashable, Sendable, + ExpressibleByIntegerLiteral +{ + case status(Int) + case `default` + case range(Range) + + public enum Range: String, Hashable, Sendable { + case information = "1XX" + case success = "2XX" + case redirect = "3XX" + case clientError = "4XX" + case serverError = "5XX" + } + + public init(integerLiteral value: Int) { + self = .status(value) + } +} diff --git a/Sources/FeatherOpenAPI/Response/BinaryResponseRepresentable.swift b/Sources/FeatherOpenAPI/Response/BinaryResponseRepresentable.swift index 041ae67..907d526 100644 --- a/Sources/FeatherOpenAPI/Response/BinaryResponseRepresentable.swift +++ b/Sources/FeatherOpenAPI/Response/BinaryResponseRepresentable.swift @@ -5,8 +5,6 @@ // Created by Tibor Bödecs on 2026. 01. 23. // -public import OpenAPIKit30 - /// Response with binary content. public protocol BinaryResponseRepresentable: ResponseRepresentable { diff --git a/Sources/FeatherOpenAPI/Response/FormResponseRepresentable.swift b/Sources/FeatherOpenAPI/Response/FormResponseRepresentable.swift index 260ae56..07dc42d 100644 --- a/Sources/FeatherOpenAPI/Response/FormResponseRepresentable.swift +++ b/Sources/FeatherOpenAPI/Response/FormResponseRepresentable.swift @@ -5,8 +5,6 @@ // Created by Tibor Bödecs on 2026. 01. 23. // -public import OpenAPIKit30 - /// Response with form-encoded content. public protocol FormResponseRepresentable: ResponseRepresentable { /// The schema type used in the form content. diff --git a/Sources/FeatherOpenAPI/Response/JSONResponseRepresentable.swift b/Sources/FeatherOpenAPI/Response/JSONResponseRepresentable.swift index 6e933ba..9612c60 100644 --- a/Sources/FeatherOpenAPI/Response/JSONResponseRepresentable.swift +++ b/Sources/FeatherOpenAPI/Response/JSONResponseRepresentable.swift @@ -5,8 +5,6 @@ // Created by Tibor Bödecs on 2026. 01. 23. // -public import OpenAPIKit30 - /// Response with JSON content. public protocol JSONResponseRepresentable: ResponseRepresentable { /// The JSON schema type used in the response content. diff --git a/Sources/FeatherOpenAPI/Schema/Abstraction/SchemaMap.swift b/Sources/FeatherOpenAPI/Schema/Abstraction/SchemaMap.swift index 09939da..ce3b456 100644 --- a/Sources/FeatherOpenAPI/Schema/Abstraction/SchemaMap.swift +++ b/Sources/FeatherOpenAPI/Schema/Abstraction/SchemaMap.swift @@ -5,10 +5,8 @@ // Created by Tibor Bödecs on 2026. 01. 23. // -public import OpenAPIKit30 - /// Ordered map of schema names to schema definitions. -public typealias SchemaMap = OrderedDictionary< +public typealias SchemaMap = OrderedMap< String, any SchemaRepresentable > diff --git a/Sources/FeatherOpenAPI/Schema/Abstraction/SchemaRepresentable.swift b/Sources/FeatherOpenAPI/Schema/Abstraction/SchemaRepresentable.swift index d251e98..05c7a97 100644 --- a/Sources/FeatherOpenAPI/Schema/Abstraction/SchemaRepresentable.swift +++ b/Sources/FeatherOpenAPI/Schema/Abstraction/SchemaRepresentable.swift @@ -10,6 +10,7 @@ public import OpenAPIKit30 /// Describes an OpenAPI schema with common properties. public protocol SchemaRepresentable: OpenAPISchemaRepresentable, + Sendable, Identifiable, ReferencedSchemaMapRepresentable, // shared properties diff --git a/Sources/FeatherOpenAPI/Schema/ObjectSchemaRepresentable.swift b/Sources/FeatherOpenAPI/Schema/ObjectSchemaRepresentable.swift index 49a17fe..5d1aae7 100644 --- a/Sources/FeatherOpenAPI/Schema/ObjectSchemaRepresentable.swift +++ b/Sources/FeatherOpenAPI/Schema/ObjectSchemaRepresentable.swift @@ -35,7 +35,9 @@ extension ObjectSchemaRepresentable { externalDocs: nil, minProperties: nil, maxProperties: nil, - properties: propertyMap.mapValues { $0.openAPISchema() }, + properties: propertyMap.orderedDictionary.mapValues { + $0.openAPISchema() + }, additionalProperties: nil, allowedValues: nil, defaultValue: nil,