From 5611e6cf6442c21afa34fc2ec316990049e53bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Sat, 20 Jun 2026 08:24:08 +0200 Subject: [PATCH 01/23] Add SiteKitOpenAPI target with an OpenAPI 3.0/3.1 spec loader Introduce an optional SiteKitOpenAPI library product and target that turns an OpenAPI document into a flattened, render-ready model. The parser (OpenAPIKit, via OpenAPIKitCompat) attaches only to this target, so a consumer depending on the base SiteKit product gains no new dependency (SE-0226 target-based resolution prunes it). Yams is reused from SiteKit, so this adds no new transitive runtime dependency either. OpenAPISpecLoader loads a spec from a file URL and handles the full input matrix on its own: format is auto-detected by extension (.json vs YAML) and version by the openapi field, with 3.0 documents normalized to 3.1 through OpenAPIKitCompat's convert(to:) so everything downstream sees one 3.1 shape. It projects the document into OpenAPISpec, a plain value model (info, servers, tags, operations, schemas) deliberately decoupled from OpenAPIKit so the renderers never import the parser. The openAPI(config:projectDirectory:) factory mirrors docc(...): it discovers the spec by convention at Content/openapi.yaml (with .yml/.json and an explicit specPath escape hatch), loads it up front, and returns a SiteBuilder. The page renderers that consume the model are a separate, follow-up piece of work. Cover the loader with red-green tests across the full 2x2 matrix, OpenAPI 3.0 and 3.1 each as YAML and JSON, using the canonical Petstore: info, servers, tags, the flattened operation list, a known operation's method, path, parameters and responses, the request body, and the component schemas with their properties and required fields. --- Package.resolved | 11 +- Package.swift | 31 +- Sources/SiteKitOpenAPI/OpenAPISchema.swift | 147 ++++++++ Sources/SiteKitOpenAPI/OpenAPISpec.swift | 316 ++++++++++++++++ .../SiteKitOpenAPI/OpenAPISpecLoader.swift | 343 ++++++++++++++++++ .../SiteKitOpenAPI/SiteBuilder+OpenAPI.swift | 72 ++++ .../Fixtures/petstore-3.0.json | 140 +++++++ .../Fixtures/petstore-3.0.yaml | 116 ++++++ .../Fixtures/petstore-3.1.json | 140 +++++++ .../Fixtures/petstore-3.1.yaml | 116 ++++++ .../OpenAPISpecLoaderTests.swift | 143 ++++++++ 11 files changed, 1573 insertions(+), 2 deletions(-) create mode 100644 Sources/SiteKitOpenAPI/OpenAPISchema.swift create mode 100644 Sources/SiteKitOpenAPI/OpenAPISpec.swift create mode 100644 Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift create mode 100644 Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift create mode 100644 Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.json create mode 100644 Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.yaml create mode 100644 Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.json create mode 100644 Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.yaml create mode 100644 Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift diff --git a/Package.resolved b/Package.resolved index e426ad1..6e2287c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "8ed9a9d9e721da71dfc2e44baf106c5f006158b5665518d22d352e32f671eb79", + "originHash" : "2b9d923b5d7e8abc1f30661689f1b5357acb38b7ef9a53f4381782b72be09c27", "pins" : [ + { + "identity" : "openapikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/OpenAPIKit.git", + "state" : { + "revision" : "57b6318128e3f901c93f4fbf98d1c1464ec168d3", + "version" : "6.2.0" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 1e45e29..69bb173 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,11 @@ let package = Package( // ONLY into builds that actually use it. A consumer depending only on the `SiteKit` product never // compiles swift-syntax (SE-0226 target-based dependency resolution prunes it). .library(name: "SiteKitSyntaxHighlighting", targets: ["SiteKitSyntaxHighlighting"]), + // Optional add-on library: renders an OpenAPI 3.0/3.1 spec (YAML or JSON) into a multi-page, + // style-conforming API-documentation site. It lives in its own product+target so the OpenAPIKit + // parser pulls in ONLY for builds that use it. A consumer depending solely on the `SiteKit` + // product never compiles OpenAPIKit (SE-0226 target-based dependency resolution prunes it). + .library(name: "SiteKitOpenAPI", targets: ["SiteKitOpenAPI"]), // The executable *product* is `sitekit` (the durable public command name); its *target* // is `SiteKitCLI` because a target literally named `sitekit` collides with the `SiteKit` // library target on a case-insensitive filesystem – at both the `Sources/` directory and @@ -26,6 +31,13 @@ let package = Package( .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + // OpenAPIKit powers ONLY the optional SiteKitOpenAPI target: it parses an OpenAPI 3.0/3.1 + // document into a runtime model the blueprint walks to render API-documentation pages. It + // attaches solely to SiteKitOpenAPI, so a consumer depending only on the base `SiteKit` + // product never compiles it (SE-0226 target-based dependency resolution prunes it). OpenAPIKit + // declares Yams only for ITS test targets, so this adds zero new transitive runtime deps. + .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "6.2.0"), + // swift-syntax powers ONLY the optional SiteKitSyntaxHighlighting target. The 6xx.x line tracks // the Swift toolchain (603.x = Swift 6.3, the toolchain SiteKit builds with). Only the parser + // tree + syntactic-classification modules are used; the macro/compiler-plugin modules (the heavy, @@ -47,7 +59,7 @@ let package = Package( .executableTarget( name: "SiteKitCLI", dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "ArgumentParser", package: "swift-argument-parser") ] ), // Optional SwiftSyntax-based highlighter. Depends on the base `SiteKit` library (for the @@ -74,6 +86,23 @@ let package = Package( name: "SiteKitSyntaxHighlightingTests", dependencies: ["SiteKitSyntaxHighlighting"] ), + // Optional OpenAPI blueprint. Depends on the base `SiteKit` library (for the pipeline, + // SiteBuilder and PageShell seams), OpenAPIKitCompat (which re-exports both the 3.0 and 3.1 + // parsers and the 3.0→3.1 conversion used to normalize every spec to one 3.1 shape), and Yams + // (already a SiteKit dependency – reused here to decode YAML specs, no new transitive dep). + .target( + name: "SiteKitOpenAPI", + dependencies: [ + "SiteKit", + .product(name: "OpenAPIKitCompat", package: "OpenAPIKit"), + "Yams", + ] + ), + .testTarget( + name: "SiteKitOpenAPITests", + dependencies: ["SiteKitOpenAPI"], + resources: [.copy("Fixtures")] + ), .testTarget( name: "SiteKitCLITests", dependencies: ["SiteKitCLI"] diff --git a/Sources/SiteKitOpenAPI/OpenAPISchema.swift b/Sources/SiteKitOpenAPI/OpenAPISchema.swift new file mode 100644 index 0000000..8354d9b --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISchema.swift @@ -0,0 +1,147 @@ +import Foundation + +/// One named schema from the spec's `components/schemas` section. +/// +/// The `name` is the component key (for example `Pet`), which doubles as the +/// slug for the schema's documentation page; `schema` is the flattened, +/// OpenAPIKit-free description the renderers walk. +public struct SchemaObject: Sendable, Equatable { + /// The component key, for example `Pet` for `#/components/schemas/Pet`. + public let name: String + + /// The flattened schema description. + public let schema: SchemaNode + + /// Memberwise initializer. + public init(name: String, schema: SchemaNode) { + self.name = name + self.schema = schema + } +} + +/// A flattened, render-ready description of a JSON Schema node. +/// +/// This is deliberately decoupled from OpenAPIKit's `JSONSchema` so the page +/// renderers never need to `import OpenAPIKit`. It captures the facets a docs +/// renderer cares about (type, format, the object's properties, an array's +/// element schema, `$ref` targets, enum values, composition) and flattens the +/// rest. Recursion runs through arrays (`properties`, `items`, `composition`) +/// so the value type stays a plain `struct` without boxing. +public struct SchemaNode: Sendable, Equatable { + /// The JSON Schema `type` keyword (`object`, `array`, `string`, `integer`, + /// `number`, `boolean`, `null`), or `nil` for a reference, a composition, or + /// an untyped fragment. + public let type: String? + + /// The `format` keyword refining `type` (for example `int64`, `date-time`). + public let format: String? + + /// The schema's `title`, if any. + public let title: String? + + /// The schema's `description`, if any. + public let description: String? + + /// The names of the required properties (object schemas only). + public let required: [String] + + /// The object's properties in declaration order (object schemas only). + public let properties: [SchemaProperty] + + /// The array element schema, in a zero-or-one-element array (array schemas + /// only). An array is used instead of an optional so the value type need not + /// be `indirect`. + public let items: [SchemaNode] + + /// The allowed values of an `enum` schema, rendered to their string form. + public let enumValues: [String] + + /// The local `$ref` target name (for example `Pet` for + /// `#/components/schemas/Pet`) when this node is a reference, else `nil`. + public let referenceName: String? + + /// The `allOf` / `oneOf` / `anyOf` composition this node represents, if any. + public let composition: Composition? + + /// Whether the schema is marked `deprecated`. + public let deprecated: Bool + + /// Whether the schema is nullable (the 3.0 `nullable: true` flag, normalized + /// from a `["T", "null"]` type array by OpenAPIKit on 3.1 documents). + public let nullable: Bool + + /// Memberwise initializer. Every field defaults to its empty value so a + /// renderer can construct a partial node without restating the whole shape. + public init( + type: String? = nil, + format: String? = nil, + title: String? = nil, + description: String? = nil, + required: [String] = [], + properties: [SchemaProperty] = [], + items: [SchemaNode] = [], + enumValues: [String] = [], + referenceName: String? = nil, + composition: Composition? = nil, + deprecated: Bool = false, + nullable: Bool = false + ) { + self.type = type + self.format = format + self.title = title + self.description = description + self.required = required + self.properties = properties + self.items = items + self.enumValues = enumValues + self.referenceName = referenceName + self.composition = composition + self.deprecated = deprecated + self.nullable = nullable + } +} + +/// One property of an object schema: its name, whether it is required, and the +/// flattened schema describing its value. +public struct SchemaProperty: Sendable, Equatable { + /// The property name as it appears in the object. + public let name: String + + /// Whether the parent object lists this property in its `required` array. + public let required: Bool + + /// The flattened schema of the property's value. + public let schema: SchemaNode + + /// Memberwise initializer. + public init(name: String, required: Bool, schema: SchemaNode) { + self.name = name + self.required = required + self.schema = schema + } +} + +/// An `allOf` / `oneOf` / `anyOf` schema composition and its member schemas. +public struct Composition: Sendable, Equatable { + /// Which JSON Schema composition keyword produced this node. + public enum Kind: String, Sendable, Equatable { + /// `allOf` – the value must satisfy every member schema. + case allOf + /// `oneOf` – the value must satisfy exactly one member schema. + case oneOf + /// `anyOf` – the value must satisfy at least one member schema. + case anyOf + } + + /// The composition keyword. + public let kind: Kind + + /// The member schemas being composed, in declaration order. + public let subschemas: [SchemaNode] + + /// Memberwise initializer. + public init(kind: Kind, subschemas: [SchemaNode]) { + self.kind = kind + self.subschemas = subschemas + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISpec.swift b/Sources/SiteKitOpenAPI/OpenAPISpec.swift new file mode 100644 index 0000000..b60fe5f --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISpec.swift @@ -0,0 +1,316 @@ +import Foundation + +/// A flattened, render-ready view of an OpenAPI document. +/// +/// `OpenAPISpecLoader` decodes an OpenAPI 3.0 or 3.1 file (YAML or JSON), +/// normalizes 3.0 documents to the 3.1 shape, and projects the result into this +/// value model. The model is intentionally decoupled from OpenAPIKit so the page +/// renderers (landing, tag, operation, schema) read only SiteKit-owned types and +/// never `import OpenAPIKit`. This is the contract every OpenAPI renderer builds on. +public struct OpenAPISpec: Sendable, Equatable { + /// The document's `info` block: title, version, description. + public let info: Info + + /// The declared servers, in document order. + public let servers: [Server] + + /// The declared tags, in document order. Operations reference these by name. + public let tags: [Tag] + + /// Every operation across all paths, flattened to one list (method + path + + /// the operation's metadata), in document order. + public let operations: [Operation] + + /// The reusable schemas from `components/schemas`, in document order. + public let schemas: [SchemaObject] + + /// Memberwise initializer. + public init( + info: Info, + servers: [Server], + tags: [Tag], + operations: [Operation], + schemas: [SchemaObject] + ) { + self.info = info + self.servers = servers + self.tags = tags + self.operations = operations + self.schemas = schemas + } + + /// The document's `info` block. + public struct Info: Sendable, Equatable { + /// The API title, shown as the site/landing heading. + public let title: String + + /// The API version string (for example `1.0.0`). + public let version: String + + /// The API's short summary, if provided (OpenAPI 3.1 only). + public let summary: String? + + /// The API's longer description (Markdown), if provided. + public let description: String? + + /// Memberwise initializer. + public init(title: String, version: String, summary: String? = nil, description: String? = nil) { + self.title = title + self.version = version + self.summary = summary + self.description = description + } + } + + /// One server entry from the document's `servers` list. + public struct Server: Sendable, Equatable { + /// The server URL, with any `{variable}` templates left intact. + public let url: String + + /// The server's description, if provided. + public let description: String? + + /// Memberwise initializer. + public init(url: String, description: String? = nil) { + self.url = url + self.description = description + } + } + + /// One tag entry from the document's `tags` list. Operations group under a + /// tag's `name`. + public struct Tag: Sendable, Equatable { + /// The tag name, used to group operations and as the tag page slug. + public let name: String + + /// The tag's description (Markdown), if provided. + public let description: String? + + /// Memberwise initializer. + public init(name: String, description: String? = nil) { + self.name = name + self.description = description + } + } +} + +/// One operation: an HTTP method on a path plus its documented metadata. +public struct Operation: Sendable, Equatable { + /// The uppercased HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, …). + public let method: String + + /// The templated path the operation lives under (for example `/pets/{id}`). + public let path: String + + /// The operation's stable `operationId`, if provided. + public let operationId: String? + + /// The operation's short summary, if provided. + public let summary: String? + + /// The operation's longer description (Markdown), if provided. + public let description: String? + + /// The tags this operation belongs to, in document order. + public let tags: [String] + + /// The operation's parameters (path, query, header, cookie), in document order. + public let parameters: [Parameter] + + /// The operation's request body, if it declares one. + public let requestBody: RequestBody? + + /// The operation's responses keyed by status, in document order. + public let responses: [Response] + + /// The operation's security requirements (an OR of requirement sets, each an + /// AND of named schemes), in document order. + public let security: [SecurityRequirement] + + /// Whether the operation is marked `deprecated`. + public let deprecated: Bool + + /// Memberwise initializer. + public init( + method: String, + path: String, + operationId: String? = nil, + summary: String? = nil, + description: String? = nil, + tags: [String] = [], + parameters: [Parameter] = [], + requestBody: RequestBody? = nil, + responses: [Response] = [], + security: [SecurityRequirement] = [], + deprecated: Bool = false + ) { + self.method = method + self.path = path + self.operationId = operationId + self.summary = summary + self.description = description + self.tags = tags + self.parameters = parameters + self.requestBody = requestBody + self.responses = responses + self.security = security + self.deprecated = deprecated + } +} + +/// One operation parameter (path, query, header, or cookie). +public struct Parameter: Sendable, Equatable { + /// Where a parameter is carried in the request. + public enum Location: Sendable, Equatable { + /// A query-string parameter (`?name=…`). + case query + /// A path parameter (a `{name}` segment). + case path + /// A header parameter. + case header + /// A cookie parameter. + case cookie + /// Any other (forward-compatible) location, carrying its raw spec value. + case other(String) + + /// The lowercase spec string for this location (`query`, `path`, …). + public var rawValue: String { + switch self { + case .query: "query" + case .path: "path" + case .header: "header" + case .cookie: "cookie" + case .other(let value): value + } + } + + /// Maps a raw OpenAPI location string to a `Location`, preserving unknown + /// values via `.other` rather than dropping them. + public init(rawValue: String) { + switch rawValue { + case "query": self = .query + case "path": self = .path + case "header": self = .header + case "cookie": self = .cookie + default: self = .other(rawValue) + } + } + } + + /// The parameter name. + public let name: String + + /// Where the parameter is carried. + public let location: Location + + /// The parameter's description (Markdown), if provided. + public let description: String? + + /// Whether the parameter is required. Path parameters are always required. + public let required: Bool + + /// Whether the parameter is marked `deprecated`. + public let deprecated: Bool + + /// The flattened schema describing the parameter's value, if it declares one. + public let schema: SchemaNode? + + /// Memberwise initializer. + public init( + name: String, + location: Location, + description: String? = nil, + required: Bool = false, + deprecated: Bool = false, + schema: SchemaNode? = nil + ) { + self.name = name + self.location = location + self.description = description + self.required = required + self.deprecated = deprecated + self.schema = schema + } +} + +/// An operation's request body. +public struct RequestBody: Sendable, Equatable { + /// The request body's description (Markdown), if provided. + public let description: String? + + /// Whether the request body is required. + public let required: Bool + + /// The body's representations keyed by media type, in document order. + public let content: [MediaType] + + /// Memberwise initializer. + public init(description: String? = nil, required: Bool = false, content: [MediaType] = []) { + self.description = description + self.required = required + self.content = content + } +} + +/// One media-type representation of a request or response body (for example +/// `application/json`) and its flattened schema. +public struct MediaType: Sendable, Equatable { + /// The media type string (for example `application/json`). + public let contentType: String + + /// The flattened schema describing this representation, if it declares one. + public let schema: SchemaNode? + + /// Memberwise initializer. + public init(contentType: String, schema: SchemaNode? = nil) { + self.contentType = contentType + self.schema = schema + } +} + +/// One response of an operation, keyed by status. +public struct Response: Sendable, Equatable { + /// The status key as written in the spec (for example `200`, `404`, + /// `default`, or a `2XX` range). + public let statusCode: String + + /// The response's description, if provided. + public let description: String? + + /// The response body's representations keyed by media type, in document order. + public let content: [MediaType] + + /// Memberwise initializer. + public init(statusCode: String, description: String? = nil, content: [MediaType] = []) { + self.statusCode = statusCode + self.description = description + self.content = content + } +} + +/// One security requirement: a set of named schemes that must ALL be satisfied +/// together. An operation's `security` list is the OR of these requirements. +public struct SecurityRequirement: Sendable, Equatable { + /// One named scheme reference inside a requirement, with its required scopes. + public struct SchemeRequirement: Sendable, Equatable { + /// The referenced `securityScheme` name from `components/securitySchemes`. + public let name: String + + /// The OAuth2 / OpenID-Connect scopes required, if any. + public let scopes: [String] + + /// Memberwise initializer. + public init(name: String, scopes: [String]) { + self.name = name + self.scopes = scopes + } + } + + /// The schemes that must all be satisfied for this requirement. + public let schemes: [SchemeRequirement] + + /// Memberwise initializer. + public init(schemes: [SchemeRequirement]) { + self.schemes = schemes + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift b/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift new file mode 100644 index 0000000..0901574 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift @@ -0,0 +1,343 @@ +import Foundation +import OpenAPIKit +import OpenAPIKit30 +import OpenAPIKitCompat +import OpenAPIKitCore +import SiteKit +import Yams + +// Both OpenAPIKit (3.1) and OpenAPIKit30 export `JSONSchema`, and both re-export `Either` / +// `AnyCodable` from OpenAPIKitCore, so the bare names are ambiguous while both modules are imported +// (OpenAPIKit30 is needed only to decode legacy 3.0 documents before converting them). The loader +// normalizes every document to the 3.1 model up front, so the mapping below speaks only the 3.1 +// types: these file-private aliases pin the bare names to the right module. +private typealias JSONSchema = OpenAPIKit.JSONSchema +private typealias Either = OpenAPIKitCore.Either +private typealias AnyCodable = OpenAPIKitCore.AnyCodable + +/// Loads an OpenAPI document from a file URL into the flattened ``OpenAPISpec``. +/// +/// The loader handles the full 2×2 input matrix on its own: +/// - **Format** is auto-detected by file extension: `.json` decodes with +/// `JSONDecoder`, `.yaml`/`.yml` (and anything else) decode with Yams. +/// - **Version** is auto-detected from the document's `openapi:` field: 3.1 +/// documents decode straight to OpenAPIKit's 3.1 model, while 3.0 documents +/// decode with `OpenAPIKit30` and are normalized to 3.1 through +/// `OpenAPIKitCompat`'s `convert(to:)`. Everything downstream therefore sees +/// one 3.1 shape and the renderers never branch on spec version. +/// +/// Conforms to SiteKit's `Loader` so it slots into the pipeline's loading phase; +/// its `Source` is a file `URL` and its `Output` is the typed ``OpenAPISpec``. +public struct OpenAPISpecLoader: Loader { + public typealias Source = URL + public typealias Output = OpenAPISpec + + public init() {} + + /// Errors thrown while loading and decoding an OpenAPI document. + public enum LoadError: Swift.Error, CustomStringConvertible, Equatable { + /// The `openapi:` version field was missing or not a recognized 3.0/3.1 value. + case unsupportedVersion(String) + + public var description: String { + switch self { + case .unsupportedVersion(let found): + "Unsupported or missing OpenAPI version '\(found)'. SiteKitOpenAPI supports OpenAPI 3.0.x and 3.1.x." + } + } + } + + /// Decodes the document at `source` and projects it into an ``OpenAPISpec``. + /// + /// Throws a decoding error (naming the file) when the document is malformed, + /// or ``LoadError/unsupportedVersion(_:)`` when the `openapi:` field is absent + /// or names a major version other than 3.0 / 3.1. + public func load(source url: URL) throws -> OpenAPISpec { + let data = try Data(contentsOf: url) + let isJSON = url.pathExtension.lowercased() == "json" + + let document = try Self.decodeDocument(data: data, isJSON: isJSON) + return Self.makeSpec(from: document) + } + + // MARK: - Decoding + + /// Detects the document's major version and decodes to a unified 3.1 model. + private static func decodeDocument(data: Data, isJSON: Bool) throws -> OpenAPIKit.OpenAPI.Document { + let version = try detectMajorVersion(data: data, isJSON: isJSON) + + switch version { + case .v3_1: + return try decode(OpenAPIKit.OpenAPI.Document.self, from: data, isJSON: isJSON) + case .v3_0: + let legacy = try decode(OpenAPIKit30.OpenAPI.Document.self, from: data, isJSON: isJSON) + return legacy.convert(to: .v3_1_1) + } + } + + /// The two major OpenAPI versions this loader accepts. + private enum MajorVersion { + case v3_0 + case v3_1 + } + + /// A minimal probe that reads only the `openapi:` field so the right typed + /// decoder can be chosen before the full (version-specific) decode. + private struct VersionProbe: Decodable { + let openapi: String + } + + /// Reads the `openapi:` field and maps it to a ``MajorVersion``. + private static func detectMajorVersion(data: Data, isJSON: Bool) throws -> MajorVersion { + let probe = try decode(VersionProbe.self, from: data, isJSON: isJSON) + if probe.openapi.hasPrefix("3.1") { + return .v3_1 + } else if probe.openapi.hasPrefix("3.0") { + return .v3_0 + } else { + throw LoadError.unsupportedVersion(probe.openapi) + } + } + + /// Decodes `T` from `data` using the JSON or YAML decoder per `isJSON`. + private static func decode(_ type: T.Type, from data: Data, isJSON: Bool) throws -> T { + if isJSON { + return try JSONDecoder().decode(T.self, from: data) + } else { + return try YAMLDecoder().decode(T.self, from: data) + } + } + + // MARK: - Mapping + + /// Projects a decoded 3.1 document into the flattened ``OpenAPISpec``. + /// + /// Path items and the schemas, parameters, request bodies, and responses + /// nested inside operations may each be either an inline value or a `$ref`. + /// A single self-contained spec (the shape this blueprint supports) carries + /// them inline, so inline values are flattened in full and a top-level `$ref` + /// to a reusable component is preserved by name (``SchemaNode/referenceName``) + /// for the renderers to link. + private static func makeSpec(from document: OpenAPIKit.OpenAPI.Document) -> OpenAPISpec { + let info = OpenAPISpec.Info( + title: document.info.title, + version: document.info.version, + summary: document.info.summary, + description: document.info.description + ) + + let servers = document.servers.map { server in + OpenAPISpec.Server(url: server.urlTemplate.absoluteString, description: server.description) + } + + let tags = (document.tags ?? []).map { tag in + OpenAPISpec.Tag(name: tag.name, description: tag.description) + } + + var operations: [Operation] = [] + for (path, pathItemEither) in document.paths { + // Single self-contained specs carry path items inline; a referenced path + // item is out of scope for this blueprint and is skipped rather than guessed. + guard case .b(let pathItem) = pathItemEither else { continue } + for endpoint in pathItem.endpoints { + operations.append(Self.makeOperation(endpoint.operation, method: endpoint.method.rawValue, path: path.rawValue)) + } + } + + let schemas = document.components.schemas.map { entry in + SchemaObject(name: entry.key.rawValue, schema: Self.makeSchema(entry.value)) + } + + return OpenAPISpec(info: info, servers: servers, tags: tags, operations: operations, schemas: schemas) + } + + private static func makeOperation(_ operation: OpenAPIKit.OpenAPI.Operation, method: String, path: String) -> Operation { + Operation( + method: method, + path: path, + operationId: operation.operationId, + summary: operation.summary, + description: operation.description, + tags: operation.tags ?? [], + parameters: operation.parameters.compactMap { Self.makeParameter($0) }, + requestBody: operation.requestBody.flatMap { Self.makeRequestBody($0) }, + responses: Self.makeResponses(operation.responses), + security: Self.makeSecurity(operation.security), + deprecated: operation.deprecated + ) + } + + private static func makeParameter( + _ parameterEither: Either, OpenAPIKit.OpenAPI.Parameter> + ) -> Parameter? { + guard case .b(let parameter) = parameterEither else { return nil } + + let schema: SchemaNode? + switch parameter.schemaOrContent { + case .a(let schemaContext): + schema = Self.makeSchema(from: schemaContext.schema) + case .b(let contentMap): + schema = Self.makeContent(contentMap).first?.schema + } + + return Parameter( + name: parameter.name, + location: Parameter.Location(rawValue: parameter.location.rawValue), + description: parameter.description, + required: parameter.required, + deprecated: parameter.deprecated, + schema: schema + ) + } + + private static func makeRequestBody(_ requestEither: Either, OpenAPIKit.OpenAPI.Request>) + -> RequestBody? + { + guard case .b(let request) = requestEither else { return nil } + return RequestBody( + description: request.description, + required: request.required, + content: Self.makeContent(request.content) + ) + } + + private static func makeResponses(_ responses: OpenAPIKit.OpenAPI.Response.Map) -> [Response] { + responses.map { entry in + let response: OpenAPIKit.OpenAPI.Response? + if case .b(let value) = entry.value { response = value } else { response = nil } + return Response( + statusCode: entry.key.rawValue, + description: response?.description, + content: response.map { Self.makeContent($0.content) } ?? [] + ) + } + } + + private static func makeContent(_ content: OpenAPIKit.OpenAPI.Content.Map) -> [MediaType] { + content.map { entry in + let value: OpenAPIKit.OpenAPI.Content? + if case .b(let inline) = entry.value { value = inline } else { value = nil } + return MediaType(contentType: entry.key.rawValue, schema: value?.schema.map { Self.makeSchema($0) }) + } + } + + private static func makeSecurity(_ security: [OpenAPIKit.OpenAPI.SecurityRequirement]?) -> [SecurityRequirement] { + (security ?? []).map { requirement in + let schemes = + requirement + .compactMap { reference, scopes -> SecurityRequirement.SchemeRequirement? in + guard let name = reference.name else { return nil } + return SecurityRequirement.SchemeRequirement(name: name, scopes: scopes) + } + // A requirement's schemes come from a dictionary (no inherent order); + // sort by name so the rendered output is deterministic build to build. + .sorted { $0.name < $1.name } + return SecurityRequirement(schemes: schemes) + } + } + + // MARK: - Schema mapping + + /// Flattens an inline-or-referenced schema, preserving a top-level `$ref` by name. + private static func makeSchema(from schemaEither: Either, JSONSchema>) -> SchemaNode { + switch schemaEither { + case .a(let reference): + return SchemaNode(referenceName: reference.name) + case .b(let schema): + return Self.makeSchema(schema) + } + } + + /// Flattens an OpenAPIKit `JSONSchema` into the OpenAPIKit-free ``SchemaNode``. + private static func makeSchema(_ schema: JSONSchema) -> SchemaNode { + let title = schema.title + let description = schema.description + let deprecated = schema.deprecated + let nullable = schema.nullable + + switch schema.value { + case .reference(let reference, _): + return SchemaNode( + title: title, + description: description, + referenceName: reference.name, + deprecated: deprecated, + nullable: nullable + ) + + case .object(_, let context): + let properties = context.properties.map { entry in + SchemaProperty( + name: entry.key, + required: context.requiredProperties.contains(entry.key), + schema: Self.makeSchema(entry.value) + ) + } + return SchemaNode( + type: "object", + title: title, + description: description, + required: context.requiredProperties, + properties: properties, + deprecated: deprecated, + nullable: nullable + ) + + case .array(_, let context): + let items = context.items.map { [Self.makeSchema($0)] } ?? [] + return SchemaNode( + type: "array", + format: schema.formatString, + title: title, + description: description, + items: items, + deprecated: deprecated, + nullable: nullable + ) + + case .all(of: let subschemas, _): + return Self.makeComposition(.allOf, subschemas, title: title, description: description, deprecated: deprecated, nullable: nullable) + case .one(of: let subschemas, _): + return Self.makeComposition(.oneOf, subschemas, title: title, description: description, deprecated: deprecated, nullable: nullable) + case .any(of: let subschemas, _): + return Self.makeComposition(.anyOf, subschemas, title: title, description: description, deprecated: deprecated, nullable: nullable) + + default: + // Scalars (string / integer / number / boolean / null) plus `.not` and + // untyped `.fragment`: carry the type, format, and enum values where present. + return SchemaNode( + type: schema.jsonType?.rawValue, + format: schema.formatString, + title: title, + description: description, + enumValues: Self.makeEnumValues(schema.allowedValues), + deprecated: deprecated, + nullable: nullable + ) + } + } + + private static func makeComposition( + _ kind: Composition.Kind, + _ subschemas: [JSONSchema], + title: String?, + description: String?, + deprecated: Bool, + nullable: Bool + ) -> SchemaNode { + SchemaNode( + title: title, + description: description, + composition: Composition(kind: kind, subschemas: subschemas.map { Self.makeSchema($0) }), + deprecated: deprecated, + nullable: nullable + ) + } + + private static func makeEnumValues(_ values: [AnyCodable]?) -> [String] { + (values ?? []).map { value in + if let string = value.value as? String { return string } + return String(describing: value.value) + } + } +} diff --git a/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift b/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift new file mode 100644 index 0000000..188af74 --- /dev/null +++ b/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift @@ -0,0 +1,72 @@ +import Foundation +import SiteKit + +extension SiteBuilder { + /// OpenAPI documentation site: renders an OpenAPI 3.0/3.1 spec (YAML or JSON) + /// into a multi-page, style-conforming API-documentation site. + /// + /// The spec is discovered by convention at `Content/openapi.yaml` (falling + /// back to `openapi.yml` / `openapi.json`), or pointed at explicitly with + /// `specPath`. It is loaded and validated up front so a missing or malformed + /// document surfaces immediately rather than producing a half-built site. + /// + /// Like `.docc(...)`, the blueprint brings its own shell and reads the token + /// CSS variables, so all color schemes work and no layout is touched. The + /// page renderers that turn the loaded ``OpenAPISpec`` into landing, tag, + /// operation, and schema pages are composed in a later slice; this factory + /// wires spec discovery, the loader, and the content-independent system + /// renderers (sitemap, robots, CSS, favicons, llms.txt). + /// + /// - Parameters: + /// - config: The site configuration. + /// - projectDirectory: The site's root directory (holds `Content/`). + /// - cleanBeforeBuild: Whether to wipe the output directory first. + /// - specPath: An explicit spec location relative to `projectDirectory`, + /// overriding the conventional `Content/openapi.yaml` discovery. + public static func openAPI( + config: SiteConfig, + projectDirectory: URL, + cleanBeforeBuild: Bool = true, + specPath: String? = nil + ) -> SiteBuilder { + // Discover and load the spec now so discovery + decoding are exercised at + // compose time and a bad spec fails loud and early. The loaded model is the + // contract the page renderers consume once they land in a later slice. + if let specURL = Self.resolveSpecURL(specPath: specPath, config: config, projectDirectory: projectDirectory) { + do { + _ = try OpenAPISpecLoader().load(source: specURL) + } catch { + print("[SiteKit] Warning: OpenAPI spec at '\(specURL.path)' could not be loaded – \(error)") + } + } else { + print( + "[SiteKit] Warning: no OpenAPI spec found (looked for '\(config.contentDirectory)/openapi.yaml', '.yml', '.json'). The OpenAPI blueprint needs a spec file." + ) + } + + return SiteBuilder(config: config, projectDirectory: projectDirectory) + .cleanBeforeBuild(cleanBeforeBuild) + .renderer(SitemapRenderer()) + .renderer(RobotsTxtRenderer()) + .renderer(TokenCSSOutputRenderer()) + .renderer(BaseCSSOutputRenderer()) + .renderer(FontsFaceCSSRenderer()) + .renderer(CloudflareHeadersRenderer()) + .renderer(FaviconRenderer()) + .renderer(LlmsTxtRenderer()) + } + + /// Resolves the spec file URL: the explicit `specPath` (relative to the + /// project root) when given, otherwise the first existing conventional + /// candidate under the content directory. Returns `nil` when no spec exists. + static func resolveSpecURL(specPath: String?, config: SiteConfig, projectDirectory: URL) -> URL? { + if let specPath { + return projectDirectory.appendingPathComponent(specPath) + } + + let contentDirectory = projectDirectory.appendingPathComponent(config.contentDirectory) + let candidates = ["openapi.yaml", "openapi.yml", "openapi.json"] + .map { contentDirectory.appendingPathComponent($0) } + return candidates.first { FileManager.default.fileExists(atPath: $0.path) } + } +} diff --git a/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.json b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.json new file mode 100644 index 0000000..1726472 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.json @@ -0,0 +1,140 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Swagger Petstore", + "version": "1.0.0", + "description": "A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification." + }, + "servers": [ + { + "url": "https://petstore.swagger.io/v1", + "description": "Production server" + } + ], + "tags": [ + { + "name": "pets", + "description": "Everything about your Pets" + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { "type": "integer", "format": "int32" } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pets" } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": ["pets"], + "requestBody": { + "description": "The pet to create", + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "responses": { + "201": { "description": "Null response" }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "Pets": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" } + } + } + } + } +} diff --git a/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.yaml b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.yaml new file mode 100644 index 0000000..91435c9 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.yaml @@ -0,0 +1,116 @@ +openapi: 3.0.0 +info: + title: Swagger Petstore + version: 1.0.0 + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification. +servers: + - url: https://petstore.swagger.io/v1 + description: Production server +tags: + - name: pets + description: Everything about your Pets +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + content: + application/json: + schema: + $ref: '#/components/schemas/Pets' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Create a pet + operationId: createPets + tags: + - pets + requestBody: + description: The pet to create + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.json b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.json new file mode 100644 index 0000000..901d780 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.json @@ -0,0 +1,140 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Swagger Petstore", + "version": "1.0.0", + "description": "A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification." + }, + "servers": [ + { + "url": "https://petstore.swagger.io/v1", + "description": "Production server" + } + ], + "tags": [ + { + "name": "pets", + "description": "Everything about your Pets" + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { "type": "integer", "format": "int32" } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pets" } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": ["pets"], + "requestBody": { + "description": "The pet to create", + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "responses": { + "201": { "description": "Null response" }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "Pets": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" } + } + } + } + } +} diff --git a/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.yaml new file mode 100644 index 0000000..a493037 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.yaml @@ -0,0 +1,116 @@ +openapi: 3.1.0 +info: + title: Swagger Petstore + version: 1.0.0 + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification. +servers: + - url: https://petstore.swagger.io/v1 + description: Production server +tags: + - name: pets + description: Everything about your Pets +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + content: + application/json: + schema: + $ref: '#/components/schemas/Pets' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Create a pet + operationId: createPets + tags: + - pets + requestBody: + description: The pet to create + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift b/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift new file mode 100644 index 0000000..f9ec906 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing + +@testable import SiteKitOpenAPI + +@Suite("OpenAPISpecLoader") +struct OpenAPISpecLoaderTests { + /// One fixture in the 2×2 decode matrix: an OpenAPI major version crossed with a serialization format. + struct Fixture: Sendable, CustomStringConvertible { + let name: String + let fileExtension: String + var description: String { "\(self.name).\(self.fileExtension)" } + } + + /// The full matrix: OpenAPI 3.0 and 3.1, each as YAML and JSON. Every fixture is the same logical + /// Petstore, so all four must decode into an identical model – which is itself the proof that the + /// 3.0 (via OpenAPIKitCompat conversion) and 3.1 (direct) paths normalize to one 3.1 shape. + static let fixtures: [Fixture] = [ + Fixture(name: "petstore-3.0", fileExtension: "yaml"), + Fixture(name: "petstore-3.0", fileExtension: "json"), + Fixture(name: "petstore-3.1", fileExtension: "yaml"), + Fixture(name: "petstore-3.1", fileExtension: "json"), + ] + + private func loadSpec(_ fixture: Fixture) throws -> OpenAPISpec { + let url = try #require( + Bundle.module.url(forResource: fixture.name, withExtension: fixture.fileExtension, subdirectory: "Fixtures"), + "Missing fixture \(fixture)" + ) + return try OpenAPISpecLoader().load(source: url) + } + + @Test("Decodes the info block", arguments: fixtures) + func info(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + #expect(spec.info.title == "Swagger Petstore") + #expect(spec.info.version == "1.0.0") + #expect(spec.info.description?.contains("sample API") == true) + } + + @Test("Decodes the server list", arguments: fixtures) + func servers(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + #expect(spec.servers.count == 1) + let server = try #require(spec.servers.first) + #expect(server.url == "https://petstore.swagger.io/v1") + #expect(server.description == "Production server") + } + + @Test("Decodes the tag list", arguments: fixtures) + func tags(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + #expect(spec.tags.count == 1) + let tag = try #require(spec.tags.first) + #expect(tag.name == "pets") + #expect(tag.description == "Everything about your Pets") + } + + @Test("Flattens every path/method into one operation list", arguments: fixtures) + func operationCount(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + // GET /pets, POST /pets, GET /pets/{petId} + #expect(spec.operations.count == 3) + } + + @Test("Maps a known operation's method, path, tags, parameters, and responses", arguments: fixtures) + func knownOperation(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + let operation = try #require( + spec.operations.first { $0.method == "GET" && $0.path == "/pets/{petId}" }, + "Missing GET /pets/{petId}" + ) + #expect(operation.operationId == "showPetById") + #expect(operation.summary == "Info for a specific pet") + #expect(operation.tags == ["pets"]) + #expect(operation.deprecated == false) + + let parameter = try #require(operation.parameters.first { $0.name == "petId" }) + #expect(parameter.location == .path) + #expect(parameter.required == true) + #expect(parameter.schema?.type == "string") + + let statusCodes = operation.responses.map(\.statusCode) + #expect(statusCodes.contains("200")) + #expect(statusCodes.contains("default")) + let okResponse = try #require(operation.responses.first { $0.statusCode == "200" }) + #expect(okResponse.content.first?.contentType == "application/json") + #expect(okResponse.content.first?.schema?.referenceName == "Pet") + } + + @Test("Maps an operation's request body", arguments: fixtures) + func requestBody(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + let operation = try #require(spec.operations.first { $0.method == "POST" && $0.path == "/pets" }) + let body = try #require(operation.requestBody) + #expect(body.required == true) + #expect(body.content.first?.contentType == "application/json") + #expect(body.content.first?.schema?.referenceName == "Pet") + } + + @Test("Maps component schemas with properties and required fields", arguments: fixtures) + func schemas(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + let names = spec.schemas.map(\.name) + #expect(names.contains("Pet")) + #expect(names.contains("Pets")) + #expect(names.contains("Error")) + + let pet = try #require(spec.schemas.first { $0.name == "Pet" }) + #expect(pet.schema.type == "object") + #expect(pet.schema.required.contains("id")) + #expect(pet.schema.required.contains("name")) + let nameProperty = try #require(pet.schema.properties.first { $0.name == "name" }) + #expect(nameProperty.schema.type == "string") + #expect(nameProperty.required == true) + let idProperty = try #require(pet.schema.properties.first { $0.name == "id" }) + #expect(idProperty.schema.format == "int64") + + let pets = try #require(spec.schemas.first { $0.name == "Pets" }) + #expect(pets.schema.type == "array") + #expect(pets.schema.items.first?.referenceName == "Pet") + } + + @Test("Rejects an unsupported OpenAPI major version") + func unsupportedVersion() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = directory.appendingPathComponent("unsupported-\(UUID().uuidString).yaml") + let swagger2 = """ + swagger: '2.0' + openapi: '2.0' + info: + title: Legacy + version: 1.0.0 + paths: {} + """ + try swagger2.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: OpenAPISpecLoader.LoadError.self) { + try OpenAPISpecLoader().load(source: url) + } + } +} From eefded69e06b391a9ddee62f80570df578048581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Sat, 20 Jun 2026 08:48:52 +0200 Subject: [PATCH 02/23] Nest the OpenAPI model under OpenAPISpec and harden the loader Namespace every model type under OpenAPISpec to match Info/Server/Tag and, in particular, to stop OpenAPISpec.Operation from shadowing Foundation.Operation for a consumer that imports both. Operation, Parameter, RequestBody, MediaType, Response and SecurityRequirement (and the schema types SchemaObject, SchemaNode, SchemaProperty, Composition) move from top-level symbols into the OpenAPISpec namespace; the loader and tests are updated to the qualified names. This is a published API, so the rename lands now rather than after consumers reference the bare names. Make the version probe's openapi field optional so a document that omits it (a real Swagger 2.0 file) is rejected with the precise LoadError.unsupportedVersion rather than a raw decoding error, and correct the load doc comment about which errors name the file. Capture a oneOf/anyOf discriminator (propertyName plus any value mapping) on the composition model, and unify $ref handling so a referenced response or media-type entry is dropped consistently with referenced parameters and request bodies instead of emitting a degenerate empty entry. Soften the factory doc comment to describe the actual warn-and-continue behavior, mark the deferred decisions with notes, and make resolveSpecURL private. Add loader tests for the previously uncovered branches: the nullable normalization that proves 3.0 nullable:true and 3.1 ["T","null"] converge to one identical node, enum values, schema-level deprecated, oneOf with a discriminator, and the error paths (Swagger 2.0, empty, malformed, missing file). --- Sources/SiteKitOpenAPI/OpenAPISchema.swift | 290 +++++++------- Sources/SiteKitOpenAPI/OpenAPISpec.swift | 355 +++++++++--------- .../SiteKitOpenAPI/OpenAPISpecLoader.swift | 175 ++++++--- .../SiteKitOpenAPI/SiteBuilder+OpenAPI.swift | 16 +- .../Fixtures/features-3.0.yaml | 43 +++ .../Fixtures/features-3.1.yaml | 42 +++ .../Fixtures/swagger-2.0.yaml | 27 ++ .../OpenAPISpecLoaderTests.swift | 131 ++++++- 8 files changed, 694 insertions(+), 385 deletions(-) create mode 100644 Tests/SiteKitOpenAPITests/Fixtures/features-3.0.yaml create mode 100644 Tests/SiteKitOpenAPITests/Fixtures/features-3.1.yaml create mode 100644 Tests/SiteKitOpenAPITests/Fixtures/swagger-2.0.yaml diff --git a/Sources/SiteKitOpenAPI/OpenAPISchema.swift b/Sources/SiteKitOpenAPI/OpenAPISchema.swift index 8354d9b..e400af8 100644 --- a/Sources/SiteKitOpenAPI/OpenAPISchema.swift +++ b/Sources/SiteKitOpenAPI/OpenAPISchema.swift @@ -1,147 +1,169 @@ import Foundation -/// One named schema from the spec's `components/schemas` section. -/// -/// The `name` is the component key (for example `Pet`), which doubles as the -/// slug for the schema's documentation page; `schema` is the flattened, -/// OpenAPIKit-free description the renderers walk. -public struct SchemaObject: Sendable, Equatable { - /// The component key, for example `Pet` for `#/components/schemas/Pet`. - public let name: String - - /// The flattened schema description. - public let schema: SchemaNode - - /// Memberwise initializer. - public init(name: String, schema: SchemaNode) { - self.name = name - self.schema = schema +extension OpenAPISpec { + /// One named schema from the spec's `components/schemas` section. + /// + /// The `name` is the component key (for example `Pet`), which doubles as the + /// slug for the schema's documentation page; `schema` is the flattened, + /// OpenAPIKit-free description the renderers walk. + public struct SchemaObject: Sendable, Equatable { + /// The component key, for example `Pet` for `#/components/schemas/Pet`. + public let name: String + + /// The flattened schema description. + public let schema: SchemaNode + + /// Memberwise initializer. + public init(name: String, schema: SchemaNode) { + self.name = name + self.schema = schema + } } -} -/// A flattened, render-ready description of a JSON Schema node. -/// -/// This is deliberately decoupled from OpenAPIKit's `JSONSchema` so the page -/// renderers never need to `import OpenAPIKit`. It captures the facets a docs -/// renderer cares about (type, format, the object's properties, an array's -/// element schema, `$ref` targets, enum values, composition) and flattens the -/// rest. Recursion runs through arrays (`properties`, `items`, `composition`) -/// so the value type stays a plain `struct` without boxing. -public struct SchemaNode: Sendable, Equatable { - /// The JSON Schema `type` keyword (`object`, `array`, `string`, `integer`, - /// `number`, `boolean`, `null`), or `nil` for a reference, a composition, or - /// an untyped fragment. - public let type: String? - - /// The `format` keyword refining `type` (for example `int64`, `date-time`). - public let format: String? - - /// The schema's `title`, if any. - public let title: String? - - /// The schema's `description`, if any. - public let description: String? - - /// The names of the required properties (object schemas only). - public let required: [String] - - /// The object's properties in declaration order (object schemas only). - public let properties: [SchemaProperty] - - /// The array element schema, in a zero-or-one-element array (array schemas - /// only). An array is used instead of an optional so the value type need not - /// be `indirect`. - public let items: [SchemaNode] - - /// The allowed values of an `enum` schema, rendered to their string form. - public let enumValues: [String] - - /// The local `$ref` target name (for example `Pet` for - /// `#/components/schemas/Pet`) when this node is a reference, else `nil`. - public let referenceName: String? - - /// The `allOf` / `oneOf` / `anyOf` composition this node represents, if any. - public let composition: Composition? - - /// Whether the schema is marked `deprecated`. - public let deprecated: Bool - - /// Whether the schema is nullable (the 3.0 `nullable: true` flag, normalized - /// from a `["T", "null"]` type array by OpenAPIKit on 3.1 documents). - public let nullable: Bool - - /// Memberwise initializer. Every field defaults to its empty value so a - /// renderer can construct a partial node without restating the whole shape. - public init( - type: String? = nil, - format: String? = nil, - title: String? = nil, - description: String? = nil, - required: [String] = [], - properties: [SchemaProperty] = [], - items: [SchemaNode] = [], - enumValues: [String] = [], - referenceName: String? = nil, - composition: Composition? = nil, - deprecated: Bool = false, - nullable: Bool = false - ) { - self.type = type - self.format = format - self.title = title - self.description = description - self.required = required - self.properties = properties - self.items = items - self.enumValues = enumValues - self.referenceName = referenceName - self.composition = composition - self.deprecated = deprecated - self.nullable = nullable + /// A flattened, render-ready description of a JSON Schema node. + /// + /// This is deliberately decoupled from OpenAPIKit's `JSONSchema` so the page + /// renderers never need to `import OpenAPIKit`. It captures the facets a docs + /// renderer cares about (type, format, the object's properties, an array's + /// element schema, `$ref` targets, enum values, composition) and flattens the + /// rest. Recursion runs through arrays (`properties`, `items`, `composition`) + /// so the value type stays a plain `struct` without boxing. + public struct SchemaNode: Sendable, Equatable { + /// The JSON Schema `type` keyword (`object`, `array`, `string`, `integer`, + /// `number`, `boolean`, `null`), or `nil` for a reference, a composition, or + /// an untyped fragment. + public let type: String? + + /// The `format` keyword refining `type` (for example `int64`, `date-time`). + public let format: String? + + /// The schema's `title`, if any. + public let title: String? + + /// The schema's `description`, if any. + public let description: String? + + /// The names of the required properties (object schemas only). + public let required: [String] + + /// The object's properties in declaration order (object schemas only). + public let properties: [SchemaProperty] + + /// The array element schema, in a zero-or-one-element array (array schemas + /// only). An array is used instead of an optional so the value type need not + /// be `indirect`. + public let items: [SchemaNode] + + /// The allowed values of an `enum` schema, rendered to their string form. + public let enumValues: [String] + + /// The local `$ref` target name (for example `Pet` for + /// `#/components/schemas/Pet`) when this node is a reference, else `nil`. + public let referenceName: String? + + /// The `allOf` / `oneOf` / `anyOf` composition this node represents, if any. + public let composition: Composition? + + /// Whether the schema is marked `deprecated`. + public let deprecated: Bool + + /// Whether the schema is nullable (the 3.0 `nullable: true` flag, normalized + /// from a `["T", "null"]` type array by OpenAPIKit on 3.1 documents). + public let nullable: Bool + + /// Memberwise initializer. Every field defaults to its empty value so a + /// renderer can construct a partial node without restating the whole shape. + public init( + type: String? = nil, + format: String? = nil, + title: String? = nil, + description: String? = nil, + required: [String] = [], + properties: [SchemaProperty] = [], + items: [SchemaNode] = [], + enumValues: [String] = [], + referenceName: String? = nil, + composition: Composition? = nil, + deprecated: Bool = false, + nullable: Bool = false + ) { + self.type = type + self.format = format + self.title = title + self.description = description + self.required = required + self.properties = properties + self.items = items + self.enumValues = enumValues + self.referenceName = referenceName + self.composition = composition + self.deprecated = deprecated + self.nullable = nullable + } } -} -/// One property of an object schema: its name, whether it is required, and the -/// flattened schema describing its value. -public struct SchemaProperty: Sendable, Equatable { - /// The property name as it appears in the object. - public let name: String + /// One property of an object schema: its name, whether it is required, and the + /// flattened schema describing its value. + public struct SchemaProperty: Sendable, Equatable { + /// The property name as it appears in the object. + public let name: String - /// Whether the parent object lists this property in its `required` array. - public let required: Bool + /// Whether the parent object lists this property in its `required` array. + public let required: Bool - /// The flattened schema of the property's value. - public let schema: SchemaNode + /// The flattened schema of the property's value. + public let schema: SchemaNode - /// Memberwise initializer. - public init(name: String, required: Bool, schema: SchemaNode) { - self.name = name - self.required = required - self.schema = schema + /// Memberwise initializer. + public init(name: String, required: Bool, schema: SchemaNode) { + self.name = name + self.required = required + self.schema = schema + } } -} - -/// An `allOf` / `oneOf` / `anyOf` schema composition and its member schemas. -public struct Composition: Sendable, Equatable { - /// Which JSON Schema composition keyword produced this node. - public enum Kind: String, Sendable, Equatable { - /// `allOf` – the value must satisfy every member schema. - case allOf - /// `oneOf` – the value must satisfy exactly one member schema. - case oneOf - /// `anyOf` – the value must satisfy at least one member schema. - case anyOf - } - - /// The composition keyword. - public let kind: Kind - - /// The member schemas being composed, in declaration order. - public let subschemas: [SchemaNode] - /// Memberwise initializer. - public init(kind: Kind, subschemas: [SchemaNode]) { - self.kind = kind - self.subschemas = subschemas + /// An `allOf` / `oneOf` / `anyOf` schema composition and its member schemas. + public struct Composition: Sendable, Equatable { + /// Which JSON Schema composition keyword produced this node. + public enum Kind: String, Sendable, Equatable { + /// `allOf` – the value must satisfy every member schema. + case allOf + /// `oneOf` – the value must satisfy exactly one member schema. + case oneOf + /// `anyOf` – the value must satisfy at least one member schema. + case anyOf + } + + /// The `discriminator` of a polymorphic `oneOf` / `anyOf`: the property whose + /// value selects the concrete variant, plus any explicit value→schema mapping. + public struct Discriminator: Sendable, Equatable { + /// The name of the property that selects the variant (for example `petType`). + public let propertyName: String + + /// Explicit mappings from a discriminator value to a schema name, if declared. + public let mapping: [String: String] + + /// Memberwise initializer. + public init(propertyName: String, mapping: [String: String] = [:]) { + self.propertyName = propertyName + self.mapping = mapping + } + } + + /// The composition keyword. + public let kind: Kind + + /// The member schemas being composed, in declaration order. + public let subschemas: [SchemaNode] + + /// The discriminator selecting the variant, for a polymorphic `oneOf` / `anyOf`. + public let discriminator: Discriminator? + + /// Memberwise initializer. + public init(kind: Kind, subschemas: [SchemaNode], discriminator: Discriminator? = nil) { + self.kind = kind + self.subschemas = subschemas + self.discriminator = discriminator + } } } diff --git a/Sources/SiteKitOpenAPI/OpenAPISpec.swift b/Sources/SiteKitOpenAPI/OpenAPISpec.swift index b60fe5f..443f5f9 100644 --- a/Sources/SiteKitOpenAPI/OpenAPISpec.swift +++ b/Sources/SiteKitOpenAPI/OpenAPISpec.swift @@ -7,6 +7,11 @@ import Foundation /// value model. The model is intentionally decoupled from OpenAPIKit so the page /// renderers (landing, tag, operation, schema) read only SiteKit-owned types and /// never `import OpenAPIKit`. This is the contract every OpenAPI renderer builds on. +/// +/// Every type in the model is nested under `OpenAPISpec` (for example +/// ``OpenAPISpec/Operation`` and ``OpenAPISpec/SchemaNode``). The namespace keeps +/// the surface predictable and, in particular, keeps ``OpenAPISpec/Operation`` +/// from shadowing `Foundation.Operation` for a consumer that imports both. public struct OpenAPISpec: Sendable, Equatable { /// The document's `info` block: title, version, description. public let info: Info @@ -94,223 +99,225 @@ public struct OpenAPISpec: Sendable, Equatable { } } -/// One operation: an HTTP method on a path plus its documented metadata. -public struct Operation: Sendable, Equatable { - /// The uppercased HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, …). - public let method: String +extension OpenAPISpec { + /// One operation: an HTTP method on a path plus its documented metadata. + public struct Operation: Sendable, Equatable { + /// The uppercased HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, …). + public let method: String - /// The templated path the operation lives under (for example `/pets/{id}`). - public let path: String + /// The templated path the operation lives under (for example `/pets/{id}`). + public let path: String - /// The operation's stable `operationId`, if provided. - public let operationId: String? + /// The operation's stable `operationId`, if provided. + public let operationId: String? - /// The operation's short summary, if provided. - public let summary: String? + /// The operation's short summary, if provided. + public let summary: String? - /// The operation's longer description (Markdown), if provided. - public let description: String? + /// The operation's longer description (Markdown), if provided. + public let description: String? - /// The tags this operation belongs to, in document order. - public let tags: [String] + /// The tags this operation belongs to, in document order. + public let tags: [String] - /// The operation's parameters (path, query, header, cookie), in document order. - public let parameters: [Parameter] + /// The operation's parameters (path, query, header, cookie), in document order. + public let parameters: [Parameter] - /// The operation's request body, if it declares one. - public let requestBody: RequestBody? + /// The operation's request body, if it declares one. + public let requestBody: RequestBody? - /// The operation's responses keyed by status, in document order. - public let responses: [Response] + /// The operation's responses keyed by status, in document order. + public let responses: [Response] - /// The operation's security requirements (an OR of requirement sets, each an - /// AND of named schemes), in document order. - public let security: [SecurityRequirement] + /// The operation's security requirements (an OR of requirement sets, each an + /// AND of named schemes), in document order. + public let security: [SecurityRequirement] - /// Whether the operation is marked `deprecated`. - public let deprecated: Bool + /// Whether the operation is marked `deprecated`. + public let deprecated: Bool - /// Memberwise initializer. - public init( - method: String, - path: String, - operationId: String? = nil, - summary: String? = nil, - description: String? = nil, - tags: [String] = [], - parameters: [Parameter] = [], - requestBody: RequestBody? = nil, - responses: [Response] = [], - security: [SecurityRequirement] = [], - deprecated: Bool = false - ) { - self.method = method - self.path = path - self.operationId = operationId - self.summary = summary - self.description = description - self.tags = tags - self.parameters = parameters - self.requestBody = requestBody - self.responses = responses - self.security = security - self.deprecated = deprecated + /// Memberwise initializer. + public init( + method: String, + path: String, + operationId: String? = nil, + summary: String? = nil, + description: String? = nil, + tags: [String] = [], + parameters: [Parameter] = [], + requestBody: RequestBody? = nil, + responses: [Response] = [], + security: [SecurityRequirement] = [], + deprecated: Bool = false + ) { + self.method = method + self.path = path + self.operationId = operationId + self.summary = summary + self.description = description + self.tags = tags + self.parameters = parameters + self.requestBody = requestBody + self.responses = responses + self.security = security + self.deprecated = deprecated + } } -} -/// One operation parameter (path, query, header, or cookie). -public struct Parameter: Sendable, Equatable { - /// Where a parameter is carried in the request. - public enum Location: Sendable, Equatable { - /// A query-string parameter (`?name=…`). - case query - /// A path parameter (a `{name}` segment). - case path - /// A header parameter. - case header - /// A cookie parameter. - case cookie - /// Any other (forward-compatible) location, carrying its raw spec value. - case other(String) - - /// The lowercase spec string for this location (`query`, `path`, …). - public var rawValue: String { - switch self { - case .query: "query" - case .path: "path" - case .header: "header" - case .cookie: "cookie" - case .other(let value): value + /// One operation parameter (path, query, header, or cookie). + public struct Parameter: Sendable, Equatable { + /// Where a parameter is carried in the request. + public enum Location: Sendable, Equatable { + /// A query-string parameter (`?name=…`). + case query + /// A path parameter (a `{name}` segment). + case path + /// A header parameter. + case header + /// A cookie parameter. + case cookie + /// Any other (forward-compatible) location, carrying its raw spec value. + case other(String) + + /// The lowercase spec string for this location (`query`, `path`, …). + public var rawValue: String { + switch self { + case .query: "query" + case .path: "path" + case .header: "header" + case .cookie: "cookie" + case .other(let value): value + } } - } - /// Maps a raw OpenAPI location string to a `Location`, preserving unknown - /// values via `.other` rather than dropping them. - public init(rawValue: String) { - switch rawValue { - case "query": self = .query - case "path": self = .path - case "header": self = .header - case "cookie": self = .cookie - default: self = .other(rawValue) + /// Maps a raw OpenAPI location string to a `Location`, preserving unknown + /// values via `.other` rather than dropping them. + public init(rawValue: String) { + switch rawValue { + case "query": self = .query + case "path": self = .path + case "header": self = .header + case "cookie": self = .cookie + default: self = .other(rawValue) + } } } - } - /// The parameter name. - public let name: String + /// The parameter name. + public let name: String - /// Where the parameter is carried. - public let location: Location + /// Where the parameter is carried. + public let location: Location - /// The parameter's description (Markdown), if provided. - public let description: String? + /// The parameter's description (Markdown), if provided. + public let description: String? - /// Whether the parameter is required. Path parameters are always required. - public let required: Bool + /// Whether the parameter is required. Path parameters are always required. + public let required: Bool - /// Whether the parameter is marked `deprecated`. - public let deprecated: Bool + /// Whether the parameter is marked `deprecated`. + public let deprecated: Bool - /// The flattened schema describing the parameter's value, if it declares one. - public let schema: SchemaNode? + /// The flattened schema describing the parameter's value, if it declares one. + public let schema: SchemaNode? - /// Memberwise initializer. - public init( - name: String, - location: Location, - description: String? = nil, - required: Bool = false, - deprecated: Bool = false, - schema: SchemaNode? = nil - ) { - self.name = name - self.location = location - self.description = description - self.required = required - self.deprecated = deprecated - self.schema = schema + /// Memberwise initializer. + public init( + name: String, + location: Location, + description: String? = nil, + required: Bool = false, + deprecated: Bool = false, + schema: SchemaNode? = nil + ) { + self.name = name + self.location = location + self.description = description + self.required = required + self.deprecated = deprecated + self.schema = schema + } } -} -/// An operation's request body. -public struct RequestBody: Sendable, Equatable { - /// The request body's description (Markdown), if provided. - public let description: String? + /// An operation's request body. + public struct RequestBody: Sendable, Equatable { + /// The request body's description (Markdown), if provided. + public let description: String? - /// Whether the request body is required. - public let required: Bool + /// Whether the request body is required. + public let required: Bool - /// The body's representations keyed by media type, in document order. - public let content: [MediaType] + /// The body's representations keyed by media type, in document order. + public let content: [MediaType] - /// Memberwise initializer. - public init(description: String? = nil, required: Bool = false, content: [MediaType] = []) { - self.description = description - self.required = required - self.content = content + /// Memberwise initializer. + public init(description: String? = nil, required: Bool = false, content: [MediaType] = []) { + self.description = description + self.required = required + self.content = content + } } -} -/// One media-type representation of a request or response body (for example -/// `application/json`) and its flattened schema. -public struct MediaType: Sendable, Equatable { - /// The media type string (for example `application/json`). - public let contentType: String + /// One media-type representation of a request or response body (for example + /// `application/json`) and its flattened schema. + public struct MediaType: Sendable, Equatable { + /// The media type string (for example `application/json`). + public let contentType: String - /// The flattened schema describing this representation, if it declares one. - public let schema: SchemaNode? + /// The flattened schema describing this representation, if it declares one. + public let schema: SchemaNode? - /// Memberwise initializer. - public init(contentType: String, schema: SchemaNode? = nil) { - self.contentType = contentType - self.schema = schema + /// Memberwise initializer. + public init(contentType: String, schema: SchemaNode? = nil) { + self.contentType = contentType + self.schema = schema + } } -} -/// One response of an operation, keyed by status. -public struct Response: Sendable, Equatable { - /// The status key as written in the spec (for example `200`, `404`, - /// `default`, or a `2XX` range). - public let statusCode: String + /// One response of an operation, keyed by status. + public struct Response: Sendable, Equatable { + /// The status key as written in the spec (for example `200`, `404`, + /// `default`, or a `2XX` range). + public let statusCode: String - /// The response's description, if provided. - public let description: String? + /// The response's description, if provided. + public let description: String? - /// The response body's representations keyed by media type, in document order. - public let content: [MediaType] + /// The response body's representations keyed by media type, in document order. + public let content: [MediaType] - /// Memberwise initializer. - public init(statusCode: String, description: String? = nil, content: [MediaType] = []) { - self.statusCode = statusCode - self.description = description - self.content = content + /// Memberwise initializer. + public init(statusCode: String, description: String? = nil, content: [MediaType] = []) { + self.statusCode = statusCode + self.description = description + self.content = content + } } -} -/// One security requirement: a set of named schemes that must ALL be satisfied -/// together. An operation's `security` list is the OR of these requirements. -public struct SecurityRequirement: Sendable, Equatable { - /// One named scheme reference inside a requirement, with its required scopes. - public struct SchemeRequirement: Sendable, Equatable { - /// The referenced `securityScheme` name from `components/securitySchemes`. - public let name: String + /// One security requirement: a set of named schemes that must ALL be satisfied + /// together. An operation's `security` list is the OR of these requirements. + public struct SecurityRequirement: Sendable, Equatable { + /// One named scheme reference inside a requirement, with its required scopes. + public struct SchemeRequirement: Sendable, Equatable { + /// The referenced `securityScheme` name from `components/securitySchemes`. + public let name: String + + /// The OAuth2 / OpenID-Connect scopes required, if any. + public let scopes: [String] + + /// Memberwise initializer. + public init(name: String, scopes: [String]) { + self.name = name + self.scopes = scopes + } + } - /// The OAuth2 / OpenID-Connect scopes required, if any. - public let scopes: [String] + /// The schemes that must all be satisfied for this requirement. + public let schemes: [SchemeRequirement] /// Memberwise initializer. - public init(name: String, scopes: [String]) { - self.name = name - self.scopes = scopes + public init(schemes: [SchemeRequirement]) { + self.schemes = schemes } } - - /// The schemes that must all be satisfied for this requirement. - public let schemes: [SchemeRequirement] - - /// Memberwise initializer. - public init(schemes: [SchemeRequirement]) { - self.schemes = schemes - } } diff --git a/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift b/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift index 0901574..645defc 100644 --- a/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift +++ b/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift @@ -1,4 +1,8 @@ import Foundation +// The target declares only the `OpenAPIKitCompat` product, but `OpenAPIKitCompat` re-exports +// `OpenAPIKit` (3.1), `OpenAPIKit30` (3.0), and `OpenAPIKitCore`, so all three resolve transitively +// and are imported explicitly here to name their types directly (the 3.0 document, the 3.1 document, +// and the shared `Either` / `AnyCodable` / `JSONSchema` types) and to make the dependency intent legible. import OpenAPIKit import OpenAPIKit30 import OpenAPIKitCompat @@ -49,9 +53,13 @@ public struct OpenAPISpecLoader: Loader { /// Decodes the document at `source` and projects it into an ``OpenAPISpec``. /// - /// Throws a decoding error (naming the file) when the document is malformed, - /// or ``LoadError/unsupportedVersion(_:)`` when the `openapi:` field is absent - /// or names a major version other than 3.0 / 3.1. + /// Throws: + /// - ``LoadError/unsupportedVersion(_:)`` when the `openapi:` field is absent + /// (for example a Swagger 2.0 document) or names a major version other than + /// 3.0 / 3.1; + /// - a file-read error – which does name the file – when `url` cannot be read; + /// - a `DecodingError` when the document is present but malformed (the decoder + /// error pinpoints the offending key/path, though not the file name). public func load(source url: URL) throws -> OpenAPISpec { let data = try Data(contentsOf: url) let isJSON = url.pathExtension.lowercased() == "json" @@ -82,20 +90,28 @@ public struct OpenAPISpecLoader: Loader { } /// A minimal probe that reads only the `openapi:` field so the right typed - /// decoder can be chosen before the full (version-specific) decode. + /// decoder can be chosen before the full (version-specific) decode. `openapi` + /// is optional so a document that omits it (for example Swagger 2.0, which uses + /// `swagger:` instead) decodes cleanly here and is rejected with a precise + /// ``LoadError/unsupportedVersion(_:)`` rather than a raw `DecodingError`. private struct VersionProbe: Decodable { - let openapi: String + let openapi: String? } /// Reads the `openapi:` field and maps it to a ``MajorVersion``. + /// + /// S2: this re-parses the whole document as `VersionProbe` before the real + /// decode (a second full parse). Fine for build-time specs; revisit only if it + /// shows up in profiles. private static func detectMajorVersion(data: Data, isJSON: Bool) throws -> MajorVersion { let probe = try decode(VersionProbe.self, from: data, isJSON: isJSON) - if probe.openapi.hasPrefix("3.1") { + let version = probe.openapi ?? "" + if version.hasPrefix("3.1") { return .v3_1 - } else if probe.openapi.hasPrefix("3.0") { + } else if version.hasPrefix("3.0") { return .v3_0 } else { - throw LoadError.unsupportedVersion(probe.openapi) + throw LoadError.unsupportedVersion(version) } } @@ -115,9 +131,10 @@ public struct OpenAPISpecLoader: Loader { /// Path items and the schemas, parameters, request bodies, and responses /// nested inside operations may each be either an inline value or a `$ref`. /// A single self-contained spec (the shape this blueprint supports) carries - /// them inline, so inline values are flattened in full and a top-level `$ref` - /// to a reusable component is preserved by name (``SchemaNode/referenceName``) - /// for the renderers to link. + /// them inline, so inline values are flattened in full; a `$ref` at the schema + /// level is preserved by name (``OpenAPISpec/SchemaNode/referenceName``) for + /// the renderers to link, and a `$ref` at the path-item / parameter / request- + /// body / response / content level is dropped consistently (see the helpers). private static func makeSpec(from document: OpenAPIKit.OpenAPI.Document) -> OpenAPISpec { let info = OpenAPISpec.Info( title: document.info.title, @@ -134,10 +151,11 @@ public struct OpenAPISpecLoader: Loader { OpenAPISpec.Tag(name: tag.name, description: tag.description) } - var operations: [Operation] = [] + var operations: [OpenAPISpec.Operation] = [] for (path, pathItemEither) in document.paths { // Single self-contained specs carry path items inline; a referenced path // item is out of scope for this blueprint and is skipped rather than guessed. + // S2: resolve in-file component references and stop dropping referenced nodes. guard case .b(let pathItem) = pathItemEither else { continue } for endpoint in pathItem.endpoints { operations.append(Self.makeOperation(endpoint.operation, method: endpoint.method.rawValue, path: path.rawValue)) @@ -145,14 +163,14 @@ public struct OpenAPISpecLoader: Loader { } let schemas = document.components.schemas.map { entry in - SchemaObject(name: entry.key.rawValue, schema: Self.makeSchema(entry.value)) + OpenAPISpec.SchemaObject(name: entry.key.rawValue, schema: Self.makeSchema(entry.value)) } return OpenAPISpec(info: info, servers: servers, tags: tags, operations: operations, schemas: schemas) } - private static func makeOperation(_ operation: OpenAPIKit.OpenAPI.Operation, method: String, path: String) -> Operation { - Operation( + private static func makeOperation(_ operation: OpenAPIKit.OpenAPI.Operation, method: String, path: String) -> OpenAPISpec.Operation { + OpenAPISpec.Operation( method: method, path: path, operationId: operation.operationId, @@ -167,12 +185,15 @@ public struct OpenAPISpecLoader: Loader { ) } + /// Maps an inline parameter. A `$ref`'d parameter is dropped (returns `nil`), + /// matching the drop-on-reference choice across the other nesting levels until + /// S2 resolves in-file component references. private static func makeParameter( _ parameterEither: Either, OpenAPIKit.OpenAPI.Parameter> - ) -> Parameter? { + ) -> OpenAPISpec.Parameter? { guard case .b(let parameter) = parameterEither else { return nil } - let schema: SchemaNode? + let schema: OpenAPISpec.SchemaNode? switch parameter.schemaOrContent { case .a(let schemaContext): schema = Self.makeSchema(from: schemaContext.schema) @@ -180,9 +201,9 @@ public struct OpenAPISpecLoader: Loader { schema = Self.makeContent(contentMap).first?.schema } - return Parameter( + return OpenAPISpec.Parameter( name: parameter.name, - location: Parameter.Location(rawValue: parameter.location.rawValue), + location: OpenAPISpec.Parameter.Location(rawValue: parameter.location.rawValue), description: parameter.description, required: parameter.required, deprecated: parameter.deprecated, @@ -190,66 +211,74 @@ public struct OpenAPISpecLoader: Loader { ) } + /// Maps an inline request body. A `$ref`'d request body is dropped (returns + /// `nil`), consistent with the other reference levels. private static func makeRequestBody(_ requestEither: Either, OpenAPIKit.OpenAPI.Request>) - -> RequestBody? + -> OpenAPISpec.RequestBody? { guard case .b(let request) = requestEither else { return nil } - return RequestBody( + return OpenAPISpec.RequestBody( description: request.description, required: request.required, content: Self.makeContent(request.content) ) } - private static func makeResponses(_ responses: OpenAPIKit.OpenAPI.Response.Map) -> [Response] { - responses.map { entry in - let response: OpenAPIKit.OpenAPI.Response? - if case .b(let value) = entry.value { response = value } else { response = nil } - return Response( + /// Maps the inline responses. A `$ref`'d response is dropped (`compactMap`), + /// consistent with the parameter / request-body reference handling above – + /// rather than emitting a degenerate empty response. S2 resolves these refs. + private static func makeResponses(_ responses: OpenAPIKit.OpenAPI.Response.Map) -> [OpenAPISpec.Response] { + responses.compactMap { entry in + guard case .b(let response) = entry.value else { return nil } + return OpenAPISpec.Response( statusCode: entry.key.rawValue, - description: response?.description, - content: response.map { Self.makeContent($0.content) } ?? [] + description: response.description, + content: Self.makeContent(response.content) ) } } - private static func makeContent(_ content: OpenAPIKit.OpenAPI.Content.Map) -> [MediaType] { - content.map { entry in - let value: OpenAPIKit.OpenAPI.Content? - if case .b(let inline) = entry.value { value = inline } else { value = nil } - return MediaType(contentType: entry.key.rawValue, schema: value?.schema.map { Self.makeSchema($0) }) + /// Maps the inline media-type representations. A `$ref`'d content entry is + /// dropped (`compactMap`), consistent with the other reference levels. + private static func makeContent(_ content: OpenAPIKit.OpenAPI.Content.Map) -> [OpenAPISpec.MediaType] { + content.compactMap { entry in + guard case .b(let inline) = entry.value else { return nil } + return OpenAPISpec.MediaType(contentType: entry.key.rawValue, schema: inline.schema.map { Self.makeSchema($0) }) } } - private static func makeSecurity(_ security: [OpenAPIKit.OpenAPI.SecurityRequirement]?) -> [SecurityRequirement] { + /// Maps each per-operation security requirement (a named scheme reference plus + /// its scopes). S2: flatten the `components/securitySchemes` *definitions* + /// (type / location / OAuth flows) when the operation pages need to render them. + private static func makeSecurity(_ security: [OpenAPIKit.OpenAPI.SecurityRequirement]?) -> [OpenAPISpec.SecurityRequirement] { (security ?? []).map { requirement in let schemes = requirement - .compactMap { reference, scopes -> SecurityRequirement.SchemeRequirement? in + .compactMap { reference, scopes -> OpenAPISpec.SecurityRequirement.SchemeRequirement? in guard let name = reference.name else { return nil } - return SecurityRequirement.SchemeRequirement(name: name, scopes: scopes) + return OpenAPISpec.SecurityRequirement.SchemeRequirement(name: name, scopes: scopes) } // A requirement's schemes come from a dictionary (no inherent order); // sort by name so the rendered output is deterministic build to build. .sorted { $0.name < $1.name } - return SecurityRequirement(schemes: schemes) + return OpenAPISpec.SecurityRequirement(schemes: schemes) } } // MARK: - Schema mapping /// Flattens an inline-or-referenced schema, preserving a top-level `$ref` by name. - private static func makeSchema(from schemaEither: Either, JSONSchema>) -> SchemaNode { + private static func makeSchema(from schemaEither: Either, JSONSchema>) -> OpenAPISpec.SchemaNode { switch schemaEither { case .a(let reference): - return SchemaNode(referenceName: reference.name) + return OpenAPISpec.SchemaNode(referenceName: reference.name) case .b(let schema): return Self.makeSchema(schema) } } - /// Flattens an OpenAPIKit `JSONSchema` into the OpenAPIKit-free ``SchemaNode``. - private static func makeSchema(_ schema: JSONSchema) -> SchemaNode { + /// Flattens an OpenAPIKit `JSONSchema` into the OpenAPIKit-free ``OpenAPISpec/SchemaNode``. + private static func makeSchema(_ schema: JSONSchema) -> OpenAPISpec.SchemaNode { let title = schema.title let description = schema.description let deprecated = schema.deprecated @@ -257,7 +286,7 @@ public struct OpenAPISpecLoader: Loader { switch schema.value { case .reference(let reference, _): - return SchemaNode( + return OpenAPISpec.SchemaNode( title: title, description: description, referenceName: reference.name, @@ -267,13 +296,13 @@ public struct OpenAPISpecLoader: Loader { case .object(_, let context): let properties = context.properties.map { entry in - SchemaProperty( + OpenAPISpec.SchemaProperty( name: entry.key, required: context.requiredProperties.contains(entry.key), schema: Self.makeSchema(entry.value) ) } - return SchemaNode( + return OpenAPISpec.SchemaNode( type: "object", title: title, description: description, @@ -285,7 +314,7 @@ public struct OpenAPISpecLoader: Loader { case .array(_, let context): let items = context.items.map { [Self.makeSchema($0)] } ?? [] - return SchemaNode( + return OpenAPISpec.SchemaNode( type: "array", format: schema.formatString, title: title, @@ -296,16 +325,40 @@ public struct OpenAPISpecLoader: Loader { ) case .all(of: let subschemas, _): - return Self.makeComposition(.allOf, subschemas, title: title, description: description, deprecated: deprecated, nullable: nullable) + return Self.makeComposition( + .allOf, + subschemas, + discriminator: schema.discriminator, + title: title, + description: description, + deprecated: deprecated, + nullable: nullable + ) case .one(of: let subschemas, _): - return Self.makeComposition(.oneOf, subschemas, title: title, description: description, deprecated: deprecated, nullable: nullable) + return Self.makeComposition( + .oneOf, + subschemas, + discriminator: schema.discriminator, + title: title, + description: description, + deprecated: deprecated, + nullable: nullable + ) case .any(of: let subschemas, _): - return Self.makeComposition(.anyOf, subschemas, title: title, description: description, deprecated: deprecated, nullable: nullable) + return Self.makeComposition( + .anyOf, + subschemas, + discriminator: schema.discriminator, + title: title, + description: description, + deprecated: deprecated, + nullable: nullable + ) default: // Scalars (string / integer / number / boolean / null) plus `.not` and // untyped `.fragment`: carry the type, format, and enum values where present. - return SchemaNode( + return OpenAPISpec.SchemaNode( type: schema.jsonType?.rawValue, format: schema.formatString, title: title, @@ -318,22 +371,38 @@ public struct OpenAPISpecLoader: Loader { } private static func makeComposition( - _ kind: Composition.Kind, + _ kind: OpenAPISpec.Composition.Kind, _ subschemas: [JSONSchema], + discriminator: OpenAPIKit.OpenAPI.Discriminator?, title: String?, description: String?, deprecated: Bool, nullable: Bool - ) -> SchemaNode { - SchemaNode( + ) -> OpenAPISpec.SchemaNode { + OpenAPISpec.SchemaNode( title: title, description: description, - composition: Composition(kind: kind, subschemas: subschemas.map { Self.makeSchema($0) }), + composition: OpenAPISpec.Composition( + kind: kind, + subschemas: subschemas.map { Self.makeSchema($0) }, + discriminator: Self.makeDiscriminator(discriminator) + ), deprecated: deprecated, nullable: nullable ) } + private static func makeDiscriminator(_ discriminator: OpenAPIKit.OpenAPI.Discriminator?) -> OpenAPISpec.Composition.Discriminator? { + guard let discriminator else { return nil } + var mapping: [String: String] = [:] + for (value, schemaName) in discriminator.mapping ?? [:] { + mapping[value] = schemaName + } + return OpenAPISpec.Composition.Discriminator(propertyName: discriminator.propertyName, mapping: mapping) + } + + /// Renders enum values to their string form. S2: a structured (non-scalar) enum + /// value would yield a Swift debug string here; revisit if such enums appear. private static func makeEnumValues(_ values: [AnyCodable]?) -> [String] { (values ?? []).map { value in if let string = value.value as? String { return string } diff --git a/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift b/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift index 188af74..5b26115 100644 --- a/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift +++ b/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift @@ -7,8 +7,13 @@ extension SiteBuilder { /// /// The spec is discovered by convention at `Content/openapi.yaml` (falling /// back to `openapi.yml` / `openapi.json`), or pointed at explicitly with - /// `specPath`. It is loaded and validated up front so a missing or malformed - /// document surfaces immediately rather than producing a half-built site. + /// `specPath`. It is loaded up front and any discovery/decoding problem is + /// logged as a warning – the build then continues (warn-and-continue), so a + /// missing or malformed spec yields a site without the API pages rather than + /// aborting the build. + /// S2: once the page renderers consume the loaded spec, decide real fail-fast + /// (a build-phase error surface fits better than a throwing factory, since + /// SiteKit factories are non-throwing by convention like `.docc(...)`). /// /// Like `.docc(...)`, the blueprint brings its own shell and reads the token /// CSS variables, so all color schemes work and no layout is touched. The @@ -30,8 +35,9 @@ extension SiteBuilder { specPath: String? = nil ) -> SiteBuilder { // Discover and load the spec now so discovery + decoding are exercised at - // compose time and a bad spec fails loud and early. The loaded model is the - // contract the page renderers consume once they land in a later slice. + // compose time; a problem is logged and the build continues (warn-and-continue). + // The loaded model is the contract the page renderers consume once they land + // in a later slice, at which point S2 revisits whether to fail the build instead. if let specURL = Self.resolveSpecURL(specPath: specPath, config: config, projectDirectory: projectDirectory) { do { _ = try OpenAPISpecLoader().load(source: specURL) @@ -59,7 +65,7 @@ extension SiteBuilder { /// Resolves the spec file URL: the explicit `specPath` (relative to the /// project root) when given, otherwise the first existing conventional /// candidate under the content directory. Returns `nil` when no spec exists. - static func resolveSpecURL(specPath: String?, config: SiteConfig, projectDirectory: URL) -> URL? { + private static func resolveSpecURL(specPath: String?, config: SiteConfig, projectDirectory: URL) -> URL? { if let specPath { return projectDirectory.appendingPathComponent(specPath) } diff --git a/Tests/SiteKitOpenAPITests/Fixtures/features-3.0.yaml b/Tests/SiteKitOpenAPITests/Fixtures/features-3.0.yaml new file mode 100644 index 0000000..0dc8267 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/features-3.0.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + title: Feature Zoo + version: 2.0.0 +paths: {} +components: + schemas: + Widget: + type: object + required: + - status + properties: + nickname: + type: string + nullable: true + status: + type: string + enum: + - available + - pending + - sold + legacyId: + type: string + deprecated: true + Cat: + type: object + properties: + huntingSkill: + type: string + Dog: + type: object + properties: + packSize: + type: integer + Animal: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' diff --git a/Tests/SiteKitOpenAPITests/Fixtures/features-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/features-3.1.yaml new file mode 100644 index 0000000..a6c4fcf --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/features-3.1.yaml @@ -0,0 +1,42 @@ +openapi: 3.1.0 +info: + title: Feature Zoo + version: 2.0.0 +paths: {} +components: + schemas: + Widget: + type: object + required: + - status + properties: + nickname: + type: ["string", "null"] + status: + type: string + enum: + - available + - pending + - sold + legacyId: + type: string + deprecated: true + Cat: + type: object + properties: + huntingSkill: + type: string + Dog: + type: object + properties: + packSize: + type: integer + Animal: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' diff --git a/Tests/SiteKitOpenAPITests/Fixtures/swagger-2.0.yaml b/Tests/SiteKitOpenAPITests/Fixtures/swagger-2.0.yaml new file mode 100644 index 0000000..1652c34 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/swagger-2.0.yaml @@ -0,0 +1,27 @@ +swagger: '2.0' +info: + title: Legacy Petstore + version: 1.0.0 +host: petstore.swagger.io +basePath: /v1 +schemes: + - https +paths: + /pets: + get: + summary: List all pets + operationId: listPets + responses: + '200': + description: A list of pets +definitions: + Pet: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + name: + type: string diff --git a/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift b/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift index f9ec906..0b3a631 100644 --- a/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift +++ b/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift @@ -3,6 +3,15 @@ import Testing @testable import SiteKitOpenAPI +/// Loads a fixture from the test bundle's `Fixtures` directory and runs it through the loader. +private func loadFixture(_ name: String, _ fileExtension: String = "yaml") throws -> OpenAPISpec { + let url = try #require( + Bundle.module.url(forResource: name, withExtension: fileExtension, subdirectory: "Fixtures"), + "Missing fixture \(name).\(fileExtension)" + ) + return try OpenAPISpecLoader().load(source: url) +} + @Suite("OpenAPISpecLoader") struct OpenAPISpecLoaderTests { /// One fixture in the 2×2 decode matrix: an OpenAPI major version crossed with a serialization format. @@ -23,11 +32,7 @@ struct OpenAPISpecLoaderTests { ] private func loadSpec(_ fixture: Fixture) throws -> OpenAPISpec { - let url = try #require( - Bundle.module.url(forResource: fixture.name, withExtension: fixture.fileExtension, subdirectory: "Fixtures"), - "Missing fixture \(fixture)" - ) - return try OpenAPISpecLoader().load(source: url) + try loadFixture(fixture.name, fixture.fileExtension) } @Test("Decodes the info block", arguments: fixtures) @@ -120,24 +125,112 @@ struct OpenAPISpecLoaderTests { #expect(pets.schema.type == "array") #expect(pets.schema.items.first?.referenceName == "Pet") } +} + +/// Coverage for the schema-mapping branches the Petstore happy path never touches: +/// `nullable` convergence across 3.0/3.1, `enum`, schema-level `deprecated`, and +/// `oneOf` + discriminator. The `features-3.0`/`features-3.1` fixtures carry the +/// same logical schemas in each dialect, so a shared assertion run over both also +/// proves the 3.0-via-Compat path preserves these facets. +@Suite("OpenAPISpecLoader feature mapping") +struct OpenAPISpecLoaderFeatureTests { + /// The same feature schemas expressed in 3.0 and in 3.1 (both YAML). + static let dialects = ["features-3.0", "features-3.1"] + + @Test("Normalizes nullable identically: 3.0 `nullable: true` and 3.1 `[\"T\",\"null\"]`") + func nullableConverges() throws { + let spec30 = try loadFixture("features-3.0") + let spec31 = try loadFixture("features-3.1") + let nickname30 = try #require(self.nicknameProperty(in: spec30)) + let nickname31 = try #require(self.nicknameProperty(in: spec31)) + + #expect(nickname30.schema.nullable == true) + #expect(nickname31.schema.nullable == true) + #expect(nickname30.schema.type == "string") + #expect(nickname31.schema.type == "string") + // The whole node converges, not just the two facets above – the core correctness claim. + #expect(nickname30.schema == nickname31.schema) + } + + @Test("Maps enum values", arguments: dialects) + func enumValues(_ fixture: String) throws { + let spec = try loadFixture(fixture) + let widget = try #require(spec.schemas.first { $0.name == "Widget" }) + let status = try #require(widget.schema.properties.first { $0.name == "status" }) + #expect(status.schema.enumValues == ["available", "pending", "sold"]) + } + + @Test("Maps schema-level deprecated", arguments: dialects) + func deprecatedField(_ fixture: String) throws { + let spec = try loadFixture(fixture) + let widget = try #require(spec.schemas.first { $0.name == "Widget" }) + let legacyId = try #require(widget.schema.properties.first { $0.name == "legacyId" }) + #expect(legacyId.schema.deprecated == true) + } + + @Test("Maps oneOf composition with its discriminator", arguments: dialects) + func oneOfDiscriminator(_ fixture: String) throws { + let spec = try loadFixture(fixture) + let animal = try #require(spec.schemas.first { $0.name == "Animal" }) + let composition = try #require(animal.schema.composition) + #expect(composition.kind == .oneOf) + #expect(Set(composition.subschemas.compactMap(\.referenceName)) == ["Cat", "Dog"]) + let discriminator = try #require(composition.discriminator) + #expect(discriminator.propertyName == "petType") + // The mapping value is captured faithfully (the raw spec value – here a `$ref` + // string); S2 resolves it to a schema page when rendering. + #expect(discriminator.mapping["cat"] == "#/components/schemas/Cat") + } + + private func nicknameProperty(in spec: OpenAPISpec) -> OpenAPISpec.SchemaProperty? { + spec.schemas.first { $0.name == "Widget" }?.schema.properties.first { $0.name == "nickname" } + } +} - @Test("Rejects an unsupported OpenAPI major version") - func unsupportedVersion() throws { - let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let url = directory.appendingPathComponent("unsupported-\(UUID().uuidString).yaml") - let swagger2 = """ - swagger: '2.0' - openapi: '2.0' - info: - title: Legacy - version: 1.0.0 - paths: {} - """ - try swagger2.write(to: url, atomically: true, encoding: .utf8) +/// Coverage for the loader's error paths: an unsupported version (a real Swagger +/// 2.0 document with no `openapi` field), an empty file, malformed YAML, and a +/// missing file. +@Suite("OpenAPISpecLoader errors") +struct OpenAPISpecLoaderErrorTests { + @Test("A Swagger 2.0 document (no openapi field) throws unsupportedVersion, not a DecodingError") + func swagger2IsRejected() throws { + let url = try #require(Bundle.module.url(forResource: "swagger-2.0", withExtension: "yaml", subdirectory: "Fixtures")) + #expect(throws: OpenAPISpecLoader.LoadError.unsupportedVersion("")) { + try OpenAPISpecLoader().load(source: url) + } + } + + @Test("An empty spec throws") + func emptySpecThrows() throws { + let url = try Self.writeTemporary("", fileExtension: "yaml") + defer { try? FileManager.default.removeItem(at: url) } + #expect(throws: (any Error).self) { + try OpenAPISpecLoader().load(source: url) + } + } + + @Test("A malformed YAML spec throws") + func malformedSpecThrows() throws { + let url = try Self.writeTemporary("openapi: '3.1.0'\npaths: { : : :", fileExtension: "yaml") defer { try? FileManager.default.removeItem(at: url) } + #expect(throws: (any Error).self) { + try OpenAPISpecLoader().load(source: url) + } + } - #expect(throws: OpenAPISpecLoader.LoadError.self) { + @Test("A missing file throws") + func missingFileThrows() throws { + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("does-not-exist-\(UUID().uuidString).yaml") + #expect(throws: (any Error).self) { try OpenAPISpecLoader().load(source: url) } } + + private static func writeTemporary(_ contents: String, fileExtension: String) throws -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("openapi-loader-\(UUID().uuidString).\(fileExtension)") + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } } From e46d48067887210e3ff310eec3794501e71d28d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Sat, 20 Jun 2026 09:00:35 +0200 Subject: [PATCH 03/23] Add the OpenAPI shell and landing page Introduce the page-rendering foundation for the OpenAPI docs site, mirroring the DocC plugin set. OpenAPIShell wraps a page body in an app-shell (its own appbar, a slot for the navigation sidebar that a later slice fills) through PageShell with chrome .appShell, so the generic site header/footer are suppressed and the docs layout reads the theme tokens without touching any layout. OpenAPIRoutes is the single source of truth for the deep-linkable URL scheme (landing, tag, operation, schema) plus the slug and tag-grouping rules every renderer shares; an untagged operation groups under a synthetic "general" tag so none is dropped. OpenAPIHTML escapes interpolated values. OpenAPILandingPage renders the API title, an optional Content/api-intro.md prose block (rendered through SiteKit's Markdown loader, a no-op when absent), and a card per tag linking to that tag's page. The .openAPI factory now loads the spec once and injects it into the page renderers, which read only the OpenAPISpec model and never import OpenAPIKit. Semantic HTML with stable classes and data attributes only; the stylesheet that targets them is a later slice. The tag, operation, and schema pages and the HTML-structure tests follow in the next commits. --- Sources/SiteKitOpenAPI/OpenAPIHTML.swift | 19 +++ .../SiteKitOpenAPI/OpenAPILandingPage.swift | 109 ++++++++++++++ Sources/SiteKitOpenAPI/OpenAPIRoutes.swift | 134 ++++++++++++++++++ Sources/SiteKitOpenAPI/OpenAPIShell.swift | 66 +++++++++ .../SiteKitOpenAPI/SiteBuilder+OpenAPI.swift | 37 +++-- 5 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 Sources/SiteKitOpenAPI/OpenAPIHTML.swift create mode 100644 Sources/SiteKitOpenAPI/OpenAPILandingPage.swift create mode 100644 Sources/SiteKitOpenAPI/OpenAPIRoutes.swift create mode 100644 Sources/SiteKitOpenAPI/OpenAPIShell.swift diff --git a/Sources/SiteKitOpenAPI/OpenAPIHTML.swift b/Sources/SiteKitOpenAPI/OpenAPIHTML.swift new file mode 100644 index 0000000..b8e1421 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIHTML.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Small HTML helpers shared by the OpenAPI page renderers. +/// +/// The renderers assemble HTML by string concatenation (like the DocC plugin +/// set), so every value interpolated from the spec passes through `escape(_:)` +/// to neutralize `&`, `"`, `<`, `>`. +enum OpenAPIHTML { + /// Escapes the five characters that would otherwise break out of text or an + /// attribute value in the assembled HTML. + static func escape(_ string: String) -> String { + string + .replacing("&", with: "&") + .replacing("\"", with: """) + .replacing("'", with: "'") + .replacing("<", with: "<") + .replacing(">", with: ">") + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPILandingPage.swift b/Sources/SiteKitOpenAPI/OpenAPILandingPage.swift new file mode 100644 index 0000000..3568fb0 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPILandingPage.swift @@ -0,0 +1,109 @@ +import Foundation +import SiteKit + +/// The landing page of the OpenAPI docs site: the API title and description, an +/// optional `Content/api-intro.md` prose block, and a card per tag linking to that +/// tag's page. +/// +/// Mirrors `DocCHomePage`: it emits a single synthetic `PageModel` (not backed by a +/// Markdown file) and wraps its body in the `OpenAPIShell`. The cards come from +/// ``OpenAPIRoutes/tagGroups(_:)`` so the landing, tag pages, and operation URLs +/// agree on which operations belong to which tag. +public struct OpenAPILandingPage: Page { + private let spec: OpenAPISpec + + /// Creates the landing renderer for `spec`. + public init(spec: OpenAPISpec) { + self.spec = spec + } + + public func pages(in context: BuildContext) -> [PageModel] { + [ + PageModel( + title: self.spec.info.title, + slug: OpenAPIRoutes.prefix(context), + htmlContent: "", + sourcePath: context.projectDirectory.appendingPathComponent("\(context.config.contentDirectory)/openapi.yaml"), + summary: self.spec.info.description, + description: self.spec.info.description, + pageType: .staticPage + ) + ] + } + + public func renderHTML(_ page: PageModel, context: BuildContext) -> String { + let path = OpenAPIRoutes.landingPath(context) + let renderer = OutputFileRenderer(context: context) + let head = renderer.buildHead( + title: "\(self.spec.info.title) – \(context.config.name)", + description: self.spec.info.description, + canonicalURL: "\(context.config.baseURL)\(path)", + ogType: "website" + ) + + let body = + "
" + + self.headerHTML() + + self.introHTML(context: context) + + self.tagCardsHTML(context: context) + + "
" + + return OpenAPIShell.wrap(content: body, page: page, context: context, head: head) + } + + public func outputURL(for page: PageModel, context: BuildContext) -> URL { + OpenAPIRoutes.outputURL(for: OpenAPIRoutes.landingPath(context), context: context) + } + + // MARK: - Body sections + + /// The API title + version + description header. + private func headerHTML() -> String { + let info = self.spec.info + var header = "
" + header += "

\(OpenAPIHTML.escape(info.title))

" + header += "

\(OpenAPIHTML.escape(info.version))

" + if let description = info.description, !description.isEmpty { + header += "

\(OpenAPIHTML.escape(description))

" + } + header += "
" + return header + } + + /// Optional getting-started prose from `Content/api-intro.md`. Returns an empty + /// string when the file is absent, so the landing is a no-op without it. + private func introHTML(context: BuildContext) -> String { + let url = context.projectDirectory + .appendingPathComponent(context.config.contentDirectory) + .appendingPathComponent("api-intro.md") + guard let markdown = try? String(contentsOf: url, encoding: .utf8) else { return "" } + // Reuse SiteKit's Markdown loader (no required frontmatter) to render the prose + // to HTML, so api-intro.md supports the same Markdown as the rest of a SiteKit site. + let source = MarkdownSource(filePath: url, content: markdown) + guard let page = try? MarkdownLoader(requiredFields: []).load(source: source) else { return "" } + return "
\(page.htmlContent)
" + } + + /// A card per tag group, linking to the tag page. + private func tagCardsHTML(context: BuildContext) -> String { + let groups = OpenAPIRoutes.tagGroups(self.spec) + guard !groups.isEmpty else { return "" } + + let cards = groups.map { group -> String in + let slug = OpenAPIRoutes.tagSlug(group.tag.name) + let href = OpenAPIHTML.escape(OpenAPIRoutes.tagPath(context, tagSlug: slug)) + let count = group.operations.count + let countLabel = count == 1 ? "1 endpoint" : "\(count) endpoints" + var card = "" + card += "

\(OpenAPIHTML.escape(group.tag.name))

" + if let description = group.tag.description, !description.isEmpty { + card += "

\(OpenAPIHTML.escape(description))

" + } + card += "

\(countLabel)

" + card += "
" + return card + }.joined() + + return "
\(cards)
" + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIRoutes.swift b/Sources/SiteKitOpenAPI/OpenAPIRoutes.swift new file mode 100644 index 0000000..48936e8 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIRoutes.swift @@ -0,0 +1,134 @@ +import Foundation +import SiteKit + +/// The deep-linkable URL scheme for the OpenAPI docs site, plus the slug helpers +/// the page renderers share so paths stay consistent across landing, tag, +/// operation, and schema pages. +/// +/// Every page lives under the configured section `urlPrefix` (default `api`): +/// - Landing: `//` +/// - Tag page: `///` +/// - Operation page: `////` +/// - Schema page: `//schemas//` +/// +/// Paths are stable (they become external deep links and SEO canonicals in a +/// later slice), so the slug rules here are the single source of truth. +enum OpenAPIRoutes { + /// The cleaned section URL prefix (no leading/trailing slashes), defaulting to + /// `api` when the site declares no section. + static func prefix(_ context: BuildContext) -> String { + let raw = context.config.effectiveSections.first?.urlPrefix ?? "api" + return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + /// The tag an untagged operation is grouped under, so no operation is dropped. + static let defaultTag = "general" + + /// Lowercases and hyphenates an arbitrary string into a URL-safe slug: runs of + /// non-alphanumeric characters collapse to a single hyphen, and leading/trailing + /// hyphens are trimmed (so `"/pets/{petId}"` becomes `"pets-petid"`). + static func slugify(_ string: String) -> String { + let lowered = string.lowercased() + var slug = "" + var lastWasHyphen = false + for character in lowered { + if character.isLetter || character.isNumber { + slug.append(character) + lastWasHyphen = false + } else if !lastWasHyphen { + slug.append("-") + lastWasHyphen = true + } + } + return slug.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } + + /// The slug for a tag name. + static func tagSlug(_ tag: String) -> String { + self.slugify(tag) + } + + /// The tag an operation is canonically grouped under: its first declared tag, + /// or ``defaultTag`` when it has none. + static func canonicalTag(for operation: OpenAPISpec.Operation) -> String { + operation.tags.first ?? self.defaultTag + } + + /// The slug for an operation: its `operationId` when present, otherwise a + /// `-` slug (so every operation has a stable, unique-enough slug). + static func operationSlug(for operation: OpenAPISpec.Operation) -> String { + if let operationId = operation.operationId, !operationId.isEmpty { + return self.slugify(operationId) + } + return self.slugify("\(operation.method)-\(operation.path)") + } + + /// The slug for a component schema name. + static func schemaSlug(_ name: String) -> String { + self.slugify(name) + } + + /// `//` – the landing page. + static func landingPath(_ context: BuildContext) -> String { + "/\(self.prefix(context))/" + } + + /// `///` – a tag page. + static func tagPath(_ context: BuildContext, tagSlug: String) -> String { + "/\(self.prefix(context))/\(tagSlug)/" + } + + /// `////` – an operation page. + static func operationPath(_ context: BuildContext, tagSlug: String, operationSlug: String) -> String { + "/\(self.prefix(context))/\(tagSlug)/\(operationSlug)/" + } + + /// `//schemas//` – a schema page. + static func schemaPath(_ context: BuildContext, schemaSlug: String) -> String { + "/\(self.prefix(context))/schemas/\(schemaSlug)/" + } + + /// Groups the spec's operations by their canonical tag (first declared tag, or + /// ``defaultTag`` when untagged), so the landing, tag pages, and operation URLs + /// all agree on which tag owns an operation. + /// + /// Order: declared tags in document order first, then any extra tag only an + /// operation introduces, then the synthetic `general` group. A declared tag that + /// owns no operation is omitted (no empty group). Each group's `tag` carries the + /// declared description where one exists. + static func tagGroups(_ spec: OpenAPISpec) -> [(tag: OpenAPISpec.Tag, operations: [OpenAPISpec.Operation])] { + var order: [String] = spec.tags.map(\.name) + var descriptions: [String: String?] = [:] + for tag in spec.tags { + descriptions[tag.name] = tag.description + } + + var operationsByTag: [String: [OpenAPISpec.Operation]] = [:] + for operation in spec.operations { + let tag = self.canonicalTag(for: operation) + operationsByTag[tag, default: []].append(operation) + if !order.contains(tag) { + order.append(tag) + } + } + + return order.compactMap { name in + guard let operations = operationsByTag[name], !operations.isEmpty else { return nil } + let tag = OpenAPISpec.Tag(name: name, description: descriptions[name] ?? nil) + return (tag, operations) + } + } + + /// Maps a site-relative path (`/api/pets/`) to its `index.html` file URL under + /// the build output directory. + static func outputURL(for path: String, context: BuildContext) -> URL { + var relative = path.hasPrefix("/") ? String(path.dropFirst()) : path + if relative.hasSuffix("/") { relative = String(relative.dropLast()) } + if relative.isEmpty { + return context.outputDirectory.appendingPathComponent("index.html") + } + return context.outputDirectory + .appendingPathComponent(relative) + .appendingPathComponent("index.html") + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIShell.swift b/Sources/SiteKitOpenAPI/OpenAPIShell.swift new file mode 100644 index 0000000..9452108 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIShell.swift @@ -0,0 +1,66 @@ +import Foundation +import SiteKit + +/// The shared OpenAPI app-shell: the chrome every OpenAPI page renderer wraps its +/// body in, so the whole API-docs site presents one consistent docs layout. +/// +/// Mirrors `DocCShell`: it brings its own appbar (and, from a later slice, a +/// sidebar nav rail), so it wraps through `PageShell.wrap(... chrome: .appShell)`, +/// which suppresses the generic site `
`/`