From 619936730e6bff26d50cdba36f6207cbac3b94ed Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 12:07:45 -0300 Subject: [PATCH 01/44] docs: add design spec for Storage OpenAPI codegen tool --- ...26-07-08-storage-openapi-codegen-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-storage-openapi-codegen-design.md diff --git a/docs/superpowers/specs/2026-07-08-storage-openapi-codegen-design.md b/docs/superpowers/specs/2026-07-08-storage-openapi-codegen-design.md new file mode 100644 index 000000000..7b741755c --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-storage-openapi-codegen-design.md @@ -0,0 +1,170 @@ +# OpenAPI-driven Swift codegen for Storage + +**Date:** 2026-07-08 +**Status:** Approved for planning + +## Goal + +Build a generic OpenAPI-to-Swift code generator, written in Swift, and use it to generate +an HTTP client for the Storage API. Other Supabase services (Auth, PostgREST, ...) are +explicit follow-ups, not part of this effort. + +This iteration produces the generator and generated code as real, compiling, tested +artifacts in the repo. It does **not** wire the generated client into the public +`SupabaseStorageClient` API — `StorageFileApi`, `StorageBucketApi`, and `Types.swift` +are untouched. Wiring is a separate future task once the generated shapes have been +reviewed. + +## Background + +- Storage does not currently publish a static OpenAPI document; it's produced on demand + via `npm run docs:export` in the `supabase/storage` repo. +- [`supabase/storage#1215`](https://github.com/supabase/storage/pull/1215) fixes the + spec for SDK generation: adds `operationId` to all operations (previously missing), + registers `bucketSchema`/`objectSchema`/`authSchema`/`errorSchema` as named components + instead of inlining them, renames the illegal `*` wildcard path param to `wildcard`, + documents real 401/403 responses, dedupes trailing-slash duplicate paths, hides the + schemaless S3-compatible catch-all route, and fixes three OpenAPI 3.1-only constructs + (`type: [T, "null"]`, `anyOf` with a null branch, and a redundant top-level `anyOf` on + `bucketUpdate`) that are invalid under the document's declared 3.0.3 version. That PR + also confirms someone already ran `swift-openapi-generator` against this spec + successfully after those fixes, which is independent validation that the fixed spec is + well-formed OpenAPI 3.0.3. +- The Storage module today is 100% hand-written: no OpenAPI involved anywhere in this + repo yet. +- A prior spike + (`/Users/guilherme/src/github.com/grdsdev/spike-swift-supabase-code-generation`) + evaluated `smithy-swift` and `swift-openapi-generator` for a similar generation problem + and rejected both because their generated code mandates a runtime dependency + (`ClientRuntime`/`OpenAPIRuntime`) and neither supports OS background sessions or + upload progress — both hard requirements for Storage's large-file transfers. The spike + hand-rolled a zero-dependency `HTTPRuntime` (streaming multipart-to-temp-file upload, + streaming download, SSE, typed error decoding, upload/download progress via a delegate + bridge) and a custom Node/TS emitter consuming Smithy/TypeSpec models directly (not + OpenAPI) to preserve streaming fidelity that OpenAPI's schema loses. +- This effort reuses the spike's `HTTPRuntime` runtime and its "hand-roll the emitter, + keep generated code dependency-free" conclusion, but swaps the source of truth to + OpenAPI (since Storage already has one, once fixed) and the generator's implementation + language to Swift (since this is a Swift-only repo and contributors shouldn't need a + Node/JVM toolchain to regenerate the client). + +## Architecture + +``` +supabase-swift/ + openapi/ + storage.json <- committed OpenAPI 3.0.3 doc (from storage#1215) + tools/ + openapi-codegen/ <- own SPM package, own Package.swift + Package.swift <- depends on OpenAPIKit30 (build-time only) + Sources/openapi-codegen/ + main.swift <- CLI entry point + IR.swift <- internal model: operations, schemas, params + OpenAPIParsing.swift <- OpenAPIKit30 doc -> IR + SwiftEmitter.swift <- IR -> Swift source text + node/ <- existing cspell tooling (precedent for this layout) + Sources/ + HTTPRuntime/ <- copied from the spike, unmodified behavior + StorageOpenAPI/ <- generated output, committed + Models.swift + StorageOpenAPIClient.swift + Tests/ + StorageOpenAPITests/ <- Swift Testing, proves the generated client works + Package.swift <- adds HTTPRuntime + StorageOpenAPI internal targets +``` + +### Why a separate nested package for the tool + +`tools/openapi-codegen` gets its own `Package.swift` so `OpenAPIKit30` (and any other +codegen-only dependency) never appears in the main package's `Package.resolved`. Every +consumer of `supabase-swift` as a library resolves only the main `Package.swift`; the +codegen tool is invoked manually by maintainers and never built as part of a consumer's +dependency graph. This mirrors `tools/node`, which already isolates the cspell tooling +from the main package for the same reason. + +### Why OpenAPIKit instead of hand-rolling the parser + +The spike's "hand-roll everything" conclusion applied to the *emitter*, driven by the +requirement to preserve streaming fidelity that OpenAPI's schema loses when the source of +truth is TypeSpec/Smithy. Here the source of truth **is** OpenAPI, so that argument +doesn't transfer to the parsing layer: OpenAPIKit is a mature, MIT-licensed, actively +maintained library purpose-built for parsing and modeling OpenAPI documents (including +`$ref` resolution, JSON Schema nullable/enum/array handling) — it's the same library +`swift-openapi-generator` itself uses internally. Reimplementing that would be +redundant, error-prone effort spent on a solved problem. The custom, genuinely novel part +of this tool is the **emitter** — turning parsed operations/schemas into Swift that +targets `HTTPRuntime` instead of a shipped runtime dependency — and that's where the +implementation effort goes. + +Apple's own `_OpenAPIGeneratorCore` (which also wraps OpenAPIKit) was considered and +rejected: it's underscore-prefixed specifically to signal "not for external use," with no +API stability guarantee — too risky to build a maintained tool on top of. + +### Pipeline + +1. `openapi-codegen` parses `openapi/storage.json` via `OpenAPIKit30.OpenAPI.Document`. +2. Builds an internal IR (operations with method/path/params/request-body/responses; + schemas as structs/enums) decoupled from OpenAPIKit's types, so the emitter is simple + and independently testable. +3. Emits two files into the target output directory: + - `Models.swift` — `Codable, Sendable, Hashable` structs/enums from + `components.schemas`. + - `StorageOpenAPIClient.swift` — one `Sendable` struct with one `async throws` method + per operation, built on `HTTPRequestBuilder`/`HTTPTransport` from `HTTPRuntime`, + matching the shape already proven in the spike's generated clients (see + `SpikeServiceClient.swift` for the target style: builder-based request assembly, + `transport.send`/`transport.stream`, `response.checkStatus(errorTypes:)` for typed + per-status errors). +4. CLI is generic, not Storage-specific: `openapi-codegen --spec --output + --module `, so pointing it at a different service's spec later is a flag change, + not a rewrite. + +### Naming convention + +Generated names mirror the spec verbatim: `operationId` → method name (e.g. +`createObject`), schema name → type name (e.g. `ObjectSchema`). No attempt is made to +match the hand-written API's naming (`upload`, `FileObject`) or shape. Reconciling the two +— deciding whether the public API adopts generated names, wraps them, or something else — +is deferred to the future wiring task, once the generated shapes exist to evaluate against. + +### Emitter feature scope + +Supported (validated against what's actually in Storage's fixed spec): +primitives/objects/arrays, string enums (`enum:` keyword), `nullable: true`, path/query/ +header parameters, JSON request/response bodies, `multipart/form-data` request bodies +(emitted to stream via `HTTPRuntime`'s temp-file assembly, not buffered in memory), binary +response bodies, and per-status-code typed error responses (via `errorSchema`). + +Unsupported constructs (a `oneOf`/`anyOf` discriminated union, an external `$ref`, a +`callbacks`/`links` object) make the generator **fail fast** with a diagnostic naming the +offending schema and its location in the document — never a silent best-effort guess at +unfamiliar shapes. Storage's fixed spec is not expected to contain any of these (PR #1215 +specifically removes the one remaining `anyOf`), so this is a safety net, not an expected +code path for this run. + +### Runtime + +`Sources/HTTPRuntime/` is a verbatim copy of the spike's runtime (updated only for this +repo's file-header convention), added as an internal SPM target — not exported via +`@_exported import Supabase`, not a public product yet. It provides: buffered request/ +response, streaming download, streaming multipart upload assembled onto a temp file +(constant memory), upload/download progress via a per-task delegate, and typed-error +decoding. None of this is exercised by real traffic in this iteration beyond what the +tests below cover — background-session support remains a design-only follow-up, matching +the spike's own conclusion. + +### Testing + +`Tests/StorageOpenAPITests/` (Swift Testing, per this repo's convention for new test +files) covers a handful of representative operations end-to-end against a mocked +transport — not all ~60 operations. At minimum: a bucket CRUD round-trip (JSON body, +typed response), an object upload (multipart request body), and a typed-error decode path +(e.g. a 404 on a missing bucket). The goal is proving the generated code is correct and +usable, not exhaustive spec coverage. + +## Out of scope for this iteration + +- Wiring generated code into `SupabaseStorageClient`/`StorageFileApi`/`StorageBucketApi`. +- Auth/`apikey` header injection or any other request customization needed for real use. +- OS background `URLSession` support (design-only, per the spike's own conclusion). +- Any spec besides Storage. From 8babc4fe5c12773680d699a93be85d133d416933 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 12:23:35 -0300 Subject: [PATCH 02/44] docs: add implementation plan for Storage OpenAPI codegen --- .../2026-07-08-storage-openapi-codegen.md | 2420 +++++++++++++++++ 1 file changed, 2420 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-storage-openapi-codegen.md diff --git a/docs/superpowers/plans/2026-07-08-storage-openapi-codegen.md b/docs/superpowers/plans/2026-07-08-storage-openapi-codegen.md new file mode 100644 index 000000000..9f89ba796 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-storage-openapi-codegen.md @@ -0,0 +1,2420 @@ +# Storage OpenAPI Codegen Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a generic Swift OpenAPI-to-Swift codegen tool and use it to generate an HTTP client for Storage, without wiring the generated code into the public `Storage` API yet. + +**Architecture:** A standalone SPM package `tools/openapi-codegen` parses an OpenAPI 3.0.3 document with `OpenAPIKit30` into a small internal IR, then a hand-written emitter turns that IR into Swift source targeting a zero-dependency runtime (`HTTPRuntime`, copied verbatim from a prior spike) that lives in the main package. The tool is run manually against Storage's spec; its output is committed like any other source file. + +**Tech Stack:** Swift 6.1, SPM, `OpenAPIKit30` (tool-only dependency), Swift Testing. + +## Global Constraints + +- `OpenAPIKit30` pinned to `from: "6.2.0"` in `tools/openapi-codegen/Package.swift` — this is the same library `swift-openapi-generator` uses internally to parse OpenAPI 3.0.x documents. If a symbol referenced below doesn't match the installed version, check that tag's source under `Sources/OpenAPIKit30/` before improvising a workaround. +- `tools/openapi-codegen` never becomes a dependency of the main `Package.swift` — it is invoked manually (`swift run --package-path tools/openapi-codegen openapi-codegen ...`), matching how `tools/node` (cspell) is isolated today. +- Generated Swift mirrors the OpenAPI spec verbatim: `operationId` → method name, schema name → type name. No attempt to match the hand-written `StorageFileApi`/`FileObject` naming. +- Unsupported OpenAPI constructs (`oneOf`/`anyOf`/`allOf`/`not`, external `$ref`, inline enums, inline object schemas with defined properties, cookie parameters, parameters using `content` instead of `schema`) make the generator **throw an error naming the offending location** — never a silent best-effort guess. +- `nullable: true` OR "not in the `required` list" both become `Optional` in emitted Swift — Swift's `Codable` doesn't distinguish "absent" from "explicit null" without custom decode logic, and this codebase doesn't need that distinction yet. +- New test files use Swift Testing (`import Testing`, `@Suite`, `@Test`, `#expect`) per this repo's convention — including the ported runtime tests, which existed as XCTest in the spike. +- For `tools/openapi-codegen` (a standalone SPM package, not part of `Supabase.xcworkspace`), use plain `swift test` — there's no Xcode scheme for it. For the main package's new targets (`HTTPRuntime`, `StorageOpenAPI`), use `swift test --filter ` for fast iteration during a task, but the final gate in Task 15 is the full `xcodebuild` run this repo's contributors rely on (`PLATFORM=IOS XCODEBUILD_ARGUMENT=test ./scripts/xcodebuild.sh`), not `swift test`. +- Every Swift file added to the main package gets the standard header: + ```swift + // + // FileName.swift + // ModuleName + // + // Created by Guilherme Souza on 08/07/26. + // + ``` +- Run `./scripts/format.sh` on any file you touch in the main package before committing (per AGENTS.md); this is not required inside `tools/openapi-codegen` (separate package, not covered by this repo's format config). + +--- + +## File Structure + +``` +supabase-swift/ + openapi/ + storage.json <- Task 12 + tools/openapi-codegen/ + Package.swift <- Task 2 + Sources/OpenAPICodegenCore/ + IR.swift <- Task 3 + OpenAPIParsing.swift <- Tasks 3-7 + SwiftNames.swift <- Task 8 + SwiftEmitter.swift <- Tasks 9-10 + Sources/openapi-codegen/ + main.swift <- Task 11 + Tests/OpenAPICodegenCoreTests/ + SchemaParsingTests.swift <- Task 3 + TypeParsingTests.swift <- Task 4 + ParameterParsingTests.swift <- Task 5 + RequestBodyParsingTests.swift <- Task 6 + ResponseParsingTests.swift <- Task 7 + SwiftNamesTests.swift <- Task 8 + ModelEmitterTests.swift <- Task 9 + ClientEmitterTests.swift <- Task 10 + Tests/openapi-codegenTests/ + EndToEndTests.swift <- Task 11 + Sources/HTTPRuntime/ <- Task 1 (copied from the spike, 11 files) + Sources/StorageOpenAPI/ + Models.swift <- Task 13 + StorageOpenAPIClient.swift <- Task 13 + Tests/HTTPRuntimeTests/ + HTTPRuntimeTests.swift <- Task 1 + Tests/StorageOpenAPITests/ + BucketOperationsTests.swift <- Task 14 + ObjectUploadTests.swift <- Task 14 + ErrorDecodingTests.swift <- Task 14 + Package.swift <- Tasks 1, 13 (add HTTPRuntime + StorageOpenAPI targets) +``` + +--- + +### Task 1: Copy HTTPRuntime into the main package + +**Files:** +- Create: `Sources/HTTPRuntime/HTTPError.swift`, `HTTPMethod.swift`, `HTTPRequest.swift`, `HTTPResponse.swift`, `HTTPTransport.swift`, `JSONCoding.swift`, `JSONValue.swift`, `MultipartFormData.swift`, `PathEncoding.swift`, `ServerSentEvents.swift`, `TransferProgress.swift`, `URLSessionTransport.swift` +- Create: `Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift` +- Modify: `Package.swift` + +**Interfaces:** +- Produces: `HTTPTransport` (protocol), `HTTPRequest`/`HTTPRequestBuilder`/`HTTPResponse`/`HTTPResponseStream`, `HTTPError`, `APIError` (protocol), `JSONCoding.encoder`/`.decoder`, `JSONValue`, `MultipartFormData`, `PathEncoding.segment(_:)`/`.greedy(_:)`, `URLSessionTransport` — all `public`, used by every later task. + +- [ ] **Step 1: Copy the runtime files verbatim, then prepend this repo's file header** + +```bash +cp /Users/guilherme/src/github.com/grdsdev/spike-swift-supabase-code-generation/Sources/HTTPRuntime/*.swift \ + Sources/HTTPRuntime/ + +for f in Sources/HTTPRuntime/*.swift; do + name=$(basename "$f") + header="//\n// ${name}\n// HTTPRuntime\n//\n// Created by Guilherme Souza on 08/07/26.\n//\n" + printf '%b\n%s' "$header" "$(cat "$f")" > "$f.tmp" && mv "$f.tmp" "$f" +done +``` + +- [ ] **Step 2: Register the `HTTPRuntime` target and test target in `Package.swift`** + +Add this block to the `targets:` array, right after the `HelpersTests` target (both `HTTPRuntime` and `Helpers` are dependency-free foundations other targets build on): + +```swift + .target( + name: "HTTPRuntime" + ), + .testTarget( + name: "HTTPRuntimeTests", + dependencies: [ + "HTTPRuntime" + ] + ), +``` + +Add `"HTTPRuntimeTests"` to the `swift6TestTargets` set near the bottom of the file: + +```swift +let swift6TestTargets: Set = ["SupabaseTests", "HelpersTests", "HTTPRuntimeTests"] +``` + +- [ ] **Step 3: Write the ported runtime tests (Swift Testing, not the spike's XCTest)** + +```swift +// +// HTTPRuntimeTests.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// + +import Foundation +import Testing + +@testable import HTTPRuntime + +@Suite +struct HTTPRuntimeTests { + + @Test + func serverSentEventParsing() async throws { + let raw = """ + event: message + data: {"delta":"hello"} + + event: progress + data: {"percent":50} + + event: done + data: {"total":3} + + + """ + let bytes = AsyncThrowingStream { continuation in + let full = Array(Data(raw.utf8)) + let mid = full.count / 2 + continuation.yield(Data(full[0.. { continuation in + continuation.yield(Data(raw.utf8)) + continuation.finish() + } + var events: [ServerSentEvent] = [] + for try await event in bytes.serverSentEvents() { events.append(event) } + #expect(events.count == 1) + #expect(events[0].data == "line1\nline2") + } + + @Test + func multipartAssemblesToFileWithoutBufferingSource() throws { + let sourceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("src-\(UUID().uuidString).bin") + let payload = Data((0..<200_000).map { UInt8($0 % 256) }) + try payload.write(to: sourceURL) + defer { try? FileManager.default.removeItem(at: sourceURL) } + + var form = MultipartFormData(boundary: "TESTBOUNDARY") + form.append(.init(name: "meta", source: .data(Data(#"{"k":"v"}"#.utf8)))) + form.append( + .init( + name: "file", filename: "big.bin", contentType: "application/octet-stream", + source: .file(sourceURL))) + + let bodyURL = try form.writeToTemporaryFile() + defer { try? FileManager.default.removeItem(at: bodyURL) } + let body = try Data(contentsOf: bodyURL) + + #expect(form.contentType == "multipart/form-data; boundary=TESTBOUNDARY") + let text = String(decoding: body.prefix(400), as: UTF8.self) + #expect(text.contains("--TESTBOUNDARY")) + #expect(text.contains(#"Content-Disposition: form-data; name="meta""#)) + #expect(text.contains(#"name="file"; filename="big.bin""#)) + #expect(body.count > payload.count) + } + + @Test + func pathEncoding() { + #expect(PathEncoding.segment("a/b c") == "a%2Fb%20c") + #expect(PathEncoding.greedy("a/b/c.txt") == "a/b/c.txt") + #expect(PathEncoding.greedy("a/b c.txt") == "a/b%20c.txt") + } + + @Test + func jsonValueRoundTrip() throws { + let value = JSONValue.object([ + "s": .string("x"), + "n": .number(3.5), + "b": .bool(true), + "arr": .array([.number(1), .null]), + ]) + let data = try JSONCoding.encoder.encode(value) + let decoded = try JSONCoding.decoder.decode(JSONValue.self, from: data) + #expect(decoded == value) + } + + @Test + func iso8601DateCoding() throws { + struct Holder: Codable, Equatable { let at: Date } + let json = #"{"at":"2026-07-06T12:34:56.789Z"}"# + let decoded = try JSONCoding.decoder.decode(Holder.self, from: Data(json.utf8)) + let reencoded = try JSONCoding.encoder.encode(decoded) + let round = try JSONCoding.decoder.decode(Holder.self, from: reencoded) + #expect(decoded == round) + } +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `swift test --filter HTTPRuntimeTests` +Expected: PASS (6 tests) + +- [ ] **Step 5: Format and commit** + +```bash +./scripts/format.sh +git add Sources/HTTPRuntime Tests/HTTPRuntimeTests Package.swift +git commit -m "feat(runtime): copy zero-dependency HTTPRuntime from the codegen spike" +``` + +--- + +### Task 2: Scaffold the `tools/openapi-codegen` package + +**Files:** +- Create: `tools/openapi-codegen/Package.swift` +- Create: `tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift` (placeholder type, replaced fully in Task 3) +- Create: `tools/openapi-codegen/Sources/openapi-codegen/main.swift` + +**Interfaces:** +- Produces: an `OpenAPICodegenCore` library target and an `openapi-codegen` executable target that later tasks add real code to. + +- [ ] **Step 1: Write the package manifest** + +```swift +// swift-tools-version: 6.1 +import PackageDescription + +let package = Package( + name: "openapi-codegen", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "openapi-codegen", targets: ["openapi-codegen"]) + ], + dependencies: [ + .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "6.2.0") + ], + targets: [ + .target( + name: "OpenAPICodegenCore", + dependencies: [ + .product(name: "OpenAPIKit30", package: "OpenAPIKit") + ] + ), + .executableTarget( + name: "openapi-codegen", + dependencies: ["OpenAPICodegenCore"] + ), + .testTarget( + name: "OpenAPICodegenCoreTests", + dependencies: ["OpenAPICodegenCore"] + ), + .testTarget( + name: "openapi-codegenTests", + dependencies: ["OpenAPICodegenCore"] + ), + ] +) +``` + +- [ ] **Step 2: Add a minimal `IR.swift` so the target compiles** + +```swift +enum IRPlaceholder {} +``` + +- [ ] **Step 3: Add a minimal `main.swift`** + +```swift +print("openapi-codegen: not yet implemented") +``` + +- [ ] **Step 4: Verify the package builds** + +Run: `swift build --package-path tools/openapi-codegen` +Expected: `Build complete!` + +- [ ] **Step 5: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "chore(codegen): scaffold the openapi-codegen tool package" +``` + +--- + +### Task 3: IR types + schema parsing (objects, string enums) + +**Files:** +- Modify: `tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift` (replace placeholder) +- Create: `tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift` +- Create: `tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift` + +**Interfaces:** +- Consumes: `OpenAPIKit30.OpenAPI.Document`/`JSONSchema` (external dependency). +- Produces: `IRDocument`, `IRSchema`, `IRSchemaKind`, `IRProperty`, `IRType`, `UnsupportedSpecConstruct`, and `OpenAPIParsing.parseNamedSchema(name:schema:)` — all used by every later parsing/emitting task. + +- [ ] **Step 1: Write the failing test** + +```swift +// +// SchemaParsingTests.swift +// + +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct SchemaParsingTests { + + @Test + func parsesObjectSchemaWithRequiredAndOptionalProperties() throws { + let json = """ + { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string", "nullable": true}, + "size": {"type": "integer"} + } + } + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + let irSchema = try OpenAPIParsing.parseNamedSchema(name: "widgetSchema", schema: schema) + + #expect(irSchema.name == "widgetSchema") + guard case .object(let properties) = irSchema.kind else { + Issue.record("expected an object schema") + return + } + let byName = Dictionary(uniqueKeysWithValues: properties.map { ($0.name, $0) }) + #expect(byName["id"]?.type == .string) + #expect(byName["id"]?.isOptional == false) + #expect(byName["name"]?.isOptional == true) + #expect(byName["size"]?.type == .integer) + #expect(byName["size"]?.isOptional == true) + } + + @Test + func parsesStringEnumSchema() throws { + let json = """ + {"type": "string", "enum": ["public", "private"]} + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + let irSchema = try OpenAPIParsing.parseNamedSchema(name: "visibility", schema: schema) + + #expect(irSchema.name == "visibility") + #expect(irSchema.kind == .stringEnum(cases: ["public", "private"])) + } + + @Test + func rejectsNonObjectNonEnumTopLevelSchema() throws { + let json = #"{"type": "integer"}"# + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseNamedSchema(name: "count", schema: schema) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --package-path tools/openapi-codegen --filter SchemaParsingTests` +Expected: FAIL to compile (`IRDocument`/`OpenAPIParsing` not defined) + +- [ ] **Step 3: Write `IR.swift`** + +```swift +// +// IR.swift +// + +/// Intermediate representation of an OpenAPI document, decoupled from +/// OpenAPIKit's types so the emitter doesn't need to know anything about +/// OpenAPI parsing. +struct IRDocument: Equatable { + var schemas: [IRSchema] + var operations: [IROperation] +} + +struct IRSchema: Equatable { + var name: String + var kind: IRSchemaKind +} + +enum IRSchemaKind: Equatable { + case object(properties: [IRProperty]) + case stringEnum(cases: [String]) +} + +struct IRProperty: Equatable { + var name: String + var type: IRType + var isOptional: Bool +} + +indirect enum IRType: Equatable { + case string + case integer + case number + case boolean + case array(IRType) + case schemaRef(String) + /// An object with no defined properties (OpenAPI's `{"type": "object"}` with + /// no `properties`) — modeled as `[String: JSONValue]` in emitted Swift. + case freeform +} + +enum IRHTTPMethod: String, Equatable { + case get, put, post, delete, options, head, patch, trace +} + +enum IRParameterLocation: Equatable { + case path, query, header +} + +struct IRParameter: Equatable { + var name: String + var location: IRParameterLocation + var type: IRType + var isOptional: Bool +} + +enum IRRequestBody: Equatable { + case json(IRType) + case multipart(fields: [IRMultipartField]) +} + +struct IRMultipartField: Equatable { + var name: String + var type: IRType + var isFile: Bool +} + +enum IRResponseBody: Equatable { + case none + case json(IRType) + case binary +} + +struct IRResponse: Equatable { + var statusCode: Int + var isError: Bool + var body: IRResponseBody +} + +struct IROperation: Equatable { + var operationId: String + var method: IRHTTPMethod + var path: String + var parameters: [IRParameter] + var requestBody: IRRequestBody? + var responses: [IRResponse] +} +``` + +- [ ] **Step 4: Write `OpenAPIParsing.swift` (schema parsing only for now)** + +```swift +// +// OpenAPIParsing.swift +// + +import OpenAPIKit30 + +/// Thrown when the input spec uses a construct this generator deliberately +/// doesn't support (see the plan's Global Constraints for the full list). +/// Never guess at an unfamiliar shape — fail with the exact location instead. +struct UnsupportedSpecConstruct: Error, CustomStringConvertible, Equatable { + var location: String + var reason: String + + var description: String { "Unsupported OpenAPI construct at \(location): \(reason)" } +} + +enum OpenAPIParsing { + + // MARK: - Schemas + + static func parseNamedSchema(name: String, schema: JSONSchema) throws -> IRSchema { + if case .string = schema.value, let allowedValues = schema.allowedValues { + let cases = allowedValues.compactMap { $0.value as? String } + return IRSchema(name: name, kind: .stringEnum(cases: cases)) + } + guard case .object(_, let objectContext) = schema.value else { + throw UnsupportedSpecConstruct( + location: "components.schemas.\(name)", + reason: "top-level schema must be an object or a string enum" + ) + } + var properties: [IRProperty] = [] + for (propertyName, propertySchema) in objectContext.properties { + properties.append( + IRProperty( + name: propertyName, + type: try parseType(propertySchema, location: "\(name).\(propertyName)"), + isOptional: !propertySchema.required || propertySchema.nullable + ) + ) + } + return IRSchema(name: name, kind: .object(properties: properties)) + } + + static func parseType(_ schema: JSONSchema, location: String) throws -> IRType { + if case .string = schema.value, schema.allowedValues != nil { + throw UnsupportedSpecConstruct( + location: location, + reason: "inline enum; register it as a named component schema instead" + ) + } + switch schema.value { + case .string: + return .string + case .integer: + return .integer + case .number: + return .number + case .boolean: + return .boolean + case .array(_, let arrayContext): + guard let items = arrayContext.items else { + throw UnsupportedSpecConstruct(location: location, reason: "array schema without 'items'") + } + return .array(try parseType(items, location: location + "[]")) + case .object(_, let objectContext) where objectContext.properties.isEmpty: + return .freeform + case .reference(let reference, _): + guard let name = reference.name else { + throw UnsupportedSpecConstruct( + location: location, reason: "external reference without a resolvable name") + } + return .schemaRef(name) + default: + throw UnsupportedSpecConstruct(location: location, reason: "unsupported schema shape") + } + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `swift test --package-path tools/openapi-codegen --filter SchemaParsingTests` +Expected: PASS (3 tests) + +- [ ] **Step 6: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "feat(codegen): parse object and string-enum schemas into IR" +``` + +--- + +### Task 4: Type parsing extras — arrays, schema refs, freeform, fail-fast + +**Files:** +- Modify: `tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift` (no code change needed — `parseType` from Task 3 already covers these; this task is tests-only, proving the behavior) +- Create: `tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift` + +**Interfaces:** +- Consumes: `OpenAPIParsing.parseType(_:location:)`, `IRType` (from Task 3). + +- [ ] **Step 1: Write the failing tests** + +```swift +// +// TypeParsingTests.swift +// + +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct TypeParsingTests { + + private func schema(_ json: String) throws -> JSONSchema { + try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + } + + @Test + func parsesArrayOfStrings() throws { + let type = try OpenAPIParsing.parseType( + schema(#"{"type": "array", "items": {"type": "string"}}"#), location: "test") + #expect(type == .array(.string)) + } + + @Test + func rejectsArrayWithoutItems() throws { + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseType(try schema(#"{"type": "array"}"#), location: "test") + } + } + + @Test + func parsesSchemaReference() throws { + let type = try OpenAPIParsing.parseType( + schema(#"{"$ref": "#/components/schemas/bucketSchema"}"#), location: "test") + #expect(type == .schemaRef("bucketSchema")) + } + + @Test + func parsesFreeformObject() throws { + let type = try OpenAPIParsing.parseType(schema(#"{"type": "object"}"#), location: "test") + #expect(type == .freeform) + } + + @Test + func rejectsInlineObjectWithProperties() throws { + let json = #"{"type": "object", "properties": {"a": {"type": "string"}}}"# + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseType(try schema(json), location: "test") + } + } + + @Test + func rejectsOneOfUnion() throws { + let json = """ + {"oneOf": [{"type": "string"}, {"type": "integer"}]} + """ + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseType(try schema(json), location: "test") + } + } + + @Test + func rejectsInlineEnum() throws { + let json = #"{"type": "string", "enum": ["a", "b"]}"# + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseType(try schema(json), location: "test") + } + } +} +``` + +- [ ] **Step 2: Run to verify current behavior** + +Run: `swift test --package-path tools/openapi-codegen --filter TypeParsingTests` +Expected: PASS (7 tests) — `parseType` from Task 3 already handles all of these; this task exists to lock the behavior down with tests before the emitter starts depending on it. + +- [ ] **Step 3: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "test(codegen): cover array/ref/freeform/fail-fast type parsing" +``` + +--- + +### Task 5: Parameter parsing + +**Files:** +- Modify: `tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift` +- Create: `tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift` + +**Interfaces:** +- Produces: `OpenAPIParsing.parseParameter(_:location:)` — `(Either, OpenAPI.Parameter>, location: String) throws -> IRParameter`. Used by Task 7's operation parsing. + +- [ ] **Step 1: Write the failing test** + +```swift +// +// ParameterParsingTests.swift +// + +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct ParameterParsingTests { + + private func parameter(_ json: String) throws -> Either< + JSONReference, OpenAPI.Parameter + > { + let param = try JSONDecoder().decode(OpenAPI.Parameter.self, from: Data(json.utf8)) + return .b(param) + } + + @Test + func parsesRequiredPathParameter() throws { + let json = """ + {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} + """ + let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + + #expect(irParameter.name == "bucketId") + #expect(irParameter.location == .path) + #expect(irParameter.type == .string) + #expect(irParameter.isOptional == false) + } + + @Test + func parsesOptionalQueryParameter() throws { + let json = """ + {"name": "limit", "in": "query", "schema": {"type": "integer"}} + """ + let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + + #expect(irParameter.location == .query) + #expect(irParameter.type == .integer) + #expect(irParameter.isOptional == true) + } + + @Test + func parsesHeaderParameter() throws { + let json = """ + {"name": "if-none-match", "in": "header", "schema": {"type": "string"}} + """ + let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + + #expect(irParameter.location == .header) + } + + @Test + func rejectsCookieParameter() throws { + let json = """ + {"name": "session", "in": "cookie", "schema": {"type": "string"}} + """ + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseParameter(try parameter(json), location: "op") + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --package-path tools/openapi-codegen --filter ParameterParsingTests` +Expected: FAIL to compile (`parseParameter` not defined) + +- [ ] **Step 3: Add `parseParameter` to `OpenAPIParsing.swift`** + +Append inside the `OpenAPIParsing` enum, after the schema-parsing section: + +```swift + // MARK: - Parameters + + static func parseParameter( + _ either: Either, OpenAPI.Parameter>, + location: String + ) throws -> IRParameter { + guard let parameter = either.parameterValue else { + throw UnsupportedSpecConstruct(location: location, reason: "external parameter reference") + } + let parameterLocation = "\(location).\(parameter.name)" + let irLocation: IRParameterLocation + switch parameter.location { + case .path: irLocation = .path + case .query: irLocation = .query + case .header: irLocation = .header + case .cookie: + throw UnsupportedSpecConstruct( + location: parameterLocation, reason: "cookie parameters aren't supported") + } + guard let schema = parameter.schemaOrContent.schemaValue else { + throw UnsupportedSpecConstruct( + location: parameterLocation, reason: "parameter uses 'content' instead of 'schema'") + } + return IRParameter( + name: parameter.name, + location: irLocation, + type: try parseType(schema, location: parameterLocation), + isOptional: !parameter.required || schema.nullable + ) + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --package-path tools/openapi-codegen --filter ParameterParsingTests` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "feat(codegen): parse path/query/header parameters into IR" +``` + +--- + +### Task 6: Request body parsing (JSON + multipart) + +**Files:** +- Modify: `tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift` +- Create: `tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift` + +**Interfaces:** +- Produces: `OpenAPIParsing.parseRequestBody(_:location:)` — `(Either, OpenAPI.Request>, location: String) throws -> IRRequestBody`. Used by Task 7. +- Produces: `OpenAPIParsing.resolveSchema(_:location:)` — `(Either, JSONSchema>, location: String) throws -> IRType`. Used by Task 6 and Task 7. + +- [ ] **Step 1: Write the failing test** + +```swift +// +// RequestBodyParsingTests.swift +// + +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct RequestBodyParsingTests { + + private func requestBody(_ json: String) throws -> Either< + JSONReference, OpenAPI.Request + > { + let request = try JSONDecoder().decode(OpenAPI.Request.self, from: Data(json.utf8)) + return .b(request) + } + + @Test + func parsesJSONRequestBody() throws { + let json = """ + { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/bucketUpdate"}} + } + } + """ + let body = try OpenAPIParsing.parseRequestBody(requestBody(json), location: "updateBucket") + + #expect(body == .json(.schemaRef("bucketUpdate"))) + } + + @Test + func parsesMultipartRequestBodyWithFileField() throws { + let json = """ + { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "cacheControl": {"type": "string"}, + "": {"type": "string", "format": "binary"} + } + } + } + } + } + """ + let body = try OpenAPIParsing.parseRequestBody(requestBody(json), location: "createObject") + + guard case .multipart(let fields) = body else { + Issue.record("expected a multipart request body") + return + } + let byName = Dictionary(uniqueKeysWithValues: fields.map { ($0.name, $0) }) + #expect(byName["cacheControl"]?.isFile == false) + #expect(byName[""]?.isFile == true) + } + + @Test + func rejectsUnsupportedContentType() throws { + let json = """ + {"content": {"text/plain": {"schema": {"type": "string"}}}} + """ + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseRequestBody(try requestBody(json), location: "op") + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --package-path tools/openapi-codegen --filter RequestBodyParsingTests` +Expected: FAIL to compile (`parseRequestBody` not defined) + +- [ ] **Step 3: Add `resolveSchema` and `parseRequestBody` to `OpenAPIParsing.swift`** + +Append inside the `OpenAPIParsing` enum: + +```swift + // MARK: - Schema references in content bodies + + static func resolveSchema( + _ either: Either, JSONSchema>, + location: String + ) throws -> IRType { + switch either { + case .a(let reference): + guard let name = reference.name else { + throw UnsupportedSpecConstruct( + location: location, reason: "external schema reference without a resolvable name") + } + return .schemaRef(name) + case .b(let schema): + return try parseType(schema, location: location) + } + } + + // MARK: - Request bodies + + static func parseRequestBody( + _ either: Either, OpenAPI.Request>, + location: String + ) throws -> IRRequestBody { + guard let request = either.requestValue else { + throw UnsupportedSpecConstruct(location: location, reason: "external request body reference") + } + if let jsonContent = request.content.first(where: { $0.key.typeAndSubtype == "application/json" })?.value { + guard let schema = jsonContent.schema else { + throw UnsupportedSpecConstruct(location: location, reason: "JSON request body without a schema") + } + return .json(try resolveSchema(schema, location: location)) + } + if let multipartContent = request.content.first(where: { + $0.key.typeAndSubtype == "multipart/form-data" + })?.value { + guard let schemaEither = multipartContent.schema, case .b(let objectSchema) = schemaEither, + case .object(_, let objectContext) = objectSchema.value + else { + throw UnsupportedSpecConstruct( + location: location, reason: "multipart request body must be an inline object schema") + } + var fields: [IRMultipartField] = [] + for (fieldName, fieldSchema) in objectContext.properties { + var isFile = false + if case .string(let core, _) = fieldSchema.value, core.format == .binary { + isFile = true + } + fields.append( + IRMultipartField( + name: fieldName, + type: try parseType(fieldSchema, location: "\(location).\(fieldName)"), + isFile: isFile + ) + ) + } + return .multipart(fields: fields) + } + let contentTypes = request.content.keys.map(\.rawValue).joined(separator: ", ") + throw UnsupportedSpecConstruct( + location: location, reason: "unsupported request body content type(s): \(contentTypes)") + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --package-path tools/openapi-codegen --filter RequestBodyParsingTests` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "feat(codegen): parse JSON and multipart request bodies into IR" +``` + +--- + +### Task 7: Response parsing + full document parsing + +**Files:** +- Modify: `tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift` +- Create: `tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift` + +**Interfaces:** +- Produces: `OpenAPIParsing.parseResponses(_:location:)`, `OpenAPIParsing.parseOperations(_:)`, `OpenAPIParsing.parseDocument(_:)` — `(OpenAPI.Document) throws -> IRDocument`. `parseDocument` is the tool's single public entry point, used by Task 11's CLI. + +- [ ] **Step 1: Write the failing test** + +```swift +// +// ResponseParsingTests.swift +// + +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct ResponseParsingTests { + + @Test + func parsesSuccessAndErrorResponses() throws { + let json = """ + { + "200": { + "description": "ok", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/bucketSchema"}}} + }, + "404": { + "description": "not found", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/errorSchema"}}} + } + } + """ + let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) + + let irResponses = try OpenAPIParsing.parseResponses(responses, location: "getBucket") + + #expect(irResponses.count == 2) + #expect(irResponses[0].statusCode == 200) + #expect(irResponses[0].isError == false) + #expect(irResponses[0].body == .json(.schemaRef("bucketSchema"))) + #expect(irResponses[1].statusCode == 404) + #expect(irResponses[1].isError == true) + #expect(irResponses[1].body == .json(.schemaRef("errorSchema"))) + } + + @Test + func parsesBinaryResponseBody() throws { + let json = """ + { + "200": { + "description": "ok", + "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}} + } + } + """ + let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) + + let irResponses = try OpenAPIParsing.parseResponses(responses, location: "download") + + #expect(irResponses[0].body == .binary) + } + + @Test + func skipsDefaultAndRangeStatusEntries() throws { + let json = """ + { + "200": {"description": "ok", "content": {}}, + "default": {"description": "generic error", "content": {}} + } + """ + let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) + + let irResponses = try OpenAPIParsing.parseResponses(responses, location: "op") + + #expect(irResponses.count == 1) + #expect(irResponses[0].statusCode == 200) + } + + @Test + func parsesFullDocumentEndToEnd() throws { + let json = """ + { + "openapi": "3.0.3", + "info": {"title": "Storage", "version": "1.0.0"}, + "paths": { + "/bucket/{bucketId}": { + "get": { + "operationId": "getBucket", + "parameters": [ + {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/bucketSchema"}} + } + } + } + } + } + }, + "components": { + "schemas": { + "bucketSchema": { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}} + } + } + } + } + """ + let document = try JSONDecoder().decode(OpenAPI.Document.self, from: Data(json.utf8)) + + let irDocument = try OpenAPIParsing.parseDocument(document) + + #expect(irDocument.schemas.map(\.name) == ["bucketSchema"]) + #expect(irDocument.operations.count == 1) + #expect(irDocument.operations[0].operationId == "getBucket") + #expect(irDocument.operations[0].method == .get) + #expect(irDocument.operations[0].path == "/bucket/{bucketId}") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --package-path tools/openapi-codegen --filter ResponseParsingTests` +Expected: FAIL to compile (`parseResponses`/`parseDocument` not defined) + +- [ ] **Step 3: Add response and document parsing to `OpenAPIParsing.swift`** + +Append inside the `OpenAPIParsing` enum: + +```swift + // MARK: - Responses + + static func parseResponses( + _ responses: OpenAPI.Response.Map, + location: String + ) throws -> [IRResponse] { + var results: [IRResponse] = [] + for (statusCode, responseEither) in responses { + guard case .status(let code) = statusCode.value else { + // `default` and range ("4XX") responses aren't modeled in v1; skip them. + continue + } + guard let response = responseEither.responseValue else { + throw UnsupportedSpecConstruct( + location: location, reason: "external response reference for status \(code)") + } + let body = try parseResponseBody(response.content, location: "\(location) -> \(code)") + results.append(IRResponse(statusCode: code, isError: !statusCode.isSuccess, body: body)) + } + return results.sorted { $0.statusCode < $1.statusCode } + } + + static func parseResponseBody( + _ content: OpenAPI.Content.Map, + location: String + ) throws -> IRResponseBody { + if let jsonContent = content.first(where: { $0.key.typeAndSubtype == "application/json" })?.value { + guard let schema = jsonContent.schema else { return .none } + return .json(try resolveSchema(schema, location: location)) + } + return content.isEmpty ? .none : .binary + } + + // MARK: - Operations + + static func parseOperations(_ document: OpenAPI.Document) throws -> [IROperation] { + var operations: [IROperation] = [] + for (path, pathItemEither) in document.paths { + guard let pathItem = pathItemEither.pathItemValue else { + throw UnsupportedSpecConstruct(location: path.rawValue, reason: "external path item reference") + } + let methodOperations: [(IRHTTPMethod, OpenAPI.Operation?)] = [ + (.get, pathItem.get), + (.put, pathItem.put), + (.post, pathItem.post), + (.delete, pathItem.delete), + (.options, pathItem.options), + (.head, pathItem.head), + (.patch, pathItem.patch), + (.trace, pathItem.trace), + ] + for (method, maybeOperation) in methodOperations { + guard let operation = maybeOperation else { continue } + let operationLocation = "\(method.rawValue.uppercased()) \(path.rawValue)" + guard let operationId = operation.operationId else { + throw UnsupportedSpecConstruct(location: operationLocation, reason: "missing operationId") + } + var parameters: [IRParameter] = [] + for parameterEither in pathItem.parameters + operation.parameters { + parameters.append(try parseParameter(parameterEither, location: operationId)) + } + let requestBody = try operation.requestBody.map { + try parseRequestBody($0, location: operationId) + } + let responses = try parseResponses(operation.responses, location: operationId) + operations.append( + IROperation( + operationId: operationId, + method: method, + path: path.rawValue, + parameters: parameters, + requestBody: requestBody, + responses: responses + ) + ) + } + } + return operations.sorted { $0.operationId < $1.operationId } + } + + // MARK: - Document + + static func parseDocument(_ document: OpenAPI.Document) throws -> IRDocument { + var schemas: [IRSchema] = [] + for (key, schema) in document.components.schemas { + schemas.append(try parseNamedSchema(name: key.rawValue, schema: schema)) + } + return IRDocument( + schemas: schemas.sorted { $0.name < $1.name }, + operations: try parseOperations(document) + ) + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --package-path tools/openapi-codegen --filter ResponseParsingTests` +Expected: PASS (4 tests) + +- [ ] **Step 5: Run the whole parsing test suite together** + +Run: `swift test --package-path tools/openapi-codegen` +Expected: PASS (all tests from Tasks 3-7) + +- [ ] **Step 6: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "feat(codegen): parse responses and wire up full-document parsing" +``` + +--- + +### Task 8: SwiftNames — identifier casing and escaping + +**Files:** +- Create: `tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftNames.swift` +- Create: `tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SwiftNamesTests.swift` + +**Interfaces:** +- Consumes: `IRType` (from Task 3). +- Produces: `SwiftNames.typeName(_:)`, `.propertyName(_:)`, `.typeReference(_:isOptional:)` — used by Tasks 9 and 10. + +- [ ] **Step 1: Write the failing test** + +```swift +// +// SwiftNamesTests.swift +// + +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct SwiftNamesTests { + + @Test + func convertsSnakeCaseToLowerCamelCase() { + #expect(SwiftNames.propertyName("file_size_limit") == "fileSizeLimit") + #expect(SwiftNames.propertyName("id") == "id") + } + + @Test + func escapesReservedWords() { + #expect(SwiftNames.propertyName("public") == "`public`") + #expect(SwiftNames.propertyName("self") == "`self`") + } + + @Test + func convertsSchemaNameToUpperCamelCaseTypeName() { + #expect(SwiftNames.typeName("bucketSchema") == "BucketSchema") + #expect(SwiftNames.typeName("errorSchema") == "ErrorSchema") + } + + @Test + func rendersTypeReferences() { + #expect(SwiftNames.typeReference(.string, isOptional: false) == "String") + #expect(SwiftNames.typeReference(.integer, isOptional: true) == "Int?") + #expect(SwiftNames.typeReference(.array(.string), isOptional: false) == "[String]") + #expect(SwiftNames.typeReference(.schemaRef("bucketSchema"), isOptional: false) == "BucketSchema") + #expect(SwiftNames.typeReference(.freeform, isOptional: false) == "[String: JSONValue]") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --package-path tools/openapi-codegen --filter SwiftNamesTests` +Expected: FAIL to compile (`SwiftNames` not defined) + +- [ ] **Step 3: Write `SwiftNames.swift`** + +```swift +// +// SwiftNames.swift +// + +enum SwiftNames { + + static let reservedWords: Set = [ + "public", "private", "internal", "fileprivate", "open", + "self", "Self", "class", "struct", "enum", "protocol", + "default", "for", "in", "if", "else", "switch", "case", + "return", "func", "var", "let", "import", "extension", "static", + "true", "false", "nil", "is", "as", "guard", "where", "continue", "break", + "operator", "typealias", "associatedtype", "subscript", "init", "deinit", + ] + + static func typeName(_ raw: String) -> String { + let camel = camelCased(raw) + guard let first = camel.first else { return camel } + return first.uppercased() + camel.dropFirst() + } + + static func propertyName(_ raw: String) -> String { + escape(camelCased(raw)) + } + + private static func camelCased(_ raw: String) -> String { + let parts = raw.split(whereSeparator: { $0 == "_" || $0 == "-" }) + guard let first = parts.first else { return raw } + let rest = parts.dropFirst().map { part -> String in + guard let firstCharacter = part.first else { return String(part) } + return firstCharacter.uppercased() + part.dropFirst() + } + return ([String(first)] + rest).joined() + } + + private static func escape(_ identifier: String) -> String { + reservedWords.contains(identifier) ? "`\(identifier)`" : identifier + } + + static func typeReference(_ type: IRType, isOptional: Bool) -> String { + let base = baseTypeReference(type) + return isOptional ? "\(base)?" : base + } + + static func baseTypeReference(_ type: IRType) -> String { + switch type { + case .string: return "String" + case .integer: return "Int" + case .number: return "Double" + case .boolean: return "Bool" + case .array(let element): return "[\(baseTypeReference(element))]" + case .schemaRef(let name): return typeName(name) + case .freeform: return "[String: JSONValue]" + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --package-path tools/openapi-codegen --filter SwiftNamesTests` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "feat(codegen): add Swift identifier casing/escaping helpers" +``` + +--- + +### Task 9: Swift emitter — models + +**Files:** +- Create: `tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift` +- Create: `tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift` + +**Interfaces:** +- Consumes: `IRDocument`, `IRSchema`, `SwiftNames` (from Tasks 3, 8). +- Produces: `SwiftEmitter.emitModels(_:)` — `(IRDocument) -> String`. Used by Task 11's CLI. + +- [ ] **Step 1: Write the failing test** + +```swift +// +// ModelEmitterTests.swift +// + +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct ModelEmitterTests { + + @Test + func emitsStructWithCodingKeys() { + let document = IRDocument( + schemas: [ + IRSchema( + name: "bucketSchema", + kind: .object(properties: [ + IRProperty(name: "id", type: .string, isOptional: false), + IRProperty(name: "file_size_limit", type: .integer, isOptional: true), + ]) + ) + ], + operations: [] + ) + + let output = SwiftEmitter.emitModels(document) + + #expect(output.contains("public struct BucketSchema: Codable, Sendable, Hashable {")) + #expect(output.contains("public var id: String")) + #expect(output.contains("public var fileSizeLimit: Int?")) + #expect(output.contains(#"case fileSizeLimit = "file_size_limit""#)) + } + + @Test + func emitsStringEnum() { + let document = IRDocument( + schemas: [IRSchema(name: "visibility", kind: .stringEnum(cases: ["public", "private"]))], + operations: [] + ) + + let output = SwiftEmitter.emitModels(document) + + #expect(output.contains("public enum Visibility: String, Codable, Sendable, Hashable {")) + #expect(output.contains(#"case `public` = "public""#)) + #expect(output.contains(#"case `private` = "private""#)) + } + + @Test + func marksSchemasReferencedByErrorResponsesAsAPIError() { + let document = IRDocument( + schemas: [ + IRSchema( + name: "errorSchema", + kind: .object(properties: [IRProperty(name: "message", type: .string, isOptional: false)]) + ) + ], + operations: [ + IROperation( + operationId: "getBucket", + method: .get, + path: "/bucket/{id}", + parameters: [], + requestBody: nil, + responses: [ + IRResponse(statusCode: 404, isError: true, body: .json(.schemaRef("errorSchema"))) + ] + ) + ] + ) + + let output = SwiftEmitter.emitModels(document) + + #expect(output.contains("public struct ErrorSchema: Codable, Sendable, Hashable, APIError {")) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --package-path tools/openapi-codegen --filter ModelEmitterTests` +Expected: FAIL to compile (`SwiftEmitter` not defined) + +- [ ] **Step 3: Write `SwiftEmitter.swift` (models section)** + +```swift +// +// SwiftEmitter.swift +// + +enum SwiftEmitter { + + static func emitModels(_ document: IRDocument) -> String { + let errorSchemaNames = self.errorSchemaNames(in: document) + var lines: [String] = [ + "// Code generated by openapi-codegen. DO NOT EDIT.", + "", + "import Foundation", + "import HTTPRuntime", + "", + ] + for schema in document.schemas.sorted(by: { $0.name < $1.name }) { + lines.append(emitSchema(schema, isError: errorSchemaNames.contains(schema.name))) + lines.append("") + } + return lines.joined(separator: "\n") + } + + static func errorSchemaNames(in document: IRDocument) -> Set { + var names: Set = [] + for operation in document.operations { + for response in operation.responses where response.isError { + if case .json(.schemaRef(let name)) = response.body { + names.insert(name) + } + } + } + return names + } + + static func emitSchema(_ schema: IRSchema, isError: Bool) -> String { + let typeName = SwiftNames.typeName(schema.name) + switch schema.kind { + case .object(let properties): + return emitStruct(named: typeName, properties: properties, isError: isError) + case .stringEnum(let cases): + return emitStringEnum(named: typeName, cases: cases, isError: isError) + } + } + + static func emitStruct(named typeName: String, properties: [IRProperty], isError: Bool) -> String { + let conformances = (["Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) + .joined(separator: ", ") + var lines = ["public struct \(typeName): \(conformances) {"] + for property in properties { + let name = SwiftNames.propertyName(property.name) + let type = SwiftNames.typeReference(property.type, isOptional: property.isOptional) + lines.append(" public var \(name): \(type)") + } + lines.append("") + lines.append(" enum CodingKeys: String, CodingKey {") + for property in properties { + let name = SwiftNames.propertyName(property.name) + lines.append(" case \(name) = \"\(property.name)\"") + } + lines.append(" }") + lines.append("}") + return lines.joined(separator: "\n") + } + + static func emitStringEnum(named typeName: String, cases: [String], isError: Bool) -> String { + let conformances = (["String", "Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) + .joined(separator: ", ") + var lines = ["public enum \(typeName): \(conformances) {"] + for value in cases { + lines.append(" case \(SwiftNames.propertyName(value)) = \"\(value)\"") + } + lines.append("}") + return lines.joined(separator: "\n") + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --package-path tools/openapi-codegen --filter ModelEmitterTests` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "feat(codegen): emit Swift models from IR schemas" +``` + +--- + +### Task 10: Swift emitter — client + +**Files:** +- Modify: `tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift` +- Create: `tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift` + +**Interfaces:** +- Produces: `SwiftEmitter.emitClient(_:clientName:)` — `(IRDocument, clientName: String) -> String`. Used by Task 11's CLI. + +- [ ] **Step 1: Write the failing test** + +```swift +// +// ClientEmitterTests.swift +// + +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct ClientEmitterTests { + + @Test + func emitsJSONRoundTripOperation() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "getBucket", + method: .get, + path: "/bucket/{bucketId}", + parameters: [ + IRParameter(name: "bucketId", location: .path, type: .string, isOptional: false) + ], + requestBody: nil, + responses: [ + IRResponse(statusCode: 200, isError: false, body: .json(.schemaRef("bucketSchema"))), + IRResponse(statusCode: 404, isError: true, body: .json(.schemaRef("errorSchema"))), + ] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + #expect(output.contains("public struct StorageOpenAPIClient: Sendable {")) + #expect(output.contains("public func getBucket(bucketId: String) async throws -> BucketSchema {")) + #expect( + output.contains( + "HTTPRequestBuilder(method: .get, baseURL: baseURL, path: \"/bucket/\\(PathEncoding.segment(bucketId))\")" + )) + #expect(output.contains("try response.checkStatus(errorTypes: [404: ErrorSchema.self])")) + #expect(output.contains("return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body)")) + } + + @Test + func emitsMultipartUploadOperation() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "createObject", + method: .post, + path: "/object/{bucketId}", + parameters: [ + IRParameter(name: "bucketId", location: .path, type: .string, isOptional: false) + ], + requestBody: .multipart(fields: [ + IRMultipartField(name: "file", type: .string, isFile: true), + IRMultipartField(name: "cacheControl", type: .string, isFile: false), + ]), + responses: [ + IRResponse(statusCode: 200, isError: false, body: .none) + ] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + #expect(output.contains("file: URL")) + #expect(output.contains("cacheControl: String")) + #expect(output.contains("source: .file(file)")) + #expect(output.contains("builder.setBody(.multipart(formData))")) + } + + @Test + func emitsBinaryDownloadOperation() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "download", + method: .get, + path: "/object/{bucketId}", + parameters: [ + IRParameter(name: "bucketId", location: .path, type: .string, isOptional: false) + ], + requestBody: nil, + responses: [IRResponse(statusCode: 200, isError: false, body: .binary)] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + #expect(output.contains("-> AsyncThrowingStream {")) + #expect(output.contains("transport.stream(try builder.build())")) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --package-path tools/openapi-codegen --filter ClientEmitterTests` +Expected: FAIL to compile (`emitClient` not defined) + +- [ ] **Step 3: Append the client emitter to `SwiftEmitter.swift`** + +```swift + // MARK: - Client + + static func emitClient(_ document: IRDocument, clientName: String) -> String { + let errorSchemaNames = self.errorSchemaNames(in: document) + var lines: [String] = [ + "// Code generated by openapi-codegen. DO NOT EDIT.", + "", + "import Foundation", + "import HTTPRuntime", + "", + "public struct \(clientName): Sendable {", + " private let baseURL: URL", + " private let transport: any HTTPTransport", + "", + " public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) {", + " self.baseURL = baseURL", + " self.transport = transport", + " }", + ] + for operation in document.operations.sorted(by: { $0.operationId < $1.operationId }) { + lines.append("") + lines.append(contentsOf: emitOperation(operation, errorSchemaNames: errorSchemaNames)) + } + lines.append("}") + return lines.joined(separator: "\n") + } + + static func emitOperation(_ operation: IROperation, errorSchemaNames: Set) -> [String] { + let methodName = SwiftNames.propertyName(operation.operationId) + let parameters = operation.parameters.sorted { lhs, rhs in + lhs.isOptional != rhs.isOptional ? !lhs.isOptional : lhs.name < rhs.name + } + var signatureParts = parameters.map { parameter -> String in + let name = SwiftNames.propertyName(parameter.name) + let type = SwiftNames.typeReference(parameter.type, isOptional: parameter.isOptional) + return "\(name): \(type)\(parameter.isOptional ? " = nil" : "")" + } + if let requestBody = operation.requestBody { + signatureParts.append(contentsOf: requestBodyParameters(requestBody)) + } + let successResponse = operation.responses.first { !$0.isError } + let returnType = returnTypeReference(for: successResponse) + + var lines: [String] = [ + " public func \(methodName)(\(signatureParts.joined(separator: ", "))) async throws" + + (returnType.map { " -> \($0)" } ?? "") + " {", + " var builder = HTTPRequestBuilder(method: .\(operation.method.rawValue), baseURL: baseURL, path: \"\(pathTemplate(operation))\")", + ] + for parameter in parameters where parameter.location == .query { + lines.append(" builder.addQuery(\"\(parameter.name)\", \(stringConversionExpression(parameter)))") + } + for parameter in parameters where parameter.location == .header { + lines.append(" builder.setHeader(\"\(parameter.name)\", \(stringConversionExpression(parameter)))") + } + if let requestBody = operation.requestBody { + lines.append(contentsOf: requestBodyLines(requestBody)) + } + if let successResponse, case .binary = successResponse.body { + lines.append(" let stream = try await transport.stream(try builder.build())") + lines.append(" guard stream.head.isSuccess else {") + lines.append( + " throw HTTPError.unexpectedStatus(status: stream.head.status, body: Data())") + lines.append(" }") + lines.append(" return stream.body") + } else { + lines.append(" let response = try await transport.send(try builder.build())") + lines.append( + " try response.checkStatus(errorTypes: \(errorTypesLiteral(operation, errorSchemaNames: errorSchemaNames)))" + ) + if let successResponse, case .json(let type) = successResponse.body { + let typeName = SwiftNames.typeReference(type, isOptional: false) + lines.append(" return try JSONCoding.decoder.decode(\(typeName).self, from: response.body)") + } + } + lines.append(" }") + return lines + } + + static func pathTemplate(_ operation: IROperation) -> String { + var result = operation.path + for parameter in operation.parameters where parameter.location == .path { + let name = SwiftNames.propertyName(parameter.name) + let stringExpr = parameter.type == .string ? name : "String(\(name))" + result = result.replacingOccurrences( + of: "{\(parameter.name)}", with: "\\(PathEncoding.segment(\(stringExpr)))") + } + return result + } + + static func stringConversionExpression(_ parameter: IRParameter) -> String { + let name = SwiftNames.propertyName(parameter.name) + if case .string = parameter.type { return name } + return parameter.isOptional ? "\(name).map(String.init)" : "String(\(name))" + } + + static func requestBodyParameters(_ requestBody: IRRequestBody) -> [String] { + switch requestBody { + case .json(let type): + return ["payload: \(SwiftNames.typeReference(type, isOptional: false))"] + case .multipart(let fields): + return fields.map { field in + let name = SwiftNames.propertyName(field.name) + let type = field.isFile ? "URL" : SwiftNames.typeReference(field.type, isOptional: false) + return "\(name): \(type)" + } + } + } + + static func requestBodyLines(_ requestBody: IRRequestBody) -> [String] { + switch requestBody { + case .json: + return [ + " builder.setHeader(\"Content-Type\", \"application/json\")", + " builder.setBody(.data(try JSONCoding.encoder.encode(payload)))", + ] + case .multipart(let fields): + var lines = [" var formData = MultipartFormData()"] + for field in fields { + let name = SwiftNames.propertyName(field.name) + if field.isFile { + lines.append( + " formData.append(MultipartFormData.Part(name: \"\(field.name)\", filename: \(name).lastPathComponent, contentType: \"application/octet-stream\", source: .file(\(name))))" + ) + } else { + lines.append( + " formData.append(MultipartFormData.Part(name: \"\(field.name)\", source: .data(Data(String(describing: \(name)).utf8))))" + ) + } + } + lines.append(" builder.setHeader(\"Content-Type\", formData.contentType)") + lines.append(" builder.setBody(.multipart(formData))") + return lines + } + } + + static func returnTypeReference(for response: IRResponse?) -> String? { + guard let response else { return nil } + switch response.body { + case .none: return nil + case .json(let type): return SwiftNames.typeReference(type, isOptional: false) + case .binary: return "AsyncThrowingStream" + } + } + + static func errorTypesLiteral(_ operation: IROperation, errorSchemaNames: Set) -> String { + var entries: [String] = [] + for response in operation.responses where response.isError { + if case .json(.schemaRef(let name)) = response.body, errorSchemaNames.contains(name) { + entries.append("\(response.statusCode): \(SwiftNames.typeName(name)).self") + } + } + return "[\(entries.joined(separator: ", "))]" + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --package-path tools/openapi-codegen --filter ClientEmitterTests` +Expected: PASS (3 tests) + +- [ ] **Step 5: Run the full test suite** + +Run: `swift test --package-path tools/openapi-codegen` +Expected: PASS (all tests from Tasks 3-10) + +- [ ] **Step 6: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "feat(codegen): emit a Swift HTTP client from IR operations" +``` + +--- + +### Task 11: CLI wiring + end-to-end fixture test + +**Files:** +- Modify: `tools/openapi-codegen/Sources/openapi-codegen/main.swift` +- Create: `tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift` + +**Interfaces:** +- Produces: the `openapi-codegen` CLI, invoked as `openapi-codegen --spec --output --module `. Used manually in Task 13. + +- [ ] **Step 1: Write the failing end-to-end test** + +This test exercises the full pipeline (`parseDocument` → `emitModels`/`emitClient`) the CLI wraps, without shelling out to the built binary. + +```swift +// +// EndToEndTests.swift +// + +import Foundation +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct EndToEndTests { + + @Test + func generatesModelsAndClientFromAMinimalStorageLikeSpec() throws { + let json = """ + { + "openapi": "3.0.3", + "info": {"title": "Storage", "version": "1.0.0"}, + "paths": { + "/bucket/{bucketId}": { + "get": { + "operationId": "getBucket", + "parameters": [ + {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/bucketSchema"}} + } + }, + "404": { + "description": "not found", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/errorSchema"}} + } + } + } + } + } + }, + "components": { + "schemas": { + "bucketSchema": { + "type": "object", + "required": ["id", "public"], + "properties": { + "id": {"type": "string"}, + "public": {"type": "boolean"} + } + }, + "errorSchema": { + "type": "object", + "required": ["message"], + "properties": {"message": {"type": "string"}} + } + } + } + } + """ + let document = try JSONDecoder().decode(OpenAPI.Document.self, from: Data(json.utf8)) + let irDocument = try OpenAPIParsing.parseDocument(document) + + let models = SwiftEmitter.emitModels(irDocument) + let client = SwiftEmitter.emitClient(irDocument, clientName: "StorageOpenAPIClient") + + #expect(models.contains("public struct BucketSchema: Codable, Sendable, Hashable {")) + #expect(models.contains("public var `public`: Bool")) + #expect(models.contains("public struct ErrorSchema: Codable, Sendable, Hashable, APIError {")) + #expect(client.contains("public func getBucket(bucketId: String) async throws -> BucketSchema {")) + #expect(client.contains("errorTypes: [404: ErrorSchema.self]")) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --package-path tools/openapi-codegen --filter EndToEndTests` +Expected: FAIL — `import OpenAPIKit30` in the executable's test target isn't declared yet. + +Add `.product(name: "OpenAPIKit30", package: "OpenAPIKit")` to the `openapi-codegenTests` target's `dependencies` in `tools/openapi-codegen/Package.swift`, then re-run. +Expected: FAIL — assertions fail because `main.swift` doesn't exercise this path yet (this test targets `OpenAPICodegenCore` directly, so it should actually compile and pass once the dependency is added; if any assertion fails, fix the emitter/parser code from Tasks 3-10, not this test). + +- [ ] **Step 3: Run again after adding the dependency** + +Run: `swift test --package-path tools/openapi-codegen --filter EndToEndTests` +Expected: PASS + +- [ ] **Step 4: Write the real CLI in `main.swift`** + +```swift +// +// main.swift +// + +import Foundation +import OpenAPICodegenCore +import OpenAPIKit30 + +struct CLIError: Error, CustomStringConvertible { + var description: String +} + +func parseArguments(_ arguments: [String]) throws -> (spec: URL, output: URL, module: String) { + var spec: String? + var output: String? + var module: String? + var index = 0 + while index < arguments.count { + switch arguments[index] { + case "--spec": + index += 1 + spec = arguments[index] + case "--output": + index += 1 + output = arguments[index] + case "--module": + index += 1 + module = arguments[index] + default: + throw CLIError(description: "unknown argument: \(arguments[index])") + } + index += 1 + } + guard let spec, let output, let module else { + throw CLIError(description: "usage: openapi-codegen --spec --output --module ") + } + return ( + URL(fileURLWithPath: spec), URL(fileURLWithPath: output, isDirectory: true), module + ) +} + +let (specURL, outputURL, moduleName) = try parseArguments(Array(CommandLine.arguments.dropFirst())) + +let data = try Data(contentsOf: specURL) +let document = try JSONDecoder().decode(OpenAPI.Document.self, from: data) +let irDocument = try OpenAPIParsing.parseDocument(document) + +try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + +let models = SwiftEmitter.emitModels(irDocument) +try models.write(to: outputURL.appendingPathComponent("Models.swift"), atomically: true, encoding: .utf8) + +let clientName = "\(moduleName)Client" +let client = SwiftEmitter.emitClient(irDocument, clientName: clientName) +try client.write( + to: outputURL.appendingPathComponent("\(clientName).swift"), atomically: true, encoding: .utf8) + +print("Generated \(irDocument.schemas.count) schemas and \(irDocument.operations.count) operations into \(outputURL.path)") +``` + +- [ ] **Step 5: Run the tool against the end-to-end fixture manually** + +```bash +mkdir -p /tmp/openapi-codegen-smoke +cat > /tmp/openapi-codegen-smoke/spec.json <<'EOF' +{ + "openapi": "3.0.3", + "info": {"title": "Smoke", "version": "1.0.0"}, + "paths": { + "/bucket/{bucketId}": { + "get": { + "operationId": "getBucket", + "parameters": [ + {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": { + "200": { + "description": "ok", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/bucketSchema"}}} + } + } + } + } + }, + "components": { + "schemas": { + "bucketSchema": { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}} + } + } + } +} +EOF +swift run --package-path tools/openapi-codegen openapi-codegen \ + --spec /tmp/openapi-codegen-smoke/spec.json \ + --output /tmp/openapi-codegen-smoke/out \ + --module Smoke +cat /tmp/openapi-codegen-smoke/out/Models.swift +cat /tmp/openapi-codegen-smoke/out/SmokeClient.swift +``` + +Expected: prints `Generated 1 schemas and 1 operations into ...`, and both files contain the expected `BucketSchema` struct and `SmokeClient.getBucket` method. + +- [ ] **Step 6: Commit** + +```bash +git add tools/openapi-codegen +git commit -m "feat(codegen): wire up the openapi-codegen CLI" +``` + +--- + +### Task 12: Acquire and commit `openapi/storage.json` + +**Files:** +- Create: `openapi/storage.json` + +**Interfaces:** +- Produces: the input spec Task 13 generates from. + +- [ ] **Step 1: Generate the spec from storage's PR branch** + +Storage doesn't publish a static OpenAPI file — it's produced on demand by a script. Check out the branch from [storage#1215](https://github.com/supabase/storage/pull/1215) and export it: + +```bash +git clone --depth 1 --branch claude/reverent-ramanujan-32755f https://github.com/supabase/storage.git /tmp/storage-spec-export +cd /tmp/storage-spec-export +npm ci +npm run docs:export +``` + +Expected: this produces `static/api.json` (confirm the exact output path by checking `src/scripts/export-docs.ts` if it differs) inside `/tmp/storage-spec-export`. + +- [ ] **Step 2: Copy and validate the spec** + +```bash +cp /tmp/storage-spec-export/static/api.json /openapi/storage.json +python3 -m json.tool /openapi/storage.json > /dev/null +``` + +Expected: no output from `json.tool` (valid JSON); replace `` with this repository's absolute path. + +- [ ] **Step 3: Confirm the tool can parse it without error** + +```bash +swift run --package-path tools/openapi-codegen openapi-codegen \ + --spec openapi/storage.json \ + --output /tmp/storage-openapi-dry-run \ + --module StorageOpenAPI +``` + +Expected: prints `Generated N schemas and M operations into /tmp/storage-openapi-dry-run` with no thrown `UnsupportedSpecConstruct`. If it throws, read the error's `location`/`reason`, decide whether the parser needs a small extension or the spec has a construct genuinely out of v1 scope, and note the decision in the commit message — don't silently loosen the fail-fast check without recording why. + +- [ ] **Step 4: Commit the spec** + +```bash +git add openapi/storage.json +git commit -m "docs(storage): commit the OpenAPI spec fixed by storage#1215" +``` + +--- + +### Task 13: Generate `Sources/StorageOpenAPI` and wire it into the main package + +**Files:** +- Create: `Sources/StorageOpenAPI/Models.swift` +- Create: `Sources/StorageOpenAPI/StorageOpenAPIClient.swift` +- Modify: `Package.swift` + +**Interfaces:** +- Produces: `StorageOpenAPI` target (public types + `StorageOpenAPIClient`), consumed by Task 14's tests. + +- [ ] **Step 1: Generate the client** + +```bash +swift run --package-path tools/openapi-codegen openapi-codegen \ + --spec openapi/storage.json \ + --output Sources/StorageOpenAPI \ + --module StorageOpenAPI +``` + +- [ ] **Step 2: Register the `StorageOpenAPI` target in `Package.swift`** + +Add this block right after the `StorageTests` target (grouped with the rest of the Storage-related targets): + +```swift + .target( + name: "StorageOpenAPI", + dependencies: [ + "HTTPRuntime" + ] + ), + .testTarget( + name: "StorageOpenAPITests", + dependencies: [ + "StorageOpenAPI", + "HTTPRuntime", + ] + ), +``` + +Add `"StorageOpenAPITests"` to the `swift6TestTargets` set: + +```swift +let swift6TestTargets: Set = [ + "SupabaseTests", "HelpersTests", "HTTPRuntimeTests", "StorageOpenAPITests", +] +``` + +- [ ] **Step 3: Format the generated files and build** + +```bash +./scripts/format.sh +swift build --target StorageOpenAPI +``` + +Expected: `Build complete!`. If it fails, the error will point at a specific emitted construct — fix the relevant `SwiftEmitter`/`OpenAPIParsing` function in `tools/openapi-codegen` (not by hand-editing the generated file, which gets overwritten next run), regenerate, and rebuild. + +- [ ] **Step 4: Commit** + +```bash +git add Sources/StorageOpenAPI Package.swift +git commit -m "feat(storage): generate the OpenAPI-based Storage client" +``` + +--- + +### Task 14: Tests proving the generated client works + +**Files:** +- Create: `Tests/StorageOpenAPITests/BucketOperationsTests.swift` +- Create: `Tests/StorageOpenAPITests/ErrorDecodingTests.swift` + +**Interfaces:** +- Consumes: `StorageOpenAPIClient`, generated model types from `Sources/StorageOpenAPI` (Task 13), `HTTPTransport`/`HTTPResponse`/`HTTPResponseHead` from `HTTPRuntime` (Task 1). + +This task's exact operation/type names depend on what Task 13 actually generated from the real spec — read `Sources/StorageOpenAPI/StorageOpenAPIClient.swift` first to confirm the method name for "get a bucket" (expected: `getBucket(bucketId:)`, per `storage#1215`'s documented `operationId`s) and the 404 error type (expected: `ErrorSchema`) before writing these tests. Adjust names below if the real spec produced something different — do not change the generator to match a guess made before reading its actual output. + +- [ ] **Step 1: Write a fake `HTTPTransport` shared by these tests** + +```swift +// +// BucketOperationsTests.swift +// + +import Foundation +import HTTPRuntime +import Testing + +@testable import StorageOpenAPI + +private struct FakeTransport: HTTPTransport { + var onSend: @Sendable (HTTPRequest) throws -> HTTPResponse + + func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws -> HTTPResponse { + try onSend(request) + } + + func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { + let response = try onSend(request) + return HTTPResponseStream( + head: response.head, + body: AsyncThrowingStream { continuation in + continuation.yield(response.body) + continuation.finish() + } + ) + } +} + +@Suite +struct BucketOperationsTests { + + @Test + func getBucketDecodesASuccessResponse() async throws { + let responseBody = Data(#"{"id":"avatars","public":true}"#.utf8) + let transport = FakeTransport { request in + #expect(request.url.path.hasSuffix("/bucket/avatars")) + return HTTPResponse(head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) + } + let client = StorageOpenAPIClient(baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) + + let bucket = try await client.getBucket(bucketId: "avatars") + + #expect(bucket.id == "avatars") + #expect(bucket.public == true) + } +} +``` + +- [ ] **Step 2: Run to verify it fails or passes as expected** + +Run: `swift test --filter StorageOpenAPITests` +Expected: PASS if `getBucket`/`BucketSchema.id`/`.public` match the real generated names exactly; if a name differs (e.g. the spec's actual property is `public` but got escaped differently, or the operationId differs), fix this test to match the generated code's real names — the generator mirrors the spec verbatim, so the spec is the source of truth here, not this test's guess. + +- [ ] **Step 3: Write the multipart upload test** + +```swift +// +// ObjectUploadTests.swift +// + +import Foundation +import HTTPRuntime +import Testing + +@testable import StorageOpenAPI + +@Suite +struct ObjectUploadTests { + + @Test + func createObjectSendsAMultipartBody() async throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("upload-\(UUID().uuidString).txt") + try Data("hello".utf8).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let transport = FakeTransport { request in + guard case .multipart(let formData) = request.body else { + Issue.record("expected a multipart request body") + return HTTPResponse(head: HTTPResponseHead(status: 500, headers: [:]), body: Data()) + } + #expect(request.headers["Content-Type"] == formData.contentType) + return HTTPResponse(head: HTTPResponseHead(status: 200, headers: [:]), body: Data()) + } + let client = StorageOpenAPIClient( + baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) + + // Read Sources/StorageOpenAPI/StorageOpenAPIClient.swift to confirm the exact + // generated signature for the object-creation operation before finalizing this call. + _ = try await client.createObject(bucketId: "avatars", wildcard: "a.txt", file: fileURL) + } +} +``` + +- [ ] **Step 4: Run and reconcile against the real generated signature** + +Run: `swift test --filter StorageOpenAPITests` +Expected: fix the call site to match whatever `Sources/StorageOpenAPI/StorageOpenAPIClient.swift` actually generated (parameter names/order come straight from the spec's path params + multipart fields). + +- [ ] **Step 5: Write the typed-error decoding test** + +```swift +// +// ErrorDecodingTests.swift +// + +import Foundation +import HTTPRuntime +import Testing + +@testable import StorageOpenAPI + +@Suite +struct ErrorDecodingTests { + + @Test + func getBucketThrowsATypedErrorOn404() async throws { + let errorBody = Data(#"{"message":"Bucket not found","error":"not_found","statusCode":"404"}"#.utf8) + let transport = FakeTransport { _ in + HTTPResponse(head: HTTPResponseHead(status: 404, headers: [:]), body: errorBody) + } + let client = StorageOpenAPIClient( + baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) + + await #expect(throws: ErrorSchema.self) { + _ = try await client.getBucket(bucketId: "missing") + } + } +} +``` + +- [ ] **Step 6: Run and reconcile against the real `errorSchema` shape** + +Run: `swift test --filter StorageOpenAPITests` +Expected: fix `errorBody`'s JSON keys to match whatever properties the real `errorSchema` component declares (read `Sources/StorageOpenAPI/Models.swift`'s `ErrorSchema` struct) — then PASS. + +- [ ] **Step 7: Format and commit** + +```bash +./scripts/format.sh +git add Tests/StorageOpenAPITests +git commit -m "test(storage): cover the generated OpenAPI client's core paths" +``` + +--- + +### Task 15: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full main-package test suite the way this repo's contributors do** + +```bash +PLATFORM=IOS XCODEBUILD_ARGUMENT=test ./scripts/xcodebuild.sh +``` + +Expected: all tests pass, including `HTTPRuntimeTests` and `StorageOpenAPITests`, with no regressions in existing modules (`Auth`, `Storage`, etc. — untouched by this plan). + +- [ ] **Step 2: Run the standalone tool's test suite** + +```bash +swift test --package-path tools/openapi-codegen +``` + +Expected: all tests pass. + +- [ ] **Step 3: Confirm formatting is clean** + +```bash +./scripts/format.sh +git status --porcelain +``` + +Expected: no output (nothing left unformatted/uncommitted from the main package). `tools/openapi-codegen` is a separate package not covered by this repo's `swift-format` config — skip it here. + +- [ ] **Step 4: Spell-check the new files** + +```bash +npm ci --prefix tools/node +./scripts/spell-check.sh +``` + +Expected: passes, or add any new legitimate technical terms (e.g. `openapi`, schema names) to `dictionary.txt`. + +- [ ] **Step 5: Confirm nothing outside this plan's scope changed** + +```bash +git diff --stat main...HEAD +``` + +Expected: only files under `openapi/`, `tools/openapi-codegen/`, `Sources/HTTPRuntime/`, `Sources/StorageOpenAPI/`, `Tests/HTTPRuntimeTests/`, `Tests/StorageOpenAPITests/`, `docs/superpowers/`, and `Package.swift`. `StorageFileApi.swift`/`StorageBucketApi.swift`/`Types.swift`/`SupabaseStorage.swift` must be untouched, per this plan's explicit scope boundary. From ec4f24b403bb9bdcdd50532e17efd201628d7ae3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 12:34:38 -0300 Subject: [PATCH 03/44] feat(runtime): copy zero-dependency HTTPRuntime from the codegen spike --- Package.swift | 24 ++- Sources/HTTPRuntime/HTTPError.swift | 43 +++++ Sources/HTTPRuntime/HTTPMethod.swift | 17 ++ Sources/HTTPRuntime/HTTPRequest.swift | 100 ++++++++++++ Sources/HTTPRuntime/HTTPResponse.swift | 58 +++++++ Sources/HTTPRuntime/HTTPTransport.swift | 23 +++ Sources/HTTPRuntime/JSONCoding.swift | 53 ++++++ Sources/HTTPRuntime/JSONValue.swift | 51 ++++++ Sources/HTTPRuntime/MultipartFormData.swift | 92 +++++++++++ Sources/HTTPRuntime/PathEncoding.swift | 27 ++++ Sources/HTTPRuntime/ServerSentEvents.swift | 97 +++++++++++ Sources/HTTPRuntime/TransferProgress.swift | 27 ++++ Sources/HTTPRuntime/URLSessionTransport.swift | 151 ++++++++++++++++++ Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift | 115 +++++++++++++ 14 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 Sources/HTTPRuntime/HTTPError.swift create mode 100644 Sources/HTTPRuntime/HTTPMethod.swift create mode 100644 Sources/HTTPRuntime/HTTPRequest.swift create mode 100644 Sources/HTTPRuntime/HTTPResponse.swift create mode 100644 Sources/HTTPRuntime/HTTPTransport.swift create mode 100644 Sources/HTTPRuntime/JSONCoding.swift create mode 100644 Sources/HTTPRuntime/JSONValue.swift create mode 100644 Sources/HTTPRuntime/MultipartFormData.swift create mode 100644 Sources/HTTPRuntime/PathEncoding.swift create mode 100644 Sources/HTTPRuntime/ServerSentEvents.swift create mode 100644 Sources/HTTPRuntime/TransferProgress.swift create mode 100644 Sources/HTTPRuntime/URLSessionTransport.swift create mode 100644 Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift diff --git a/Package.swift b/Package.swift index e44d0bfcf..e7d430b6c 100644 --- a/Package.swift +++ b/Package.swift @@ -49,6 +49,15 @@ let package = Package( "Helpers", ] ), + .target( + name: "HTTPRuntime" + ), + .testTarget( + name: "HTTPRuntimeTests", + dependencies: [ + "HTTPRuntime" + ] + ), .target( name: "Auth", dependencies: [ @@ -230,7 +239,7 @@ let package = Package( // Test targets migrated to Swift Testing get full Swift 6 checking, same as // production targets. Everything else stays pinned to v5 until its migration // phase lands (see SDK-435). -let swift6TestTargets: Set = ["SupabaseTests", "HelpersTests"] +let swift6TestTargets: Set = ["SupabaseTests", "HelpersTests", "HTTPRuntimeTests"] for target in package.targets { // Test targets never opted into `ExistentialAny` below, so bumping swift-tools-version @@ -241,6 +250,19 @@ for target in package.targets { continue } + // HTTPRuntime is the zero-dependency HTTP runtime that intentionally exposes + // Foundation types (URL, Data, etc.) in its public API for generated code. + // It doesn't use InternalImportsByDefault since it publicly re-exports Foundation types. + if target.name == "HTTPRuntime" || target.name == "HTTPRuntimeTests" { + target.swiftSettings = [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ImmutableWeakCaptures"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("MemberImportVisibility"), + ] + continue + } + var swiftSettings: [SwiftSetting] = [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("ImmutableWeakCaptures"), diff --git a/Sources/HTTPRuntime/HTTPError.swift b/Sources/HTTPRuntime/HTTPError.swift new file mode 100644 index 000000000..3b8a79da3 --- /dev/null +++ b/Sources/HTTPRuntime/HTTPError.swift @@ -0,0 +1,43 @@ +// +// HTTPError.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// Errors surfaced by the runtime itself (transport/encoding/decoding), as +/// distinct from typed API errors decoded from a response body. +public enum HTTPError: Error, Sendable { + case invalidURL(base: URL, path: String) + case transport(any Error) + case decoding(any Error) + case encoding(any Error) + /// A non-success status whose body did not decode to any modeled error. + case unexpectedStatus(status: Int, body: Data) +} + +/// Marker protocol for generated, typed API errors decoded from a response +/// body for a known status code. +public protocol APIError: Error, Sendable, Decodable {} + +extension HTTPResponse { + /// Validates the status code, decoding a modeled error when the status + /// matches one of the provided error types. Generated code passes the + /// `status -> ErrorType` table declared by the operation's Smithy/TypeSpec + /// error bindings. + public func checkStatus(errorTypes: [Int: any APIError.Type]) throws { + guard !isSuccess else { return } + if let errorType = errorTypes[status] { + do { + let decoded = try JSONCoding.decoder.decode(errorType, from: body) + throw decoded + } catch let error as any APIError { + throw error + } catch { + throw HTTPError.decoding(error) + } + } + throw HTTPError.unexpectedStatus(status: status, body: body) + } +} diff --git a/Sources/HTTPRuntime/HTTPMethod.swift b/Sources/HTTPRuntime/HTTPMethod.swift new file mode 100644 index 000000000..90c9ac6a5 --- /dev/null +++ b/Sources/HTTPRuntime/HTTPMethod.swift @@ -0,0 +1,17 @@ +// +// HTTPMethod.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +// Part of the internal HTTP runtime. NEVER exposed as public SDK surface. + +/// HTTP verbs supported by the generated operations. +public enum HTTPMethod: String, Sendable, Hashable { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" + case head = "HEAD" +} diff --git a/Sources/HTTPRuntime/HTTPRequest.swift b/Sources/HTTPRuntime/HTTPRequest.swift new file mode 100644 index 000000000..c51d09baf --- /dev/null +++ b/Sources/HTTPRuntime/HTTPRequest.swift @@ -0,0 +1,100 @@ +// +// HTTPRequest.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// The body of an outgoing request. +/// +/// `.file` is the key to streaming large uploads without loading them into +/// memory: `URLSession` streams the file from disk. `.multipart` assembles a +/// `multipart/form-data` body onto a temporary file and then uploads that file, +/// so even large multipart parts never materialize fully in memory. +public enum HTTPBody: Sendable { + case none + case data(Data) + case file(URL) + case multipart(MultipartFormData) +} + +/// A fully-resolved HTTP request: absolute URL, headers, and body. +public struct HTTPRequest: Sendable { + public var method: HTTPMethod + public var url: URL + public var headers: [String: String] + public var body: HTTPBody + + public init( + method: HTTPMethod, + url: URL, + headers: [String: String] = [:], + body: HTTPBody = .none + ) { + self.method = method + self.url = url + self.headers = headers + self.body = body + } +} + +/// Assembles an `HTTPRequest` from a base URL, a path template already filled +/// with path parameters, repeated query items, and headers. +/// +/// Generated code drives this builder; it never constructs `URLComponents` +/// directly. Query values use repeated-key encoding (`?k=a&k=b`) to match the +/// Smithy/OpenAPI list conventions in the specs. +public struct HTTPRequestBuilder: Sendable { + public let method: HTTPMethod + private let baseURL: URL + private let path: String + private var queryItems: [URLQueryItem] = [] + private var headers: [String: String] = [:] + private var body: HTTPBody = .none + + public init(method: HTTPMethod, baseURL: URL, path: String) { + self.method = method + self.baseURL = baseURL + self.path = path + } + + public mutating func addQuery(_ name: String, _ value: String?) { + guard let value else { return } + queryItems.append(URLQueryItem(name: name, value: value)) + } + + public mutating func addQuery(_ name: String, _ values: [String]?) { + guard let values else { return } + for value in values { + queryItems.append(URLQueryItem(name: name, value: value)) + } + } + + public mutating func setHeader(_ name: String, _ value: String?) { + guard let value else { return } + headers[name] = value + } + + public mutating func setBody(_ body: HTTPBody) { + self.body = body + } + + public func build() throws -> HTTPRequest { + // Compose by string so slashes inside greedy path params ({path+}) are + // preserved. Generated code percent-encodes individual label values. + var base = baseURL.absoluteString + if base.hasSuffix("/") { base.removeLast() } + let prefixedPath = path.hasPrefix("/") ? path : "/" + path + guard var components = URLComponents(string: base + prefixedPath) else { + throw HTTPError.invalidURL(base: baseURL, path: path) + } + if !queryItems.isEmpty { + components.queryItems = queryItems + } + guard let url = components.url else { + throw HTTPError.invalidURL(base: baseURL, path: path) + } + return HTTPRequest(method: method, url: url, headers: headers, body: body) + } +} diff --git a/Sources/HTTPRuntime/HTTPResponse.swift b/Sources/HTTPRuntime/HTTPResponse.swift new file mode 100644 index 000000000..0d47d64be --- /dev/null +++ b/Sources/HTTPRuntime/HTTPResponse.swift @@ -0,0 +1,58 @@ +// +// HTTPResponse.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// The status line and headers of a response, without the body. +/// +/// Used on its own for streaming responses (where the body is delivered +/// separately as an `AsyncSequence`) and embedded in ``HTTPResponse`` for +/// buffered responses. +public struct HTTPResponseHead: Sendable { + public let status: Int + public let headers: [String: String] + + public init(status: Int, headers: [String: String]) { + self.status = status + self.headers = headers + } + + public func header(_ name: String) -> String? { + // HTTP header names are case-insensitive. + if let exact = headers[name] { return exact } + let lowered = name.lowercased() + return headers.first { $0.key.lowercased() == lowered }?.value + } + + public var isSuccess: Bool { (200..<300).contains(status) } +} + +/// A fully-buffered response. +public struct HTTPResponse: Sendable { + public let head: HTTPResponseHead + public let body: Data + + public init(head: HTTPResponseHead, body: Data) { + self.head = head + self.body = body + } + + public var status: Int { head.status } + public func header(_ name: String) -> String? { head.header(name) } + public var isSuccess: Bool { head.isSuccess } +} + +/// A streaming response: the head arrives first, the body is an async sequence +/// of `Data` chunks (used for large downloads and event streams). +public struct HTTPResponseStream: Sendable { + public let head: HTTPResponseHead + public let body: AsyncThrowingStream + + public init(head: HTTPResponseHead, body: AsyncThrowingStream) { + self.head = head + self.body = body + } +} diff --git a/Sources/HTTPRuntime/HTTPTransport.swift b/Sources/HTTPRuntime/HTTPTransport.swift new file mode 100644 index 000000000..1f2118b6c --- /dev/null +++ b/Sources/HTTPRuntime/HTTPTransport.swift @@ -0,0 +1,23 @@ +// +// HTTPTransport.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +/// The abstraction generated clients depend on. Kept deliberately small so the +/// generated code never touches `URLSession` directly and so tests can inject a +/// mock transport. +public protocol HTTPTransport: Sendable { + /// Buffered request/response. + func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws -> HTTPResponse + + /// Streaming response: head first, body as an async sequence of chunks. + /// Used for large downloads and event streams. + func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream +} + +extension HTTPTransport { + public func send(_ request: HTTPRequest) async throws -> HTTPResponse { + try await send(request, uploadProgress: nil) + } +} diff --git a/Sources/HTTPRuntime/JSONCoding.swift b/Sources/HTTPRuntime/JSONCoding.swift new file mode 100644 index 000000000..523c47ae9 --- /dev/null +++ b/Sources/HTTPRuntime/JSONCoding.swift @@ -0,0 +1,53 @@ +// +// JSONCoding.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// Shared JSON coders used by generated code. Dates are ISO-8601 with +/// fractional seconds, matching the mock server and the spec timestamps. +public enum JSONCoding { + public static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, enc in + var container = enc.singleValueContainer() + try container.encode(iso8601.string(from: date)) + } + return encoder + }() + + public static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { dec in + let container = try dec.singleValueContainer() + let string = try container.decode(String.self) + guard let date = iso8601.date(from: string) ?? iso8601NoFraction.date(from: string) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO-8601 date: \(string)" + ) + } + return date + } + return decoder + }() + + /// ISO-8601 string with fractional seconds, for `@httpQuery` timestamp params. + public static func iso8601String(_ date: Date) -> String { + iso8601.string(from: date) + } + + nonisolated(unsafe) private static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + nonisolated(unsafe) private static let iso8601NoFraction: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} diff --git a/Sources/HTTPRuntime/JSONValue.swift b/Sources/HTTPRuntime/JSONValue.swift new file mode 100644 index 000000000..8c4ba4d67 --- /dev/null +++ b/Sources/HTTPRuntime/JSONValue.swift @@ -0,0 +1,51 @@ +// +// JSONValue.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// A free-form JSON value, used for spec `document` / `Record` types. +public enum JSONValue: Codable, Sendable, Hashable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value" + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } +} diff --git a/Sources/HTTPRuntime/MultipartFormData.swift b/Sources/HTTPRuntime/MultipartFormData.swift new file mode 100644 index 000000000..0f9291ae3 --- /dev/null +++ b/Sources/HTTPRuntime/MultipartFormData.swift @@ -0,0 +1,92 @@ +// +// MultipartFormData.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// Builds a `multipart/form-data` body by streaming its parts onto a temporary +/// file, so large file parts never load fully into memory. The resulting file +/// is then uploaded with `URLSession.upload(for:fromFile:)`. +/// +/// This is the runtime's answer to "streaming multipart upload of large files +/// without loading into memory": file parts are copied chunk-by-chunk to the +/// staging file. +public struct MultipartFormData: Sendable { + public struct Part: Sendable { + public enum Source: Sendable { + case data(Data) + case file(URL) + } + public var name: String + public var filename: String? + public var contentType: String? + public var source: Source + + public init(name: String, filename: String? = nil, contentType: String? = nil, source: Source) { + self.name = name + self.filename = filename + self.contentType = contentType + self.source = source + } + } + + public let boundary: String + public private(set) var parts: [Part] + + public init(boundary: String = "Boundary-\(UUID().uuidString)", parts: [Part] = []) { + self.boundary = boundary + self.parts = parts + } + + public mutating func append(_ part: Part) { + parts.append(part) + } + + public var contentType: String { + "multipart/form-data; boundary=\(boundary)" + } + + /// Streams all parts to a temporary file and returns its URL. The caller is + /// responsible for deleting the file after the upload completes. + public func writeToTemporaryFile() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("multipart-\(UUID().uuidString).tmp") + FileManager.default.createFile(atPath: url.path, contents: nil) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + + func write(_ string: String) throws { + try handle.write(contentsOf: Data(string.utf8)) + } + + for part in parts { + try write("--\(boundary)\r\n") + var disposition = "Content-Disposition: form-data; name=\"\(part.name)\"" + if let filename = part.filename { + disposition += "; filename=\"\(filename)\"" + } + try write(disposition + "\r\n") + if let contentType = part.contentType { + try write("Content-Type: \(contentType)\r\n") + } + try write("\r\n") + + switch part.source { + case .data(let data): + try handle.write(contentsOf: data) + case .file(let fileURL): + let reader = try FileHandle(forReadingFrom: fileURL) + defer { try? reader.close() } + // Copy in bounded chunks so a large file never fully buffers. + while case let chunk = reader.readData(ofLength: 64 * 1024), !chunk.isEmpty { + try handle.write(contentsOf: chunk) + } + } + try write("\r\n") + } + try write("--\(boundary)--\r\n") + return url + } +} diff --git a/Sources/HTTPRuntime/PathEncoding.swift b/Sources/HTTPRuntime/PathEncoding.swift new file mode 100644 index 000000000..eb0d9ca14 --- /dev/null +++ b/Sources/HTTPRuntime/PathEncoding.swift @@ -0,0 +1,27 @@ +// +// PathEncoding.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// Percent-encoding for URL path parameters. Generated code calls these when +/// substituting `@httpLabel` values into a path template. +public enum PathEncoding { + private static let segmentAllowed: CharacterSet = { + var set = CharacterSet.urlPathAllowed + set.remove("/") // a single path segment must escape slashes + return set + }() + + /// Encodes a single path segment (escapes `/`). + public static func segment(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? value + } + + /// Encodes a greedy label (`{path+}`) that may legitimately contain `/`. + public static func greedy(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value + } +} diff --git a/Sources/HTTPRuntime/ServerSentEvents.swift b/Sources/HTTPRuntime/ServerSentEvents.swift new file mode 100644 index 000000000..f52b664ae --- /dev/null +++ b/Sources/HTTPRuntime/ServerSentEvents.swift @@ -0,0 +1,97 @@ +// +// ServerSentEvents.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// A single Server-Sent Event frame (`text/event-stream`). +public struct ServerSentEvent: Sendable, Hashable { + public var event: String? + public var data: String + public var id: String? + public var retry: Int? + + public init(event: String? = nil, data: String, id: String? = nil, retry: Int? = nil) { + self.event = event + self.data = data + self.id = id + self.retry = retry + } +} + +extension AsyncThrowingStream where Element == Data, Failure == any Error { + /// Parses this stream of raw byte chunks into SSE frames, splitting on the + /// blank-line frame delimiter and coalescing multi-line `data:` fields per + /// the SSE spec. Generated code maps each frame's `data` (JSON) into the + /// operation's typed event union. + public func serverSentEvents() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + var buffer = Data() + do { + for try await chunk in self { + buffer.append(chunk) + while let frame = Self.extractFrame(&buffer) { + if let event = Self.parseFrame(frame) { + continuation.yield(event) + } + } + } + // Flush any trailing frame without a terminating blank line. + if !buffer.isEmpty, let event = Self.parseFrame(buffer) { + continuation.yield(event) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Pulls one complete frame (bytes up to and including `\n\n`) out of the + /// buffer, or returns nil if no full frame is buffered yet. + private static func extractFrame(_ buffer: inout Data) -> Data? { + let delimiter = Data([0x0A, 0x0A]) // "\n\n" + guard let range = buffer.range(of: delimiter) else { return nil } + let frame = buffer.subdata(in: buffer.startIndex.. ServerSentEvent? { + guard let text = String(data: frame, encoding: .utf8) else { return nil } + var event: String? + var id: String? + var retry: Int? + var dataLines: [String] = [] + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + let line = rawLine.hasSuffix("\r") ? String(rawLine.dropLast()) : String(rawLine) + if line.isEmpty || line.hasPrefix(":") { continue } // comment / blank + let field: Substring + let value: String + if let colon = line.firstIndex(of: ":") { + field = line[line.startIndex.. 0 else { return nil } + return Double(completed) / Double(total) + } +} + +/// A `@Sendable` progress callback invoked as bytes move. +public typealias ProgressHandler = @Sendable (TransferProgress) -> Void diff --git a/Sources/HTTPRuntime/URLSessionTransport.swift b/Sources/HTTPRuntime/URLSessionTransport.swift new file mode 100644 index 000000000..4869d0e8a --- /dev/null +++ b/Sources/HTTPRuntime/URLSessionTransport.swift @@ -0,0 +1,151 @@ +// +// URLSessionTransport.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// The default, zero-dependency `HTTPTransport` backed by `URLSession`. +/// +/// Design notes (and a real URLSession constraint worth recording): +/// - Buffered and streaming requests use the modern async `URLSession` APIs +/// (`upload(for:fromFile:)`, `bytes(for:)`), which keep the code fully +/// async/await + `AsyncSequence` and Sendable-clean. +/// - Uploads stream from a file on disk (`.file`/`.multipart`), so large bodies +/// never fully buffer in memory. Progress is reported via a per-task delegate. +/// - Background sessions are exposed via `init(configuration:)`, BUT the async +/// convenience APIs are not supported on a background `URLSessionConfiguration` +/// — background transfers must use delegate-based `downloadTask`/`uploadTask` +/// that complete out-of-process. That path is documented as a known limitation +/// rather than faked here. +public struct URLSessionTransport: HTTPTransport { + private let session: URLSession + + public init(configuration: URLSessionConfiguration = .default) { + self.session = URLSession(configuration: configuration) + } + + public init(session: URLSession) { + self.session = session + } + + public func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws + -> HTTPResponse + { + let urlRequest = try Self.makeURLRequest(request) + let delegate = uploadProgress.map { ProgressDelegate(onProgress: $0) } + + let data: Data + let response: URLResponse + do { + switch request.body { + case .none: + (data, response) = try await session.data(for: urlRequest, delegate: delegate) + case .data(let payload): + (data, response) = try await session.upload( + for: urlRequest, from: payload, delegate: delegate) + case .file(let fileURL): + (data, response) = try await session.upload( + for: urlRequest, fromFile: fileURL, delegate: delegate) + case .multipart(let form): + let fileURL = try form.writeToTemporaryFile() + defer { try? FileManager.default.removeItem(at: fileURL) } + var withBoundary = urlRequest + withBoundary.setValue(form.contentType, forHTTPHeaderField: "Content-Type") + (data, response) = try await session.upload( + for: withBoundary, fromFile: fileURL, delegate: delegate) + } + } catch { + throw HTTPError.transport(error) + } + return HTTPResponse(head: Self.makeHead(response), body: data) + } + + public func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { + let urlRequest = try Self.makeURLRequest(request) + let bytes: URLSession.AsyncBytes + let response: URLResponse + do { + (bytes, response) = try await session.bytes(for: urlRequest) + } catch { + throw HTTPError.transport(error) + } + + let body = AsyncThrowingStream { continuation in + let task = Task { + do { + var buffer = [UInt8]() + buffer.reserveCapacity(16 * 1024) + for try await byte in bytes { + buffer.append(byte) + // Flush on newline (prompt SSE frame delivery) or when a + // chunk fills up (bounded memory for large downloads). + if byte == 0x0A || buffer.count >= 16 * 1024 { + continuation.yield(Data(buffer)) + buffer.removeAll(keepingCapacity: true) + } + } + if !buffer.isEmpty { continuation.yield(Data(buffer)) } + continuation.finish() + } catch { + continuation.finish(throwing: HTTPError.transport(error)) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return HTTPResponseStream(head: Self.makeHead(response), body: body) + } + + // MARK: - Helpers + + private static func makeURLRequest(_ request: HTTPRequest) throws -> URLRequest { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method.rawValue + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + if case .data(let payload) = request.body { + urlRequest.httpBody = payload + } + return urlRequest + } + + private static func makeHead(_ response: URLResponse) -> HTTPResponseHead { + guard let http = response as? HTTPURLResponse else { + return HTTPResponseHead(status: 0, headers: [:]) + } + var headers: [String: String] = [:] + for (key, value) in http.allHeaderFields { + if let key = key as? String, let value = value as? String { + headers[key] = value + } + } + return HTTPResponseHead(status: http.statusCode, headers: headers) + } +} + +/// Per-task delegate that forwards upload progress. Isolated state is guarded by +/// a lock so it is safe under Swift 6 strict concurrency. +private final class ProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let onProgress: ProgressHandler + + init(onProgress: @escaping ProgressHandler) { + self.onProgress = onProgress + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + let total = totalBytesExpectedToSend > 0 ? totalBytesExpectedToSend : nil + onProgress(TransferProgress(completed: totalBytesSent, total: total)) + } +} diff --git a/Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift b/Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift new file mode 100644 index 000000000..d20b28875 --- /dev/null +++ b/Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift @@ -0,0 +1,115 @@ +// +// HTTPRuntimeTests.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// + +import Foundation +import Testing + +@testable import HTTPRuntime + +@Suite +struct HTTPRuntimeTests { + + @Test + func serverSentEventParsing() async throws { + let raw = """ + event: message + data: {"delta":"hello"} + + event: progress + data: {"percent":50} + + event: done + data: {"total":3} + + + """ + let bytes = AsyncThrowingStream { continuation in + let full = Array(Data(raw.utf8)) + let mid = full.count / 2 + continuation.yield(Data(full[0.. { continuation in + continuation.yield(Data(raw.utf8)) + continuation.finish() + } + var events: [ServerSentEvent] = [] + for try await event in bytes.serverSentEvents() { events.append(event) } + #expect(events.count == 1) + #expect(events[0].data == "line1\nline2") + } + + @Test + func multipartAssemblesToFileWithoutBufferingSource() throws { + let sourceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("src-\(UUID().uuidString).bin") + let payload = Data((0..<200_000).map { UInt8($0 % 256) }) + try payload.write(to: sourceURL) + defer { try? FileManager.default.removeItem(at: sourceURL) } + + var form = MultipartFormData(boundary: "TESTBOUNDARY") + form.append(.init(name: "meta", source: .data(Data(#"{"k":"v"}"#.utf8)))) + form.append( + .init( + name: "file", filename: "big.bin", contentType: "application/octet-stream", + source: .file(sourceURL))) + + let bodyURL = try form.writeToTemporaryFile() + defer { try? FileManager.default.removeItem(at: bodyURL) } + let body = try Data(contentsOf: bodyURL) + + #expect(form.contentType == "multipart/form-data; boundary=TESTBOUNDARY") + let text = String(decoding: body.prefix(400), as: UTF8.self) + #expect(text.contains("--TESTBOUNDARY")) + #expect(text.contains(#"Content-Disposition: form-data; name="meta""#)) + #expect(text.contains(#"name="file"; filename="big.bin""#)) + #expect(body.count > payload.count) + } + + @Test + func pathEncoding() { + #expect(PathEncoding.segment("a/b c") == "a%2Fb%20c") + #expect(PathEncoding.greedy("a/b/c.txt") == "a/b/c.txt") + #expect(PathEncoding.greedy("a/b c.txt") == "a/b%20c.txt") + } + + @Test + func jsonValueRoundTrip() throws { + let value = JSONValue.object([ + "s": .string("x"), + "n": .number(3.5), + "b": .bool(true), + "arr": .array([.number(1), .null]), + ]) + let data = try JSONCoding.encoder.encode(value) + let decoded = try JSONCoding.decoder.decode(JSONValue.self, from: data) + #expect(decoded == value) + } + + @Test + func iso8601DateCoding() throws { + struct Holder: Codable, Equatable { let at: Date } + let json = #"{"at":"2026-07-06T12:34:56.789Z"}"# + let decoded = try JSONCoding.decoder.decode(Holder.self, from: Data(json.utf8)) + let reencoded = try JSONCoding.encoder.encode(decoded) + let round = try JSONCoding.decoder.decode(Holder.self, from: reencoded) + #expect(decoded == round) + } +} From 13c2c995abbd1ed95a316a6ce01f92005f4aa217 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 12:39:19 -0300 Subject: [PATCH 04/44] fix(runtime): use per-file public import instead of a target-wide feature opt-out --- Package.swift | 13 ------------- Sources/HTTPRuntime/HTTPError.swift | 2 +- Sources/HTTPRuntime/HTTPRequest.swift | 2 +- Sources/HTTPRuntime/HTTPResponse.swift | 2 +- Sources/HTTPRuntime/JSONCoding.swift | 2 +- Sources/HTTPRuntime/MultipartFormData.swift | 2 +- Sources/HTTPRuntime/ServerSentEvents.swift | 2 +- Sources/HTTPRuntime/URLSessionTransport.swift | 2 +- 8 files changed, 7 insertions(+), 20 deletions(-) diff --git a/Package.swift b/Package.swift index e7d430b6c..7005d250a 100644 --- a/Package.swift +++ b/Package.swift @@ -250,19 +250,6 @@ for target in package.targets { continue } - // HTTPRuntime is the zero-dependency HTTP runtime that intentionally exposes - // Foundation types (URL, Data, etc.) in its public API for generated code. - // It doesn't use InternalImportsByDefault since it publicly re-exports Foundation types. - if target.name == "HTTPRuntime" || target.name == "HTTPRuntimeTests" { - target.swiftSettings = [ - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("ImmutableWeakCaptures"), - .enableUpcomingFeature("InferIsolatedConformances"), - .enableUpcomingFeature("MemberImportVisibility"), - ] - continue - } - var swiftSettings: [SwiftSetting] = [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("ImmutableWeakCaptures"), diff --git a/Sources/HTTPRuntime/HTTPError.swift b/Sources/HTTPRuntime/HTTPError.swift index 3b8a79da3..dae456220 100644 --- a/Sources/HTTPRuntime/HTTPError.swift +++ b/Sources/HTTPRuntime/HTTPError.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -import Foundation +public import Foundation /// Errors surfaced by the runtime itself (transport/encoding/decoding), as /// distinct from typed API errors decoded from a response body. diff --git a/Sources/HTTPRuntime/HTTPRequest.swift b/Sources/HTTPRuntime/HTTPRequest.swift index c51d09baf..fa2515952 100644 --- a/Sources/HTTPRuntime/HTTPRequest.swift +++ b/Sources/HTTPRuntime/HTTPRequest.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -import Foundation +public import Foundation /// The body of an outgoing request. /// diff --git a/Sources/HTTPRuntime/HTTPResponse.swift b/Sources/HTTPRuntime/HTTPResponse.swift index 0d47d64be..6d1c2b8a2 100644 --- a/Sources/HTTPRuntime/HTTPResponse.swift +++ b/Sources/HTTPRuntime/HTTPResponse.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -import Foundation +public import Foundation /// The status line and headers of a response, without the body. /// diff --git a/Sources/HTTPRuntime/JSONCoding.swift b/Sources/HTTPRuntime/JSONCoding.swift index 523c47ae9..81a829084 100644 --- a/Sources/HTTPRuntime/JSONCoding.swift +++ b/Sources/HTTPRuntime/JSONCoding.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -import Foundation +public import Foundation /// Shared JSON coders used by generated code. Dates are ISO-8601 with /// fractional seconds, matching the mock server and the spec timestamps. diff --git a/Sources/HTTPRuntime/MultipartFormData.swift b/Sources/HTTPRuntime/MultipartFormData.swift index 0f9291ae3..9da8df0c5 100644 --- a/Sources/HTTPRuntime/MultipartFormData.swift +++ b/Sources/HTTPRuntime/MultipartFormData.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -import Foundation +public import Foundation /// Builds a `multipart/form-data` body by streaming its parts onto a temporary /// file, so large file parts never load fully into memory. The resulting file diff --git a/Sources/HTTPRuntime/ServerSentEvents.swift b/Sources/HTTPRuntime/ServerSentEvents.swift index f52b664ae..121e6f5c1 100644 --- a/Sources/HTTPRuntime/ServerSentEvents.swift +++ b/Sources/HTTPRuntime/ServerSentEvents.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -import Foundation +public import Foundation /// A single Server-Sent Event frame (`text/event-stream`). public struct ServerSentEvent: Sendable, Hashable { diff --git a/Sources/HTTPRuntime/URLSessionTransport.swift b/Sources/HTTPRuntime/URLSessionTransport.swift index 4869d0e8a..7761acc3e 100644 --- a/Sources/HTTPRuntime/URLSessionTransport.swift +++ b/Sources/HTTPRuntime/URLSessionTransport.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -import Foundation +public import Foundation #if canImport(FoundationNetworking) import FoundationNetworking From 7d6971ca93199c45fb6ce7653eb98cc456e27416 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 12:42:18 -0300 Subject: [PATCH 05/44] chore(codegen): scaffold the openapi-codegen tool package --- tools/openapi-codegen/Package.resolved | 15 +++++++++ tools/openapi-codegen/Package.swift | 33 +++++++++++++++++++ .../Sources/OpenAPICodegenCore/IR.swift | 1 + .../Sources/openapi-codegen/main.swift | 1 + .../OpenAPICodegenCoreTests/Placeholder.swift | 1 + .../openapi-codegenTests/Placeholder.swift | 1 + 6 files changed, 52 insertions(+) create mode 100644 tools/openapi-codegen/Package.resolved create mode 100644 tools/openapi-codegen/Package.swift create mode 100644 tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift create mode 100644 tools/openapi-codegen/Sources/openapi-codegen/main.swift create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/Placeholder.swift create mode 100644 tools/openapi-codegen/Tests/openapi-codegenTests/Placeholder.swift diff --git a/tools/openapi-codegen/Package.resolved b/tools/openapi-codegen/Package.resolved new file mode 100644 index 000000000..e71ecf711 --- /dev/null +++ b/tools/openapi-codegen/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "24a7d95e7ed17e9182ff3018d5123a9bceaf01f1b8e978eaf20463f38a33a99b", + "pins" : [ + { + "identity" : "openapikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/OpenAPIKit.git", + "state" : { + "revision" : "57b6318128e3f901c93f4fbf98d1c1464ec168d3", + "version" : "6.2.0" + } + } + ], + "version" : 3 +} diff --git a/tools/openapi-codegen/Package.swift b/tools/openapi-codegen/Package.swift new file mode 100644 index 000000000..c4b4a20e2 --- /dev/null +++ b/tools/openapi-codegen/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 6.1 +import PackageDescription + +let package = Package( + name: "openapi-codegen", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "openapi-codegen", targets: ["openapi-codegen"]) + ], + dependencies: [ + .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "6.2.0") + ], + targets: [ + .target( + name: "OpenAPICodegenCore", + dependencies: [ + .product(name: "OpenAPIKit30", package: "OpenAPIKit") + ] + ), + .executableTarget( + name: "openapi-codegen", + dependencies: ["OpenAPICodegenCore"] + ), + .testTarget( + name: "OpenAPICodegenCoreTests", + dependencies: ["OpenAPICodegenCore"] + ), + .testTarget( + name: "openapi-codegenTests", + dependencies: ["OpenAPICodegenCore"] + ), + ] +) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift new file mode 100644 index 000000000..ce2a272fd --- /dev/null +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift @@ -0,0 +1 @@ +enum IRPlaceholder {} diff --git a/tools/openapi-codegen/Sources/openapi-codegen/main.swift b/tools/openapi-codegen/Sources/openapi-codegen/main.swift new file mode 100644 index 000000000..4d8e8ecab --- /dev/null +++ b/tools/openapi-codegen/Sources/openapi-codegen/main.swift @@ -0,0 +1 @@ +print("openapi-codegen: not yet implemented") diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/Placeholder.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/Placeholder.swift new file mode 100644 index 000000000..9db75b300 --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/Placeholder.swift @@ -0,0 +1 @@ +// Placeholder test file diff --git a/tools/openapi-codegen/Tests/openapi-codegenTests/Placeholder.swift b/tools/openapi-codegen/Tests/openapi-codegenTests/Placeholder.swift new file mode 100644 index 000000000..9db75b300 --- /dev/null +++ b/tools/openapi-codegen/Tests/openapi-codegenTests/Placeholder.swift @@ -0,0 +1 @@ +// Placeholder test file From cbb977c28314362ba8dff155ddb4175cb4b18a3d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 12:45:51 -0300 Subject: [PATCH 06/44] feat(codegen): parse object and string-enum schemas into IR --- .../Sources/OpenAPICodegenCore/IR.swift | 87 ++++++++++++++++++- .../OpenAPICodegenCore/OpenAPIParsing.swift | 78 +++++++++++++++++ .../OpenAPICodegenCoreTests/Placeholder.swift | 1 - .../SchemaParsingTests.swift | 66 ++++++++++++++ 4 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift delete mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/Placeholder.swift create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift index ce2a272fd..c20d6c6c4 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift @@ -1 +1,86 @@ -enum IRPlaceholder {} +// +// IR.swift +// + +/// Intermediate representation of an OpenAPI document, decoupled from +/// OpenAPIKit's types so the emitter doesn't need to know anything about +/// OpenAPI parsing. +struct IRDocument: Equatable { + var schemas: [IRSchema] + var operations: [IROperation] +} + +struct IRSchema: Equatable { + var name: String + var kind: IRSchemaKind +} + +enum IRSchemaKind: Equatable { + case object(properties: [IRProperty]) + case stringEnum(cases: [String]) +} + +struct IRProperty: Equatable { + var name: String + var type: IRType + var isOptional: Bool +} + +indirect enum IRType: Equatable { + case string + case integer + case number + case boolean + case array(IRType) + case schemaRef(String) + /// An object with no defined properties (OpenAPI's `{"type": "object"}` with + /// no `properties`) — modeled as `[String: JSONValue]` in emitted Swift. + case freeform +} + +enum IRHTTPMethod: String, Equatable { + case get, put, post, delete, options, head, patch, trace +} + +enum IRParameterLocation: Equatable { + case path, query, header +} + +struct IRParameter: Equatable { + var name: String + var location: IRParameterLocation + var type: IRType + var isOptional: Bool +} + +enum IRRequestBody: Equatable { + case json(IRType) + case multipart(fields: [IRMultipartField]) +} + +struct IRMultipartField: Equatable { + var name: String + var type: IRType + var isFile: Bool +} + +enum IRResponseBody: Equatable { + case none + case json(IRType) + case binary +} + +struct IRResponse: Equatable { + var statusCode: Int + var isError: Bool + var body: IRResponseBody +} + +struct IROperation: Equatable { + var operationId: String + var method: IRHTTPMethod + var path: String + var parameters: [IRParameter] + var requestBody: IRRequestBody? + var responses: [IRResponse] +} diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift new file mode 100644 index 000000000..3fa668d86 --- /dev/null +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -0,0 +1,78 @@ +// +// OpenAPIParsing.swift +// + +import OpenAPIKit30 + +/// Thrown when the input spec uses a construct this generator deliberately +/// doesn't support (see the plan's Global Constraints for the full list). +/// Never guess at an unfamiliar shape — fail with the exact location instead. +struct UnsupportedSpecConstruct: Error, CustomStringConvertible, Equatable { + var location: String + var reason: String + + var description: String { "Unsupported OpenAPI construct at \(location): \(reason)" } +} + +enum OpenAPIParsing { + + // MARK: - Schemas + + static func parseNamedSchema(name: String, schema: JSONSchema) throws -> IRSchema { + if case .string = schema.value, let allowedValues = schema.allowedValues { + let cases = allowedValues.compactMap { $0.value as? String } + return IRSchema(name: name, kind: .stringEnum(cases: cases)) + } + guard case .object(_, let objectContext) = schema.value else { + throw UnsupportedSpecConstruct( + location: "components.schemas.\(name)", + reason: "top-level schema must be an object or a string enum" + ) + } + var properties: [IRProperty] = [] + for (propertyName, propertySchema) in objectContext.properties { + properties.append( + IRProperty( + name: propertyName, + type: try parseType(propertySchema, location: "\(name).\(propertyName)"), + isOptional: !propertySchema.required || propertySchema.nullable + ) + ) + } + return IRSchema(name: name, kind: .object(properties: properties)) + } + + static func parseType(_ schema: JSONSchema, location: String) throws -> IRType { + if case .string = schema.value, schema.allowedValues != nil { + throw UnsupportedSpecConstruct( + location: location, + reason: "inline enum; register it as a named component schema instead" + ) + } + switch schema.value { + case .string: + return .string + case .integer: + return .integer + case .number: + return .number + case .boolean: + return .boolean + case .array(_, let arrayContext): + guard let items = arrayContext.items else { + throw UnsupportedSpecConstruct(location: location, reason: "array schema without 'items'") + } + return .array(try parseType(items, location: location + "[]")) + case .object(_, let objectContext) where objectContext.properties.isEmpty: + return .freeform + case .reference(let reference, _): + guard let name = reference.name else { + throw UnsupportedSpecConstruct( + location: location, reason: "external reference without a resolvable name") + } + return .schemaRef(name) + default: + throw UnsupportedSpecConstruct(location: location, reason: "unsupported schema shape") + } + } +} diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/Placeholder.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/Placeholder.swift deleted file mode 100644 index 9db75b300..000000000 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/Placeholder.swift +++ /dev/null @@ -1 +0,0 @@ -// Placeholder test file diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift new file mode 100644 index 000000000..90c545260 --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift @@ -0,0 +1,66 @@ +// +// SchemaParsingTests.swift +// + +import Foundation +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct SchemaParsingTests { + + @Test + func parsesObjectSchemaWithRequiredAndOptionalProperties() throws { + let json = """ + { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string", "nullable": true}, + "size": {"type": "integer"} + } + } + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + let irSchema = try OpenAPIParsing.parseNamedSchema(name: "widgetSchema", schema: schema) + + #expect(irSchema.name == "widgetSchema") + guard case .object(let properties) = irSchema.kind else { + Issue.record("expected an object schema") + return + } + let byName = Dictionary(uniqueKeysWithValues: properties.map { ($0.name, $0) }) + #expect(byName["id"]?.type == .string) + #expect(byName["id"]?.isOptional == false) + #expect(byName["name"]?.isOptional == true) + #expect(byName["size"]?.type == .integer) + #expect(byName["size"]?.isOptional == true) + } + + @Test + func parsesStringEnumSchema() throws { + let json = """ + {"type": "string", "enum": ["public", "private"]} + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + let irSchema = try OpenAPIParsing.parseNamedSchema(name: "visibility", schema: schema) + + #expect(irSchema.name == "visibility") + #expect(irSchema.kind == .stringEnum(cases: ["public", "private"])) + } + + @Test + func rejectsNonObjectNonEnumTopLevelSchema() throws { + let json = #"{"type": "integer"}"# + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseNamedSchema(name: "count", schema: schema) + } + } +} From f6d141f6303aec150dba21af03074c45d687d72c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 13:05:56 -0300 Subject: [PATCH 07/44] test(codegen): cover array/ref/freeform/fail-fast type parsing --- .../TypeParsingTests.swift | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift new file mode 100644 index 000000000..7cd682f48 --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift @@ -0,0 +1,70 @@ +// +// TypeParsingTests.swift +// + +import Foundation +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct TypeParsingTests { + + private func schema(_ json: String) throws -> JSONSchema { + try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + } + + @Test + func parsesArrayOfStrings() throws { + let type = try OpenAPIParsing.parseType( + schema(#"{"type": "array", "items": {"type": "string"}}"#), location: "test") + #expect(type == .array(.string)) + } + + @Test + func rejectsArrayWithoutItems() throws { + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseType(try schema(#"{"type": "array"}"#), location: "test") + } + } + + @Test + func parsesSchemaReference() throws { + let type = try OpenAPIParsing.parseType( + schema("{\"$ref\": \"#/components/schemas/bucketSchema\"}"), location: "test") + #expect(type == .schemaRef("bucketSchema")) + } + + @Test + func parsesFreeformObject() throws { + let type = try OpenAPIParsing.parseType(schema(#"{"type": "object"}"#), location: "test") + #expect(type == .freeform) + } + + @Test + func rejectsInlineObjectWithProperties() throws { + let json = #"{"type": "object", "properties": {"a": {"type": "string"}}}"# + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseType(try schema(json), location: "test") + } + } + + @Test + func rejectsOneOfUnion() throws { + let json = """ + {"oneOf": [{"type": "string"}, {"type": "integer"}]} + """ + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseType(try schema(json), location: "test") + } + } + + @Test + func rejectsInlineEnum() throws { + let json = #"{"type": "string", "enum": ["a", "b"]}"# + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseType(try schema(json), location: "test") + } + } +} From 87c2d2d7056a0e32dfa675a3861597c457c93124 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 13:09:01 -0300 Subject: [PATCH 08/44] feat(codegen): parse path/query/header parameters into IR --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 31 +++++++++ .../ParameterParsingTests.swift | 65 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 3fa668d86..2c86c717f 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -75,4 +75,35 @@ enum OpenAPIParsing { throw UnsupportedSpecConstruct(location: location, reason: "unsupported schema shape") } } + + // MARK: - Parameters + + static func parseParameter( + _ either: Either, OpenAPI.Parameter>, + location: String + ) throws -> IRParameter { + guard let parameter = either.parameterValue else { + throw UnsupportedSpecConstruct(location: location, reason: "external parameter reference") + } + let parameterLocation = "\(location).\(parameter.name)" + let irLocation: IRParameterLocation + switch parameter.location { + case .path: irLocation = .path + case .query: irLocation = .query + case .header: irLocation = .header + case .cookie: + throw UnsupportedSpecConstruct( + location: parameterLocation, reason: "cookie parameters aren't supported") + } + guard let schema = parameter.schemaOrContent.schemaValue else { + throw UnsupportedSpecConstruct( + location: parameterLocation, reason: "parameter uses 'content' instead of 'schema'") + } + return IRParameter( + name: parameter.name, + location: irLocation, + type: try parseType(schema, location: parameterLocation), + isOptional: !parameter.required || schema.nullable + ) + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift new file mode 100644 index 000000000..5288244c5 --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift @@ -0,0 +1,65 @@ +// +// ParameterParsingTests.swift +// + +import Foundation +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct ParameterParsingTests { + + private func parameter(_ json: String) throws -> Either< + JSONReference, OpenAPI.Parameter + > { + let param = try JSONDecoder().decode(OpenAPI.Parameter.self, from: Data(json.utf8)) + return .b(param) + } + + @Test + func parsesRequiredPathParameter() throws { + let json = """ + {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} + """ + let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + + #expect(irParameter.name == "bucketId") + #expect(irParameter.location == .path) + #expect(irParameter.type == .string) + #expect(irParameter.isOptional == false) + } + + @Test + func parsesOptionalQueryParameter() throws { + let json = """ + {"name": "limit", "in": "query", "schema": {"type": "integer"}} + """ + let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + + #expect(irParameter.location == .query) + #expect(irParameter.type == .integer) + #expect(irParameter.isOptional == true) + } + + @Test + func parsesHeaderParameter() throws { + let json = """ + {"name": "if-none-match", "in": "header", "schema": {"type": "string"}} + """ + let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + + #expect(irParameter.location == .header) + } + + @Test + func rejectsCookieParameter() throws { + let json = """ + {"name": "session", "in": "cookie", "schema": {"type": "string"}} + """ + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseParameter(try parameter(json), location: "op") + } + } +} From b66c51c51d7933d73e278764545b667cc3381890 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 13:18:49 -0300 Subject: [PATCH 09/44] feat(codegen): parse JSON and multipart request bodies into IR --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 66 +++++++++++++++++ .../RequestBodyParsingTests.swift | 72 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 2c86c717f..5e4fb0aad 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -106,4 +106,70 @@ enum OpenAPIParsing { isOptional: !parameter.required || schema.nullable ) } + + // MARK: - Schema references in content bodies + + static func resolveSchema( + _ either: Either, JSONSchema>, + location: String + ) throws -> IRType { + switch either { + case .a(let reference): + guard let name = reference.name else { + throw UnsupportedSpecConstruct( + location: location, reason: "external schema reference without a resolvable name") + } + return .schemaRef(name) + case .b(let schema): + return try parseType(schema, location: location) + } + } + + // MARK: - Request bodies + + static func parseRequestBody( + _ either: Either, OpenAPI.Request>, + location: String + ) throws -> IRRequestBody { + guard let request = either.requestValue else { + throw UnsupportedSpecConstruct(location: location, reason: "external request body reference") + } + if let jsonContent = request.content.first(where: { + $0.key.typeAndSubtype == "application/json" + })?.value { + guard let schema = jsonContent.schema else { + throw UnsupportedSpecConstruct( + location: location, reason: "JSON request body without a schema") + } + return .json(try resolveSchema(schema, location: location)) + } + if let multipartContent = request.content.first(where: { + $0.key.typeAndSubtype == "multipart/form-data" + })?.value { + guard let schemaEither = multipartContent.schema, case .b(let objectSchema) = schemaEither, + case .object(_, let objectContext) = objectSchema.value + else { + throw UnsupportedSpecConstruct( + location: location, reason: "multipart request body must be an inline object schema") + } + var fields: [IRMultipartField] = [] + for (fieldName, fieldSchema) in objectContext.properties { + var isFile = false + if case .string(let core, _) = fieldSchema.value, core.format == .binary { + isFile = true + } + fields.append( + IRMultipartField( + name: fieldName, + type: try parseType(fieldSchema, location: "\(location).\(fieldName)"), + isFile: isFile + ) + ) + } + return .multipart(fields: fields) + } + let contentTypes = request.content.keys.map(\.rawValue).joined(separator: ", ") + throw UnsupportedSpecConstruct( + location: location, reason: "unsupported request body content type(s): \(contentTypes)") + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift new file mode 100644 index 000000000..a64f6f6b4 --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift @@ -0,0 +1,72 @@ +// +// RequestBodyParsingTests.swift +// + +import Foundation +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct RequestBodyParsingTests { + + private func requestBody(_ json: String) throws -> Either< + JSONReference, OpenAPI.Request + > { + let request = try JSONDecoder().decode(OpenAPI.Request.self, from: Data(json.utf8)) + return .b(request) + } + + @Test + func parsesJSONRequestBody() throws { + let json = """ + { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/bucketUpdate"}} + } + } + """ + let body = try OpenAPIParsing.parseRequestBody(requestBody(json), location: "updateBucket") + + #expect(body == .json(.schemaRef("bucketUpdate"))) + } + + @Test + func parsesMultipartRequestBodyWithFileField() throws { + let json = """ + { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "cacheControl": {"type": "string"}, + "": {"type": "string", "format": "binary"} + } + } + } + } + } + """ + let body = try OpenAPIParsing.parseRequestBody(requestBody(json), location: "createObject") + + guard case .multipart(let fields) = body else { + Issue.record("expected a multipart request body") + return + } + let byName = Dictionary(uniqueKeysWithValues: fields.map { ($0.name, $0) }) + #expect(byName["cacheControl"]?.isFile == false) + #expect(byName[""]?.isFile == true) + } + + @Test + func rejectsUnsupportedContentType() throws { + let json = """ + {"content": {"text/plain": {"schema": {"type": "string"}}}} + """ + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseRequestBody(try requestBody(json), location: "op") + } + } +} From 169930d2d2928d787c266a810864d3dccc64e50d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 13:28:08 -0300 Subject: [PATCH 10/44] feat(codegen): parse responses and wire up full-document parsing Adds parseResponses/parseResponseBody (OpenAPI response -> IRResponse, skipping default/range status codes) and parseOperations (walks every path/method into IROperation). parseDocument ties schema and operation parsing together into the single IRDocument entry point the emitter and CLI will consume. --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 96 ++++++++++++++ .../ResponseParsingTests.swift | 119 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 5e4fb0aad..e58172a7f 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -172,4 +172,100 @@ enum OpenAPIParsing { throw UnsupportedSpecConstruct( location: location, reason: "unsupported request body content type(s): \(contentTypes)") } + + // MARK: - Responses + + static func parseResponses( + _ responses: OpenAPI.Response.Map, + location: String + ) throws -> [IRResponse] { + var results: [IRResponse] = [] + for (statusCode, responseEither) in responses { + guard case .status(let code) = statusCode.value else { + // `default` and range ("4XX") responses aren't modeled in v1; skip them. + continue + } + guard let response = responseEither.responseValue else { + throw UnsupportedSpecConstruct( + location: location, reason: "external response reference for status \(code)") + } + let body = try parseResponseBody(response.content, location: "\(location) -> \(code)") + results.append(IRResponse(statusCode: code, isError: !statusCode.isSuccess, body: body)) + } + return results.sorted { $0.statusCode < $1.statusCode } + } + + static func parseResponseBody( + _ content: OpenAPI.Content.Map, + location: String + ) throws -> IRResponseBody { + if let jsonContent = content.first(where: { $0.key.typeAndSubtype == "application/json" })? + .value + { + guard let schema = jsonContent.schema else { return .none } + return .json(try resolveSchema(schema, location: location)) + } + return content.isEmpty ? .none : .binary + } + + // MARK: - Operations + + static func parseOperations(_ document: OpenAPI.Document) throws -> [IROperation] { + var operations: [IROperation] = [] + for (path, pathItemEither) in document.paths { + guard let pathItem = pathItemEither.pathItemValue else { + throw UnsupportedSpecConstruct( + location: path.rawValue, reason: "external path item reference") + } + let methodOperations: [(IRHTTPMethod, OpenAPI.Operation?)] = [ + (.get, pathItem.get), + (.put, pathItem.put), + (.post, pathItem.post), + (.delete, pathItem.delete), + (.options, pathItem.options), + (.head, pathItem.head), + (.patch, pathItem.patch), + (.trace, pathItem.trace), + ] + for (method, maybeOperation) in methodOperations { + guard let operation = maybeOperation else { continue } + let operationLocation = "\(method.rawValue.uppercased()) \(path.rawValue)" + guard let operationId = operation.operationId else { + throw UnsupportedSpecConstruct(location: operationLocation, reason: "missing operationId") + } + var parameters: [IRParameter] = [] + for parameterEither in pathItem.parameters + operation.parameters { + parameters.append(try parseParameter(parameterEither, location: operationId)) + } + let requestBody = try operation.requestBody.map { + try parseRequestBody($0, location: operationId) + } + let responses = try parseResponses(operation.responses, location: operationId) + operations.append( + IROperation( + operationId: operationId, + method: method, + path: path.rawValue, + parameters: parameters, + requestBody: requestBody, + responses: responses + ) + ) + } + } + return operations.sorted { $0.operationId < $1.operationId } + } + + // MARK: - Document + + static func parseDocument(_ document: OpenAPI.Document) throws -> IRDocument { + var schemas: [IRSchema] = [] + for (key, schema) in document.components.schemas { + schemas.append(try parseNamedSchema(name: key.rawValue, schema: schema)) + } + return IRDocument( + schemas: schemas.sorted { $0.name < $1.name }, + operations: try parseOperations(document) + ) + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift new file mode 100644 index 000000000..08aa47edd --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift @@ -0,0 +1,119 @@ +// +// ResponseParsingTests.swift +// + +import Foundation +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct ResponseParsingTests { + + @Test + func parsesSuccessAndErrorResponses() throws { + let json = """ + { + "200": { + "description": "ok", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/bucketSchema"}}} + }, + "404": { + "description": "not found", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/errorSchema"}}} + } + } + """ + let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) + + let irResponses = try OpenAPIParsing.parseResponses(responses, location: "getBucket") + + #expect(irResponses.count == 2) + #expect(irResponses[0].statusCode == 200) + #expect(irResponses[0].isError == false) + #expect(irResponses[0].body == .json(.schemaRef("bucketSchema"))) + #expect(irResponses[1].statusCode == 404) + #expect(irResponses[1].isError == true) + #expect(irResponses[1].body == .json(.schemaRef("errorSchema"))) + } + + @Test + func parsesBinaryResponseBody() throws { + let json = """ + { + "200": { + "description": "ok", + "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}} + } + } + """ + let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) + + let irResponses = try OpenAPIParsing.parseResponses(responses, location: "download") + + #expect(irResponses[0].body == .binary) + } + + @Test + func skipsDefaultAndRangeStatusEntries() throws { + let json = """ + { + "200": {"description": "ok", "content": {}}, + "default": {"description": "generic error", "content": {}} + } + """ + let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) + + let irResponses = try OpenAPIParsing.parseResponses(responses, location: "op") + + #expect(irResponses.count == 1) + #expect(irResponses[0].statusCode == 200) + } + + @Test + func parsesFullDocumentEndToEnd() throws { + let json = """ + { + "openapi": "3.0.3", + "info": {"title": "Storage", "version": "1.0.0"}, + "paths": { + "/bucket/{bucketId}": { + "get": { + "operationId": "getBucket", + "parameters": [ + {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/bucketSchema"}} + } + } + } + } + } + }, + "components": { + "schemas": { + "bucketSchema": { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}} + } + } + } + } + """ + let document = try JSONDecoder().decode(OpenAPI.Document.self, from: Data(json.utf8)) + + let irDocument = try OpenAPIParsing.parseDocument(document) + + #expect(irDocument.schemas.map(\.name) == ["bucketSchema"]) + #expect(irDocument.operations.count == 1) + #expect(irDocument.operations[0].operationId == "getBucket") + #expect(irDocument.operations[0].method == .get) + #expect(irDocument.operations[0].path == "/bucket/{bucketId}") + } +} From 30bb2442cb4e014345acd6168bdf5daa583b834d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 13:31:45 -0300 Subject: [PATCH 11/44] feat(codegen): add Swift identifier casing/escaping helpers --- .../OpenAPICodegenCore/SwiftNames.swift | 56 +++++++++++++++++++ .../SwiftNamesTests.swift | 39 +++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftNames.swift create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SwiftNamesTests.swift diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftNames.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftNames.swift new file mode 100644 index 000000000..d18daa84f --- /dev/null +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftNames.swift @@ -0,0 +1,56 @@ +// +// SwiftNames.swift +// + +enum SwiftNames { + + static let reservedWords: Set = [ + "public", "private", "internal", "fileprivate", "open", + "self", "Self", "class", "struct", "enum", "protocol", + "default", "for", "in", "if", "else", "switch", "case", + "return", "func", "var", "let", "import", "extension", "static", + "true", "false", "nil", "is", "as", "guard", "where", "continue", "break", + "operator", "typealias", "associatedtype", "subscript", "init", "deinit", + ] + + static func typeName(_ raw: String) -> String { + let camel = camelCased(raw) + guard let first = camel.first else { return camel } + return first.uppercased() + camel.dropFirst() + } + + static func propertyName(_ raw: String) -> String { + escape(camelCased(raw)) + } + + private static func camelCased(_ raw: String) -> String { + let parts = raw.split(whereSeparator: { $0 == "_" || $0 == "-" }) + guard let first = parts.first else { return raw } + let rest = parts.dropFirst().map { part -> String in + guard let firstCharacter = part.first else { return String(part) } + return firstCharacter.uppercased() + part.dropFirst() + } + return ([String(first)] + rest).joined() + } + + private static func escape(_ identifier: String) -> String { + reservedWords.contains(identifier) ? "`\(identifier)`" : identifier + } + + static func typeReference(_ type: IRType, isOptional: Bool) -> String { + let base = baseTypeReference(type) + return isOptional ? "\(base)?" : base + } + + static func baseTypeReference(_ type: IRType) -> String { + switch type { + case .string: return "String" + case .integer: return "Int" + case .number: return "Double" + case .boolean: return "Bool" + case .array(let element): return "[\(baseTypeReference(element))]" + case .schemaRef(let name): return typeName(name) + case .freeform: return "[String: JSONValue]" + } + } +} diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SwiftNamesTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SwiftNamesTests.swift new file mode 100644 index 000000000..0f8a02a4e --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SwiftNamesTests.swift @@ -0,0 +1,39 @@ +// +// SwiftNamesTests.swift +// + +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct SwiftNamesTests { + + @Test + func convertsSnakeCaseToLowerCamelCase() { + #expect(SwiftNames.propertyName("file_size_limit") == "fileSizeLimit") + #expect(SwiftNames.propertyName("id") == "id") + } + + @Test + func escapesReservedWords() { + #expect(SwiftNames.propertyName("public") == "`public`") + #expect(SwiftNames.propertyName("self") == "`self`") + } + + @Test + func convertsSchemaNameToUpperCamelCaseTypeName() { + #expect(SwiftNames.typeName("bucketSchema") == "BucketSchema") + #expect(SwiftNames.typeName("errorSchema") == "ErrorSchema") + } + + @Test + func rendersTypeReferences() { + #expect(SwiftNames.typeReference(.string, isOptional: false) == "String") + #expect(SwiftNames.typeReference(.integer, isOptional: true) == "Int?") + #expect(SwiftNames.typeReference(.array(.string), isOptional: false) == "[String]") + #expect( + SwiftNames.typeReference(.schemaRef("bucketSchema"), isOptional: false) == "BucketSchema") + #expect(SwiftNames.typeReference(.freeform, isOptional: false) == "[String: JSONValue]") + } +} From f8d24f7a3aa4c21683f0544ceaf1481d3de47e4a Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 13:34:10 -0300 Subject: [PATCH 12/44] feat(codegen): emit Swift models from IR schemas --- .../OpenAPICodegenCore/SwiftEmitter.swift | 75 ++++++++++++++++++ .../ModelEmitterTests.swift | 76 +++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift new file mode 100644 index 000000000..450459089 --- /dev/null +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift @@ -0,0 +1,75 @@ +// +// SwiftEmitter.swift +// + +enum SwiftEmitter { + + static func emitModels(_ document: IRDocument) -> String { + let errorSchemaNames = self.errorSchemaNames(in: document) + var lines: [String] = [ + "// Code generated by openapi-codegen. DO NOT EDIT.", + "", + "import Foundation", + "import HTTPRuntime", + "", + ] + for schema in document.schemas.sorted(by: { $0.name < $1.name }) { + lines.append(emitSchema(schema, isError: errorSchemaNames.contains(schema.name))) + lines.append("") + } + return lines.joined(separator: "\n") + } + + static func errorSchemaNames(in document: IRDocument) -> Set { + var names: Set = [] + for operation in document.operations { + for response in operation.responses where response.isError { + if case .json(.schemaRef(let name)) = response.body { + names.insert(name) + } + } + } + return names + } + + static func emitSchema(_ schema: IRSchema, isError: Bool) -> String { + let typeName = SwiftNames.typeName(schema.name) + switch schema.kind { + case .object(let properties): + return emitStruct(named: typeName, properties: properties, isError: isError) + case .stringEnum(let cases): + return emitStringEnum(named: typeName, cases: cases, isError: isError) + } + } + + static func emitStruct(named typeName: String, properties: [IRProperty], isError: Bool) -> String { + let conformances = (["Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) + .joined(separator: ", ") + var lines = ["public struct \(typeName): \(conformances) {"] + for property in properties { + let name = SwiftNames.propertyName(property.name) + let type = SwiftNames.typeReference(property.type, isOptional: property.isOptional) + lines.append(" public var \(name): \(type)") + } + lines.append("") + lines.append(" enum CodingKeys: String, CodingKey {") + for property in properties { + let name = SwiftNames.propertyName(property.name) + lines.append(" case \(name) = \"\(property.name)\"") + } + lines.append(" }") + lines.append("}") + return lines.joined(separator: "\n") + } + + static func emitStringEnum(named typeName: String, cases: [String], isError: Bool) -> String { + let conformances = (["String", "Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) + .joined(separator: ", ") + var lines = ["public enum \(typeName): \(conformances) {"] + for value in cases { + lines.append(" case \(SwiftNames.propertyName(value)) = \"\(value)\"") + } + lines.append("}") + return lines.joined(separator: "\n") + } +} diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift new file mode 100644 index 000000000..bfd4ee13a --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift @@ -0,0 +1,76 @@ +// +// ModelEmitterTests.swift +// + +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct ModelEmitterTests { + + @Test + func emitsStructWithCodingKeys() { + let document = IRDocument( + schemas: [ + IRSchema( + name: "bucketSchema", + kind: .object(properties: [ + IRProperty(name: "id", type: .string, isOptional: false), + IRProperty(name: "file_size_limit", type: .integer, isOptional: true), + ]) + ) + ], + operations: [] + ) + + let output = SwiftEmitter.emitModels(document) + + #expect(output.contains("public struct BucketSchema: Codable, Sendable, Hashable {")) + #expect(output.contains("public var id: String")) + #expect(output.contains("public var fileSizeLimit: Int?")) + #expect(output.contains(#"case fileSizeLimit = "file_size_limit""#)) + } + + @Test + func emitsStringEnum() { + let document = IRDocument( + schemas: [IRSchema(name: "visibility", kind: .stringEnum(cases: ["public", "private"]))], + operations: [] + ) + + let output = SwiftEmitter.emitModels(document) + + #expect(output.contains("public enum Visibility: String, Codable, Sendable, Hashable {")) + #expect(output.contains(#"case `public` = "public""#)) + #expect(output.contains(#"case `private` = "private""#)) + } + + @Test + func marksSchemasReferencedByErrorResponsesAsAPIError() { + let document = IRDocument( + schemas: [ + IRSchema( + name: "errorSchema", + kind: .object(properties: [IRProperty(name: "message", type: .string, isOptional: false)]) + ) + ], + operations: [ + IROperation( + operationId: "getBucket", + method: .get, + path: "/bucket/{id}", + parameters: [], + requestBody: nil, + responses: [ + IRResponse(statusCode: 404, isError: true, body: .json(.schemaRef("errorSchema"))) + ] + ) + ] + ) + + let output = SwiftEmitter.emitModels(document) + + #expect(output.contains("public struct ErrorSchema: Codable, Sendable, Hashable, APIError {")) + } +} From 246746686ae6d3dc5a049e085c9ebed0fe137985 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 13:39:17 -0300 Subject: [PATCH 13/44] feat(codegen): emit a Swift HTTP client from IR operations Adds SwiftEmitter.emitClient(_:clientName:), which turns IRDocument operations into a Swift client struct with one async throws method per operation: HTTPRequestBuilder construction with path/query/header parameters, JSON and multipart request bodies, JSON and binary response bodies, and typed per-status-code error decoding via HTTPResponse.checkStatus(errorTypes:). --- .../OpenAPICodegenCore/SwiftEmitter.swift | 154 ++++++++++++++++++ .../ClientEmitterTests.swift | 99 +++++++++++ 2 files changed, 253 insertions(+) create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift index 450459089..21103b6ac 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift @@ -72,4 +72,158 @@ enum SwiftEmitter { lines.append("}") return lines.joined(separator: "\n") } + + // MARK: - Client + + static func emitClient(_ document: IRDocument, clientName: String) -> String { + let errorSchemaNames = self.errorSchemaNames(in: document) + var lines: [String] = [ + "// Code generated by openapi-codegen. DO NOT EDIT.", + "", + "import Foundation", + "import HTTPRuntime", + "", + "public struct \(clientName): Sendable {", + " private let baseURL: URL", + " private let transport: any HTTPTransport", + "", + " public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) {", + " self.baseURL = baseURL", + " self.transport = transport", + " }", + ] + for operation in document.operations.sorted(by: { $0.operationId < $1.operationId }) { + lines.append("") + lines.append(contentsOf: emitOperation(operation, errorSchemaNames: errorSchemaNames)) + } + lines.append("}") + return lines.joined(separator: "\n") + } + + static func emitOperation(_ operation: IROperation, errorSchemaNames: Set) -> [String] { + let methodName = SwiftNames.propertyName(operation.operationId) + let parameters = operation.parameters.sorted { lhs, rhs in + lhs.isOptional != rhs.isOptional ? !lhs.isOptional : lhs.name < rhs.name + } + var signatureParts = parameters.map { parameter -> String in + let name = SwiftNames.propertyName(parameter.name) + let type = SwiftNames.typeReference(parameter.type, isOptional: parameter.isOptional) + return "\(name): \(type)\(parameter.isOptional ? " = nil" : "")" + } + if let requestBody = operation.requestBody { + signatureParts.append(contentsOf: requestBodyParameters(requestBody)) + } + let successResponse = operation.responses.first { !$0.isError } + let returnType = returnTypeReference(for: successResponse) + + var lines: [String] = [ + " public func \(methodName)(\(signatureParts.joined(separator: ", "))) async throws" + + (returnType.map { " -> \($0)" } ?? "") + " {", + " var builder = HTTPRequestBuilder(method: .\(operation.method.rawValue), baseURL: baseURL, path: \"\(pathTemplate(operation))\")", + ] + for parameter in parameters where parameter.location == .query { + lines.append(" builder.addQuery(\"\(parameter.name)\", \(stringConversionExpression(parameter)))") + } + for parameter in parameters where parameter.location == .header { + lines.append(" builder.setHeader(\"\(parameter.name)\", \(stringConversionExpression(parameter)))") + } + if let requestBody = operation.requestBody { + lines.append(contentsOf: requestBodyLines(requestBody)) + } + if let successResponse, case .binary = successResponse.body { + lines.append(" let stream = try await transport.stream(try builder.build())") + lines.append(" guard stream.head.isSuccess else {") + lines.append( + " throw HTTPError.unexpectedStatus(status: stream.head.status, body: Data())") + lines.append(" }") + lines.append(" return stream.body") + } else { + lines.append(" let response = try await transport.send(try builder.build())") + lines.append( + " try response.checkStatus(errorTypes: \(errorTypesLiteral(operation, errorSchemaNames: errorSchemaNames)))" + ) + if let successResponse, case .json(let type) = successResponse.body { + let typeName = SwiftNames.typeReference(type, isOptional: false) + lines.append(" return try JSONCoding.decoder.decode(\(typeName).self, from: response.body)") + } + } + lines.append(" }") + return lines + } + + static func pathTemplate(_ operation: IROperation) -> String { + var result = operation.path + for parameter in operation.parameters where parameter.location == .path { + let name = SwiftNames.propertyName(parameter.name) + let stringExpr = parameter.type == .string ? name : "String(\(name))" + result = result.replacingOccurrences( + of: "{\(parameter.name)}", with: "\\(PathEncoding.segment(\(stringExpr)))") + } + return result + } + + static func stringConversionExpression(_ parameter: IRParameter) -> String { + let name = SwiftNames.propertyName(parameter.name) + if case .string = parameter.type { return name } + return parameter.isOptional ? "\(name).map(String.init)" : "String(\(name))" + } + + static func requestBodyParameters(_ requestBody: IRRequestBody) -> [String] { + switch requestBody { + case .json(let type): + return ["payload: \(SwiftNames.typeReference(type, isOptional: false))"] + case .multipart(let fields): + return fields.map { field in + let name = SwiftNames.propertyName(field.name) + let type = field.isFile ? "URL" : SwiftNames.typeReference(field.type, isOptional: false) + return "\(name): \(type)" + } + } + } + + static func requestBodyLines(_ requestBody: IRRequestBody) -> [String] { + switch requestBody { + case .json: + return [ + " builder.setHeader(\"Content-Type\", \"application/json\")", + " builder.setBody(.data(try JSONCoding.encoder.encode(payload)))", + ] + case .multipart(let fields): + var lines = [" var formData = MultipartFormData()"] + for field in fields { + let name = SwiftNames.propertyName(field.name) + if field.isFile { + lines.append( + " formData.append(MultipartFormData.Part(name: \"\(field.name)\", filename: \(name).lastPathComponent, contentType: \"application/octet-stream\", source: .file(\(name))))" + ) + } else { + lines.append( + " formData.append(MultipartFormData.Part(name: \"\(field.name)\", source: .data(Data(String(describing: \(name)).utf8))))" + ) + } + } + lines.append(" builder.setHeader(\"Content-Type\", formData.contentType)") + lines.append(" builder.setBody(.multipart(formData))") + return lines + } + } + + static func returnTypeReference(for response: IRResponse?) -> String? { + guard let response else { return nil } + switch response.body { + case .none: return nil + case .json(let type): return SwiftNames.typeReference(type, isOptional: false) + case .binary: return "AsyncThrowingStream" + } + } + + static func errorTypesLiteral(_ operation: IROperation, errorSchemaNames: Set) -> String { + var entries: [String] = [] + for response in operation.responses where response.isError { + if case .json(.schemaRef(let name)) = response.body, errorSchemaNames.contains(name) { + entries.append("\(response.statusCode): \(SwiftNames.typeName(name)).self") + } + } + return "[\(entries.joined(separator: ", "))]" + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift new file mode 100644 index 000000000..93c6efcf6 --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift @@ -0,0 +1,99 @@ +// +// ClientEmitterTests.swift +// + +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct ClientEmitterTests { + + @Test + func emitsJSONRoundTripOperation() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "getBucket", + method: .get, + path: "/bucket/{bucketId}", + parameters: [ + IRParameter(name: "bucketId", location: .path, type: .string, isOptional: false) + ], + requestBody: nil, + responses: [ + IRResponse(statusCode: 200, isError: false, body: .json(.schemaRef("bucketSchema"))), + IRResponse(statusCode: 404, isError: true, body: .json(.schemaRef("errorSchema"))), + ] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + #expect(output.contains("public struct StorageOpenAPIClient: Sendable {")) + #expect(output.contains("public func getBucket(bucketId: String) async throws -> BucketSchema {")) + #expect( + output.contains( + "HTTPRequestBuilder(method: .get, baseURL: baseURL, path: \"/bucket/\\(PathEncoding.segment(bucketId))\")" + )) + #expect(output.contains("try response.checkStatus(errorTypes: [404: ErrorSchema.self])")) + #expect(output.contains("return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body)")) + } + + @Test + func emitsMultipartUploadOperation() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "createObject", + method: .post, + path: "/object/{bucketId}", + parameters: [ + IRParameter(name: "bucketId", location: .path, type: .string, isOptional: false) + ], + requestBody: .multipart(fields: [ + IRMultipartField(name: "file", type: .string, isFile: true), + IRMultipartField(name: "cacheControl", type: .string, isFile: false), + ]), + responses: [ + IRResponse(statusCode: 200, isError: false, body: .none) + ] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + #expect(output.contains("file: URL")) + #expect(output.contains("cacheControl: String")) + #expect(output.contains("source: .file(file)")) + #expect(output.contains("builder.setBody(.multipart(formData))")) + } + + @Test + func emitsBinaryDownloadOperation() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "download", + method: .get, + path: "/object/{bucketId}", + parameters: [ + IRParameter(name: "bucketId", location: .path, type: .string, isOptional: false) + ], + requestBody: nil, + responses: [IRResponse(statusCode: 200, isError: false, body: .binary)] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + #expect(output.contains("-> AsyncThrowingStream {")) + #expect(output.contains("transport.stream(try builder.build())")) + } +} From c6af030ee7c7383961feb56ea5eb78c9b260f620 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 13:48:47 -0300 Subject: [PATCH 14/44] feat(codegen): wire up the openapi-codegen CLI Reads a spec file, runs it through OpenAPIParsing.parseDocument and SwiftEmitter.emitModels/emitClient, and writes Models.swift and Client.swift to the output directory. Marks OpenAPIParsing, SwiftEmitter, and the IR types public so the executable target (a separate module from OpenAPICodegenCore) can call them. --- tools/openapi-codegen/Package.swift | 5 +- .../Sources/OpenAPICodegenCore/IR.swift | 72 +++++++++--------- .../OpenAPICodegenCore/OpenAPIParsing.swift | 4 +- .../OpenAPICodegenCore/SwiftEmitter.swift | 6 +- .../Sources/openapi-codegen/main.swift | 63 ++++++++++++++- .../openapi-codegenTests/EndToEndTests.swift | 76 +++++++++++++++++++ 6 files changed, 183 insertions(+), 43 deletions(-) create mode 100644 tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift diff --git a/tools/openapi-codegen/Package.swift b/tools/openapi-codegen/Package.swift index c4b4a20e2..1ea701bff 100644 --- a/tools/openapi-codegen/Package.swift +++ b/tools/openapi-codegen/Package.swift @@ -27,7 +27,10 @@ let package = Package( ), .testTarget( name: "openapi-codegenTests", - dependencies: ["OpenAPICodegenCore"] + dependencies: [ + "OpenAPICodegenCore", + .product(name: "OpenAPIKit30", package: "OpenAPIKit"), + ] ), ] ) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift index c20d6c6c4..ebb0dc777 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift @@ -5,28 +5,28 @@ /// Intermediate representation of an OpenAPI document, decoupled from /// OpenAPIKit's types so the emitter doesn't need to know anything about /// OpenAPI parsing. -struct IRDocument: Equatable { - var schemas: [IRSchema] - var operations: [IROperation] +public struct IRDocument: Equatable { + public var schemas: [IRSchema] + public var operations: [IROperation] } -struct IRSchema: Equatable { - var name: String - var kind: IRSchemaKind +public struct IRSchema: Equatable { + public var name: String + public var kind: IRSchemaKind } -enum IRSchemaKind: Equatable { +public enum IRSchemaKind: Equatable { case object(properties: [IRProperty]) case stringEnum(cases: [String]) } -struct IRProperty: Equatable { - var name: String - var type: IRType - var isOptional: Bool +public struct IRProperty: Equatable { + public var name: String + public var type: IRType + public var isOptional: Bool } -indirect enum IRType: Equatable { +public indirect enum IRType: Equatable { case string case integer case number @@ -38,49 +38,49 @@ indirect enum IRType: Equatable { case freeform } -enum IRHTTPMethod: String, Equatable { +public enum IRHTTPMethod: String, Equatable { case get, put, post, delete, options, head, patch, trace } -enum IRParameterLocation: Equatable { +public enum IRParameterLocation: Equatable { case path, query, header } -struct IRParameter: Equatable { - var name: String - var location: IRParameterLocation - var type: IRType - var isOptional: Bool +public struct IRParameter: Equatable { + public var name: String + public var location: IRParameterLocation + public var type: IRType + public var isOptional: Bool } -enum IRRequestBody: Equatable { +public enum IRRequestBody: Equatable { case json(IRType) case multipart(fields: [IRMultipartField]) } -struct IRMultipartField: Equatable { - var name: String - var type: IRType - var isFile: Bool +public struct IRMultipartField: Equatable { + public var name: String + public var type: IRType + public var isFile: Bool } -enum IRResponseBody: Equatable { +public enum IRResponseBody: Equatable { case none case json(IRType) case binary } -struct IRResponse: Equatable { - var statusCode: Int - var isError: Bool - var body: IRResponseBody +public struct IRResponse: Equatable { + public var statusCode: Int + public var isError: Bool + public var body: IRResponseBody } -struct IROperation: Equatable { - var operationId: String - var method: IRHTTPMethod - var path: String - var parameters: [IRParameter] - var requestBody: IRRequestBody? - var responses: [IRResponse] +public struct IROperation: Equatable { + public var operationId: String + public var method: IRHTTPMethod + public var path: String + public var parameters: [IRParameter] + public var requestBody: IRRequestBody? + public var responses: [IRResponse] } diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index e58172a7f..6f3691894 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -14,7 +14,7 @@ struct UnsupportedSpecConstruct: Error, CustomStringConvertible, Equatable { var description: String { "Unsupported OpenAPI construct at \(location): \(reason)" } } -enum OpenAPIParsing { +public enum OpenAPIParsing { // MARK: - Schemas @@ -258,7 +258,7 @@ enum OpenAPIParsing { // MARK: - Document - static func parseDocument(_ document: OpenAPI.Document) throws -> IRDocument { + public static func parseDocument(_ document: OpenAPI.Document) throws -> IRDocument { var schemas: [IRSchema] = [] for (key, schema) in document.components.schemas { schemas.append(try parseNamedSchema(name: key.rawValue, schema: schema)) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift index 21103b6ac..17a917218 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift @@ -2,9 +2,9 @@ // SwiftEmitter.swift // -enum SwiftEmitter { +public enum SwiftEmitter { - static func emitModels(_ document: IRDocument) -> String { + public static func emitModels(_ document: IRDocument) -> String { let errorSchemaNames = self.errorSchemaNames(in: document) var lines: [String] = [ "// Code generated by openapi-codegen. DO NOT EDIT.", @@ -75,7 +75,7 @@ enum SwiftEmitter { // MARK: - Client - static func emitClient(_ document: IRDocument, clientName: String) -> String { + public static func emitClient(_ document: IRDocument, clientName: String) -> String { let errorSchemaNames = self.errorSchemaNames(in: document) var lines: [String] = [ "// Code generated by openapi-codegen. DO NOT EDIT.", diff --git a/tools/openapi-codegen/Sources/openapi-codegen/main.swift b/tools/openapi-codegen/Sources/openapi-codegen/main.swift index 4d8e8ecab..60ca9800a 100644 --- a/tools/openapi-codegen/Sources/openapi-codegen/main.swift +++ b/tools/openapi-codegen/Sources/openapi-codegen/main.swift @@ -1 +1,62 @@ -print("openapi-codegen: not yet implemented") +// +// main.swift +// + +import Foundation +import OpenAPICodegenCore +import OpenAPIKit30 + +struct CLIError: Error, CustomStringConvertible { + var description: String +} + +func parseArguments(_ arguments: [String]) throws -> (spec: URL, output: URL, module: String) { + var spec: String? + var output: String? + var module: String? + var index = 0 + while index < arguments.count { + switch arguments[index] { + case "--spec": + index += 1 + spec = arguments[index] + case "--output": + index += 1 + output = arguments[index] + case "--module": + index += 1 + module = arguments[index] + default: + throw CLIError(description: "unknown argument: \(arguments[index])") + } + index += 1 + } + guard let spec, let output, let module else { + throw CLIError( + description: "usage: openapi-codegen --spec --output --module ") + } + return ( + URL(fileURLWithPath: spec), URL(fileURLWithPath: output, isDirectory: true), module + ) +} + +let (specURL, outputURL, moduleName) = try parseArguments(Array(CommandLine.arguments.dropFirst())) + +let data = try Data(contentsOf: specURL) +let document = try JSONDecoder().decode(OpenAPI.Document.self, from: data) +let irDocument = try OpenAPIParsing.parseDocument(document) + +try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + +let models = SwiftEmitter.emitModels(irDocument) +try models.write( + to: outputURL.appendingPathComponent("Models.swift"), atomically: true, encoding: .utf8) + +let clientName = "\(moduleName)Client" +let client = SwiftEmitter.emitClient(irDocument, clientName: clientName) +try client.write( + to: outputURL.appendingPathComponent("\(clientName).swift"), atomically: true, encoding: .utf8) + +print( + "Generated \(irDocument.schemas.count) schemas and \(irDocument.operations.count) operations into \(outputURL.path)" +) diff --git a/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift b/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift new file mode 100644 index 000000000..25ba40458 --- /dev/null +++ b/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift @@ -0,0 +1,76 @@ +// +// EndToEndTests.swift +// + +import Foundation +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct EndToEndTests { + + @Test + func generatesModelsAndClientFromAMinimalStorageLikeSpec() throws { + let json = """ + { + "openapi": "3.0.3", + "info": {"title": "Storage", "version": "1.0.0"}, + "paths": { + "/bucket/{bucketId}": { + "get": { + "operationId": "getBucket", + "parameters": [ + {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/bucketSchema"}} + } + }, + "404": { + "description": "not found", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/errorSchema"}} + } + } + } + } + } + }, + "components": { + "schemas": { + "bucketSchema": { + "type": "object", + "required": ["id", "public"], + "properties": { + "id": {"type": "string"}, + "public": {"type": "boolean"} + } + }, + "errorSchema": { + "type": "object", + "required": ["message"], + "properties": {"message": {"type": "string"}} + } + } + } + } + """ + let document = try JSONDecoder().decode(OpenAPI.Document.self, from: Data(json.utf8)) + let irDocument = try OpenAPIParsing.parseDocument(document) + + let models = SwiftEmitter.emitModels(irDocument) + let client = SwiftEmitter.emitClient(irDocument, clientName: "StorageOpenAPIClient") + + #expect(models.contains("public struct BucketSchema: Codable, Sendable, Hashable {")) + #expect(models.contains("public var `public`: Bool")) + #expect(models.contains("public struct ErrorSchema: Codable, Sendable, Hashable, APIError {")) + #expect( + client.contains("public func getBucket(bucketId: String) async throws -> BucketSchema {")) + #expect(client.contains("errorTypes: [404: ErrorSchema.self]")) + } +} From 86e02639d5f6f965c626bc49f3c888c9b097c0f2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 14:14:23 -0300 Subject: [PATCH 15/44] feat(codegen): hoist inline enum object properties into named schemas --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 25 ++++++++++--- .../SchemaParsingTests.swift | 35 +++++++++++++++++-- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 6f3691894..d26dc26c8 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -18,10 +18,10 @@ public enum OpenAPIParsing { // MARK: - Schemas - static func parseNamedSchema(name: String, schema: JSONSchema) throws -> IRSchema { + static func parseNamedSchema(name: String, schema: JSONSchema) throws -> [IRSchema] { if case .string = schema.value, let allowedValues = schema.allowedValues { let cases = allowedValues.compactMap { $0.value as? String } - return IRSchema(name: name, kind: .stringEnum(cases: cases)) + return [IRSchema(name: name, kind: .stringEnum(cases: cases))] } guard case .object(_, let objectContext) = schema.value else { throw UnsupportedSpecConstruct( @@ -30,7 +30,24 @@ public enum OpenAPIParsing { ) } var properties: [IRProperty] = [] + var hoistedSchemas: [IRSchema] = [] for (propertyName, propertySchema) in objectContext.properties { + if case .string = propertySchema.value, let allowedValues = propertySchema.allowedValues { + // Inline enum on an object property: hoist it into its own named + // schema instead of failing. Unlike a union or an inline object with + // properties, there's no ambiguity in what Swift type this becomes. + let hoistedName = "\(name)_\(propertyName)" + let cases = allowedValues.compactMap { $0.value as? String } + hoistedSchemas.append(IRSchema(name: hoistedName, kind: .stringEnum(cases: cases))) + properties.append( + IRProperty( + name: propertyName, + type: .schemaRef(hoistedName), + isOptional: !propertySchema.required || propertySchema.nullable + ) + ) + continue + } properties.append( IRProperty( name: propertyName, @@ -39,7 +56,7 @@ public enum OpenAPIParsing { ) ) } - return IRSchema(name: name, kind: .object(properties: properties)) + return [IRSchema(name: name, kind: .object(properties: properties))] + hoistedSchemas } static func parseType(_ schema: JSONSchema, location: String) throws -> IRType { @@ -261,7 +278,7 @@ public enum OpenAPIParsing { public static func parseDocument(_ document: OpenAPI.Document) throws -> IRDocument { var schemas: [IRSchema] = [] for (key, schema) in document.components.schemas { - schemas.append(try parseNamedSchema(name: key.rawValue, schema: schema)) + schemas.append(contentsOf: try parseNamedSchema(name: key.rawValue, schema: schema)) } return IRDocument( schemas: schemas.sorted { $0.name < $1.name }, diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift index 90c545260..2f4554302 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift @@ -26,8 +26,10 @@ struct SchemaParsingTests { """ let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) - let irSchema = try OpenAPIParsing.parseNamedSchema(name: "widgetSchema", schema: schema) + let irSchemas = try OpenAPIParsing.parseNamedSchema(name: "widgetSchema", schema: schema) + #expect(irSchemas.count == 1) + let irSchema = irSchemas[0] #expect(irSchema.name == "widgetSchema") guard case .object(let properties) = irSchema.kind else { Issue.record("expected an object schema") @@ -48,8 +50,10 @@ struct SchemaParsingTests { """ let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) - let irSchema = try OpenAPIParsing.parseNamedSchema(name: "visibility", schema: schema) + let irSchemas = try OpenAPIParsing.parseNamedSchema(name: "visibility", schema: schema) + #expect(irSchemas.count == 1) + let irSchema = irSchemas[0] #expect(irSchema.name == "visibility") #expect(irSchema.kind == .stringEnum(cases: ["public", "private"])) } @@ -63,4 +67,31 @@ struct SchemaParsingTests { try OpenAPIParsing.parseNamedSchema(name: "count", schema: schema) } } + + @Test + func hoistsInlineEnumPropertyIntoItsOwnNamedSchema() throws { + let json = """ + { + "type": "object", + "required": ["id", "type"], + "properties": { + "id": {"type": "string"}, + "type": {"type": "string", "enum": ["STANDARD", "ANALYTICS"]} + } + } + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + let irSchemas = try OpenAPIParsing.parseNamedSchema(name: "bucketSchema", schema: schema) + + #expect(irSchemas.count == 2) + guard case .object(let properties) = irSchemas[0].kind else { + Issue.record("expected the first schema to be the object") + return + } + let byName = Dictionary(uniqueKeysWithValues: properties.map { ($0.name, $0) }) + #expect(byName["type"]?.type == .schemaRef("bucketSchema_type")) + #expect(irSchemas[1].name == "bucketSchema_type") + #expect(irSchemas[1].kind == .stringEnum(cases: ["STANDARD", "ANALYTICS"])) + } } From b3d5106f47e140351c3ddded4767f89dd363b5e1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 14:27:34 -0300 Subject: [PATCH 16/44] feat(codegen): hoist inline object request/response bodies into named schemas --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 123 +++++++++++++----- .../RequestBodyParsingTests.swift | 37 +++++- .../ResponseParsingTests.swift | 43 +++++- 3 files changed, 164 insertions(+), 39 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index d26dc26c8..8653f6998 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -29,34 +29,57 @@ public enum OpenAPIParsing { reason: "top-level schema must be an object or a string enum" ) } + let (properties, hoisted) = try parseObjectProperties( + name: name, objectContext: objectContext, location: name) + return [IRSchema(name: name, kind: .object(properties: properties))] + hoisted + } + + /// Parses an object schema's properties, hoisting any inline enum or + /// inline object-with-properties into its own named schema (recursively) + /// instead of failing. Unlike a union, there's no ambiguity in what Swift + /// type these become, so a name of `"\(name)_\(propertyName)"` is enough. + private static func parseObjectProperties( + name: String, + objectContext: JSONSchema.ObjectContext, + location: String + ) throws -> (properties: [IRProperty], hoisted: [IRSchema]) { var properties: [IRProperty] = [] - var hoistedSchemas: [IRSchema] = [] + var hoisted: [IRSchema] = [] for (propertyName, propertySchema) in objectContext.properties { + let propertyLocation = "\(location).\(propertyName)" + let isOptional = !propertySchema.required || propertySchema.nullable + if case .string = propertySchema.value, let allowedValues = propertySchema.allowedValues { - // Inline enum on an object property: hoist it into its own named - // schema instead of failing. Unlike a union or an inline object with - // properties, there's no ambiguity in what Swift type this becomes. let hoistedName = "\(name)_\(propertyName)" let cases = allowedValues.compactMap { $0.value as? String } - hoistedSchemas.append(IRSchema(name: hoistedName, kind: .stringEnum(cases: cases))) + hoisted.append(IRSchema(name: hoistedName, kind: .stringEnum(cases: cases))) properties.append( - IRProperty( - name: propertyName, - type: .schemaRef(hoistedName), - isOptional: !propertySchema.required || propertySchema.nullable - ) - ) + IRProperty(name: propertyName, type: .schemaRef(hoistedName), isOptional: isOptional)) continue } + + if case .object(_, let nestedContext) = propertySchema.value, + !nestedContext.properties.isEmpty + { + let hoistedName = "\(name)_\(propertyName)" + let (nestedProperties, nestedHoisted) = try parseObjectProperties( + name: hoistedName, objectContext: nestedContext, location: propertyLocation) + hoisted.append(IRSchema(name: hoistedName, kind: .object(properties: nestedProperties))) + hoisted.append(contentsOf: nestedHoisted) + properties.append( + IRProperty(name: propertyName, type: .schemaRef(hoistedName), isOptional: isOptional)) + continue + } + properties.append( IRProperty( name: propertyName, - type: try parseType(propertySchema, location: "\(name).\(propertyName)"), - isOptional: !propertySchema.required || propertySchema.nullable + type: try parseType(propertySchema, location: propertyLocation), + isOptional: isOptional ) ) } - return [IRSchema(name: name, kind: .object(properties: properties))] + hoistedSchemas + return (properties, hoisted) } static func parseType(_ schema: JSONSchema, location: String) throws -> IRType { @@ -147,18 +170,29 @@ public enum OpenAPIParsing { static func parseRequestBody( _ either: Either, OpenAPI.Request>, location: String - ) throws -> IRRequestBody { + ) throws -> (body: IRRequestBody, hoisted: [IRSchema]) { guard let request = either.requestValue else { throw UnsupportedSpecConstruct(location: location, reason: "external request body reference") } if let jsonContent = request.content.first(where: { $0.key.typeAndSubtype == "application/json" })?.value { - guard let schema = jsonContent.schema else { + guard let schemaEither = jsonContent.schema else { throw UnsupportedSpecConstruct( location: location, reason: "JSON request body without a schema") } - return .json(try resolveSchema(schema, location: location)) + if case .b(let inlineSchema) = schemaEither, + case .object(_, let objectContext) = inlineSchema.value, + !objectContext.properties.isEmpty + { + let hoistedName = "\(location)_requestBody" + let (properties, nestedHoisted) = try parseObjectProperties( + name: hoistedName, objectContext: objectContext, location: hoistedName) + let hoisted = + [IRSchema(name: hoistedName, kind: .object(properties: properties))] + nestedHoisted + return (.json(.schemaRef(hoistedName)), hoisted) + } + return (.json(try resolveSchema(schemaEither, location: location)), []) } if let multipartContent = request.content.first(where: { $0.key.typeAndSubtype == "multipart/form-data" @@ -183,7 +217,7 @@ public enum OpenAPIParsing { ) ) } - return .multipart(fields: fields) + return (.multipart(fields: fields), []) } let contentTypes = request.content.keys.map(\.rawValue).joined(separator: ", ") throw UnsupportedSpecConstruct( @@ -195,8 +229,9 @@ public enum OpenAPIParsing { static func parseResponses( _ responses: OpenAPI.Response.Map, location: String - ) throws -> [IRResponse] { + ) throws -> (responses: [IRResponse], hoisted: [IRSchema]) { var results: [IRResponse] = [] + var hoisted: [IRSchema] = [] for (statusCode, responseEither) in responses { guard case .status(let code) = statusCode.value else { // `default` and range ("4XX") responses aren't modeled in v1; skip them. @@ -206,29 +241,45 @@ public enum OpenAPIParsing { throw UnsupportedSpecConstruct( location: location, reason: "external response reference for status \(code)") } - let body = try parseResponseBody(response.content, location: "\(location) -> \(code)") + let (body, bodyHoisted) = try parseResponseBody( + response.content, location: "\(location) -> \(code)") + hoisted.append(contentsOf: bodyHoisted) results.append(IRResponse(statusCode: code, isError: !statusCode.isSuccess, body: body)) } - return results.sorted { $0.statusCode < $1.statusCode } + return (results.sorted { $0.statusCode < $1.statusCode }, hoisted) } static func parseResponseBody( _ content: OpenAPI.Content.Map, location: String - ) throws -> IRResponseBody { + ) throws -> (body: IRResponseBody, hoisted: [IRSchema]) { if let jsonContent = content.first(where: { $0.key.typeAndSubtype == "application/json" })? .value { - guard let schema = jsonContent.schema else { return .none } - return .json(try resolveSchema(schema, location: location)) + guard let schemaEither = jsonContent.schema else { return (.none, []) } + if case .b(let inlineSchema) = schemaEither, + case .object(_, let objectContext) = inlineSchema.value, + !objectContext.properties.isEmpty + { + let hoistedName = "\(location)_response" + let (properties, nestedHoisted) = try parseObjectProperties( + name: hoistedName, objectContext: objectContext, location: hoistedName) + let hoisted = + [IRSchema(name: hoistedName, kind: .object(properties: properties))] + nestedHoisted + return (.json(.schemaRef(hoistedName)), hoisted) + } + return (.json(try resolveSchema(schemaEither, location: location)), []) } - return content.isEmpty ? .none : .binary + return (content.isEmpty ? .none : .binary, []) } // MARK: - Operations - static func parseOperations(_ document: OpenAPI.Document) throws -> [IROperation] { + static func parseOperations(_ document: OpenAPI.Document) throws -> ( + operations: [IROperation], hoisted: [IRSchema] + ) { var operations: [IROperation] = [] + var hoisted: [IRSchema] = [] for (path, pathItemEither) in document.paths { guard let pathItem = pathItemEither.pathItemValue else { throw UnsupportedSpecConstruct( @@ -254,10 +305,15 @@ public enum OpenAPIParsing { for parameterEither in pathItem.parameters + operation.parameters { parameters.append(try parseParameter(parameterEither, location: operationId)) } - let requestBody = try operation.requestBody.map { - try parseRequestBody($0, location: operationId) + var requestBody: IRRequestBody? + if let requestBodyEither = operation.requestBody { + let (body, bodyHoisted) = try parseRequestBody(requestBodyEither, location: operationId) + requestBody = body + hoisted.append(contentsOf: bodyHoisted) } - let responses = try parseResponses(operation.responses, location: operationId) + let (responses, responsesHoisted) = try parseResponses( + operation.responses, location: operationId) + hoisted.append(contentsOf: responsesHoisted) operations.append( IROperation( operationId: operationId, @@ -270,7 +326,7 @@ public enum OpenAPIParsing { ) } } - return operations.sorted { $0.operationId < $1.operationId } + return (operations.sorted { $0.operationId < $1.operationId }, hoisted) } // MARK: - Document @@ -280,9 +336,8 @@ public enum OpenAPIParsing { for (key, schema) in document.components.schemas { schemas.append(contentsOf: try parseNamedSchema(name: key.rawValue, schema: schema)) } - return IRDocument( - schemas: schemas.sorted { $0.name < $1.name }, - operations: try parseOperations(document) - ) + let (operations, operationHoisted) = try parseOperations(document) + schemas.append(contentsOf: operationHoisted) + return IRDocument(schemas: schemas.sorted { $0.name < $1.name }, operations: operations) } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift index a64f6f6b4..26fd227da 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift @@ -27,9 +27,11 @@ struct RequestBodyParsingTests { } } """ - let body = try OpenAPIParsing.parseRequestBody(requestBody(json), location: "updateBucket") + let (body, hoisted) = try OpenAPIParsing.parseRequestBody( + requestBody(json), location: "updateBucket") #expect(body == .json(.schemaRef("bucketUpdate"))) + #expect(hoisted.isEmpty) } @Test @@ -49,7 +51,8 @@ struct RequestBodyParsingTests { } } """ - let body = try OpenAPIParsing.parseRequestBody(requestBody(json), location: "createObject") + let (body, hoisted) = try OpenAPIParsing.parseRequestBody( + requestBody(json), location: "createObject") guard case .multipart(let fields) = body else { Issue.record("expected a multipart request body") @@ -58,6 +61,7 @@ struct RequestBodyParsingTests { let byName = Dictionary(uniqueKeysWithValues: fields.map { ($0.name, $0) }) #expect(byName["cacheControl"]?.isFile == false) #expect(byName[""]?.isFile == true) + #expect(hoisted.isEmpty) } @Test @@ -69,4 +73,33 @@ struct RequestBodyParsingTests { try OpenAPIParsing.parseRequestBody(try requestBody(json), location: "op") } } + + @Test + func hoistsInlineObjectRequestBody() throws { + let json = """ + { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sourceKey": {"type": "string"}, + "metadata": { + "type": "object", + "properties": {"cacheControl": {"type": "string"}} + } + } + } + } + } + } + """ + let (body, hoisted) = try OpenAPIParsing.parseRequestBody( + requestBody(json), location: "copyObject") + + #expect(body == .json(.schemaRef("copyObject_requestBody"))) + #expect(hoisted.count == 2) + #expect(hoisted[0].name == "copyObject_requestBody") + #expect(hoisted[1].name == "copyObject_requestBody_metadata") + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift index 08aa47edd..a3e8e4fdf 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift @@ -27,7 +27,7 @@ struct ResponseParsingTests { """ let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) - let irResponses = try OpenAPIParsing.parseResponses(responses, location: "getBucket") + let (irResponses, hoisted) = try OpenAPIParsing.parseResponses(responses, location: "getBucket") #expect(irResponses.count == 2) #expect(irResponses[0].statusCode == 200) @@ -36,6 +36,7 @@ struct ResponseParsingTests { #expect(irResponses[1].statusCode == 404) #expect(irResponses[1].isError == true) #expect(irResponses[1].body == .json(.schemaRef("errorSchema"))) + #expect(hoisted.isEmpty) } @Test @@ -50,9 +51,10 @@ struct ResponseParsingTests { """ let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) - let irResponses = try OpenAPIParsing.parseResponses(responses, location: "download") + let (irResponses, hoisted) = try OpenAPIParsing.parseResponses(responses, location: "download") #expect(irResponses[0].body == .binary) + #expect(hoisted.isEmpty) } @Test @@ -65,10 +67,45 @@ struct ResponseParsingTests { """ let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) - let irResponses = try OpenAPIParsing.parseResponses(responses, location: "op") + let (irResponses, hoisted) = try OpenAPIParsing.parseResponses(responses, location: "op") #expect(irResponses.count == 1) #expect(irResponses[0].statusCode == 200) + #expect(hoisted.isEmpty) + } + + @Test + func hoistsInlineObjectResponseBody() throws { + let json = """ + { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": {"type": "string"}, + "metadata": { + "type": "object", + "properties": {"cacheControl": {"type": "string"}} + } + } + } + } + } + } + } + """ + let responses = try JSONDecoder().decode(OpenAPI.Response.Map.self, from: Data(json.utf8)) + + let (irResponses, hoisted) = try OpenAPIParsing.parseResponses( + responses, location: "copyObject") + + #expect(irResponses[0].body == .json(.schemaRef("copyObject -> 200_response"))) + #expect(hoisted.count == 2) + #expect(hoisted[0].name == "copyObject -> 200_response") + #expect(hoisted[1].name == "copyObject -> 200_response_metadata") } @Test From ca9d0c22bfbc33855232f74d62bc6e213a1af07a Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 14:42:48 -0300 Subject: [PATCH 17/44] fix(codegen): use identifier-safe names for hoisted response-body schemas --- .../Sources/OpenAPICodegenCore/OpenAPIParsing.swift | 6 ++++-- .../OpenAPICodegenCoreTests/ResponseParsingTests.swift | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 8653f6998..7d79003c5 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -242,7 +242,7 @@ public enum OpenAPIParsing { location: location, reason: "external response reference for status \(code)") } let (body, bodyHoisted) = try parseResponseBody( - response.content, location: "\(location) -> \(code)") + response.content, operationId: location, statusCode: code, location: "\(location) -> \(code)") hoisted.append(contentsOf: bodyHoisted) results.append(IRResponse(statusCode: code, isError: !statusCode.isSuccess, body: body)) } @@ -251,6 +251,8 @@ public enum OpenAPIParsing { static func parseResponseBody( _ content: OpenAPI.Content.Map, + operationId: String, + statusCode: Int, location: String ) throws -> (body: IRResponseBody, hoisted: [IRSchema]) { if let jsonContent = content.first(where: { $0.key.typeAndSubtype == "application/json" })? @@ -261,7 +263,7 @@ public enum OpenAPIParsing { case .object(_, let objectContext) = inlineSchema.value, !objectContext.properties.isEmpty { - let hoistedName = "\(location)_response" + let hoistedName = "\(operationId)_response\(statusCode)" let (properties, nestedHoisted) = try parseObjectProperties( name: hoistedName, objectContext: objectContext, location: hoistedName) let hoisted = diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift index a3e8e4fdf..741fc040b 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift @@ -102,10 +102,10 @@ struct ResponseParsingTests { let (irResponses, hoisted) = try OpenAPIParsing.parseResponses( responses, location: "copyObject") - #expect(irResponses[0].body == .json(.schemaRef("copyObject -> 200_response"))) + #expect(irResponses[0].body == .json(.schemaRef("copyObject_response200"))) #expect(hoisted.count == 2) - #expect(hoisted[0].name == "copyObject -> 200_response") - #expect(hoisted[1].name == "copyObject -> 200_response_metadata") + #expect(hoisted[0].name == "copyObject_response200") + #expect(hoisted[1].name == "copyObject_response200_metadata") } @Test From 4703169397713d987b8483d26df529d076be9582 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 15:00:55 -0300 Subject: [PATCH 18/44] test(codegen): use inline snapshots for generated-code assertions Replace multiple .contains(...) substring checks in ModelEmitterTests, ClientEmitterTests, and EndToEndTests with assertInlineSnapshot, so a single snapshot catches unintended changes anywhere in the generated Swift output instead of only the specific substrings a .contains check happened to look for. --- tools/openapi-codegen/Package.resolved | 38 +++++++- tools/openapi-codegen/Package.swift | 9 +- .../ClientEmitterTests.swift | 96 ++++++++++++++++--- .../ModelEmitterTests.swift | 59 ++++++++++-- .../openapi-codegenTests/EndToEndTests.swift | 59 ++++++++++-- 5 files changed, 230 insertions(+), 31 deletions(-) diff --git a/tools/openapi-codegen/Package.resolved b/tools/openapi-codegen/Package.resolved index e71ecf711..06d55d9cd 100644 --- a/tools/openapi-codegen/Package.resolved +++ b/tools/openapi-codegen/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "24a7d95e7ed17e9182ff3018d5123a9bceaf01f1b8e978eaf20463f38a33a99b", + "originHash" : "27b1f1c87d8f63db11d5c57414c1b5bd1aa188e072260a841c3626eed61728d8", "pins" : [ { "identity" : "openapikit", @@ -9,6 +9,42 @@ "revision" : "57b6318128e3f901c93f4fbf98d1c1464ec168d3", "version" : "6.2.0" } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", + "version" : "1.19.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", + "version" : "1.10.1" + } } ], "version" : 3 diff --git a/tools/openapi-codegen/Package.swift b/tools/openapi-codegen/Package.swift index 1ea701bff..9356acf58 100644 --- a/tools/openapi-codegen/Package.swift +++ b/tools/openapi-codegen/Package.swift @@ -8,7 +8,8 @@ let package = Package( .executable(name: "openapi-codegen", targets: ["openapi-codegen"]) ], dependencies: [ - .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "6.2.0") + .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "6.2.0"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), ], targets: [ .target( @@ -23,13 +24,17 @@ let package = Package( ), .testTarget( name: "OpenAPICodegenCoreTests", - dependencies: ["OpenAPICodegenCore"] + dependencies: [ + "OpenAPICodegenCore", + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + ] ), .testTarget( name: "openapi-codegenTests", dependencies: [ "OpenAPICodegenCore", .product(name: "OpenAPIKit30", package: "OpenAPIKit"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), ] ), ] diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift index 93c6efcf6..96845353e 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift @@ -2,6 +2,7 @@ // ClientEmitterTests.swift // +import InlineSnapshotTesting import Testing @testable import OpenAPICodegenCore @@ -32,14 +33,31 @@ struct ClientEmitterTests { let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") - #expect(output.contains("public struct StorageOpenAPIClient: Sendable {")) - #expect(output.contains("public func getBucket(bucketId: String) async throws -> BucketSchema {")) - #expect( - output.contains( - "HTTPRequestBuilder(method: .get, baseURL: baseURL, path: \"/bucket/\\(PathEncoding.segment(bucketId))\")" - )) - #expect(output.contains("try response.checkStatus(errorTypes: [404: ErrorSchema.self])")) - #expect(output.contains("return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body)")) + assertInlineSnapshot(of: output, as: .lines) { + #""" + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + public func getBucket(bucketId: String) async throws -> BucketSchema { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [404: ErrorSchema.self]) + return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body) + } + } + """# + } } @Test @@ -67,10 +85,35 @@ struct ClientEmitterTests { let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") - #expect(output.contains("file: URL")) - #expect(output.contains("cacheControl: String")) - #expect(output.contains("source: .file(file)")) - #expect(output.contains("builder.setBody(.multipart(formData))")) + assertInlineSnapshot(of: output, as: .lines) { + #""" + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + public func createObject(bucketId: String, file: URL, cacheControl: String) async throws { + var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/object/\(PathEncoding.segment(bucketId))") + var formData = MultipartFormData() + formData.append(MultipartFormData.Part(name: "file", filename: file.lastPathComponent, contentType: "application/octet-stream", source: .file(file))) + formData.append(MultipartFormData.Part(name: "cacheControl", source: .data(Data(String(describing: cacheControl).utf8)))) + builder.setHeader("Content-Type", formData.contentType) + builder.setBody(.multipart(formData)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: []) + } + } + """# + } } @Test @@ -93,7 +136,32 @@ struct ClientEmitterTests { let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") - #expect(output.contains("-> AsyncThrowingStream {")) - #expect(output.contains("transport.stream(try builder.build())")) + assertInlineSnapshot(of: output, as: .lines) { + #""" + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + public func download(bucketId: String) async throws -> AsyncThrowingStream { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/object/\(PathEncoding.segment(bucketId))") + let stream = try await transport.stream(try builder.build()) + guard stream.head.isSuccess else { + throw HTTPError.unexpectedStatus(status: stream.head.status, body: Data()) + } + return stream.body + } + } + """# + } } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift index bfd4ee13a..ca7a89fa4 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift @@ -2,6 +2,7 @@ // ModelEmitterTests.swift // +import InlineSnapshotTesting import Testing @testable import OpenAPICodegenCore @@ -26,10 +27,25 @@ struct ModelEmitterTests { let output = SwiftEmitter.emitModels(document) - #expect(output.contains("public struct BucketSchema: Codable, Sendable, Hashable {")) - #expect(output.contains("public var id: String")) - #expect(output.contains("public var fileSizeLimit: Int?")) - #expect(output.contains(#"case fileSizeLimit = "file_size_limit""#)) + assertInlineSnapshot(of: output, as: .lines) { + """ + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public struct BucketSchema: Codable, Sendable, Hashable { + public var id: String + public var fileSizeLimit: Int? + + enum CodingKeys: String, CodingKey { + case id = "id" + case fileSizeLimit = "file_size_limit" + } + } + + """ + } } @Test @@ -41,9 +57,20 @@ struct ModelEmitterTests { let output = SwiftEmitter.emitModels(document) - #expect(output.contains("public enum Visibility: String, Codable, Sendable, Hashable {")) - #expect(output.contains(#"case `public` = "public""#)) - #expect(output.contains(#"case `private` = "private""#)) + assertInlineSnapshot(of: output, as: .lines) { + """ + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public enum Visibility: String, Codable, Sendable, Hashable { + case `public` = "public" + case `private` = "private" + } + + """ + } } @Test @@ -71,6 +98,22 @@ struct ModelEmitterTests { let output = SwiftEmitter.emitModels(document) - #expect(output.contains("public struct ErrorSchema: Codable, Sendable, Hashable, APIError {")) + assertInlineSnapshot(of: output, as: .lines) { + """ + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public struct ErrorSchema: Codable, Sendable, Hashable, APIError { + public var message: String + + enum CodingKeys: String, CodingKey { + case message = "message" + } + } + + """ + } } } diff --git a/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift b/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift index 25ba40458..8a62f1ce6 100644 --- a/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift +++ b/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift @@ -3,6 +3,7 @@ // import Foundation +import InlineSnapshotTesting import OpenAPIKit30 import Testing @@ -66,11 +67,57 @@ struct EndToEndTests { let models = SwiftEmitter.emitModels(irDocument) let client = SwiftEmitter.emitClient(irDocument, clientName: "StorageOpenAPIClient") - #expect(models.contains("public struct BucketSchema: Codable, Sendable, Hashable {")) - #expect(models.contains("public var `public`: Bool")) - #expect(models.contains("public struct ErrorSchema: Codable, Sendable, Hashable, APIError {")) - #expect( - client.contains("public func getBucket(bucketId: String) async throws -> BucketSchema {")) - #expect(client.contains("errorTypes: [404: ErrorSchema.self]")) + assertInlineSnapshot(of: models, as: .lines) { + """ + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public struct BucketSchema: Codable, Sendable, Hashable { + public var id: String + public var `public`: Bool + + enum CodingKeys: String, CodingKey { + case id = "id" + case `public` = "public" + } + } + + public struct ErrorSchema: Codable, Sendable, Hashable, APIError { + public var message: String + + enum CodingKeys: String, CodingKey { + case message = "message" + } + } + + """ + } + assertInlineSnapshot(of: client, as: .lines) { + #""" + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + public func getBucket(bucketId: String) async throws -> BucketSchema { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [404: ErrorSchema.self]) + return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body) + } + } + """# + } } } From cd30b4302b01a5dcb8f4ee6f47f9553de4bb85b2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 15:13:08 -0300 Subject: [PATCH 19/44] fix(codegen): sort object properties deterministically to eliminate flaky snapshot tests parseObjectProperties iterated objectContext.properties (an OpenAPIKit OrderedDictionary populated via JSONDecoder's container.allKeys) without sorting. Decodable's keyed-container key order isn't guaranteed stable across decode calls, so the emitted struct's property/CodingKeys order could differ run to run, causing EndToEndTests to flake (confirmed: 1 failure in 5 consecutive runs, id/public swapped). Sort by property name before iterating, matching the sort-for-determinism pattern already used elsewhere in this parser (parseNamedSchema/parseDocument's schema sort, parseOperations's operation sort, parseResponses's status-code sort). --- .../Sources/OpenAPICodegenCore/OpenAPIParsing.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 7d79003c5..2c39483a6 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -45,7 +45,7 @@ public enum OpenAPIParsing { ) throws -> (properties: [IRProperty], hoisted: [IRSchema]) { var properties: [IRProperty] = [] var hoisted: [IRSchema] = [] - for (propertyName, propertySchema) in objectContext.properties { + for (propertyName, propertySchema) in objectContext.properties.sorted(by: { $0.key < $1.key }) { let propertyLocation = "\(location).\(propertyName)" let isOptional = !propertySchema.required || propertySchema.nullable From 22ba3441b7ba9109424dfe1dfbc0b89268fc8e98 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 15:56:06 -0300 Subject: [PATCH 20/44] feat(codegen): hoist inline enum parameters into named schemas --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 22 ++++++++++++++++--- .../ParameterParsingTests.swift | 22 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 2c39483a6..4cbd4badb 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -121,7 +121,7 @@ public enum OpenAPIParsing { static func parseParameter( _ either: Either, OpenAPI.Parameter>, location: String - ) throws -> IRParameter { + ) throws -> (parameter: IRParameter, hoisted: IRSchema?) { guard let parameter = either.parameterValue else { throw UnsupportedSpecConstruct(location: location, reason: "external parameter reference") } @@ -139,12 +139,24 @@ public enum OpenAPIParsing { throw UnsupportedSpecConstruct( location: parameterLocation, reason: "parameter uses 'content' instead of 'schema'") } - return IRParameter( + if case .string = schema.value, let allowedValues = schema.allowedValues { + let hoistedName = "\(location)_\(parameter.name)" + let cases = allowedValues.compactMap { $0.value as? String } + let irParameter = IRParameter( + name: parameter.name, + location: irLocation, + type: .schemaRef(hoistedName), + isOptional: !parameter.required || schema.nullable + ) + return (irParameter, IRSchema(name: hoistedName, kind: .stringEnum(cases: cases))) + } + let irParameter = IRParameter( name: parameter.name, location: irLocation, type: try parseType(schema, location: parameterLocation), isOptional: !parameter.required || schema.nullable ) + return (irParameter, nil) } // MARK: - Schema references in content bodies @@ -305,7 +317,11 @@ public enum OpenAPIParsing { } var parameters: [IRParameter] = [] for parameterEither in pathItem.parameters + operation.parameters { - parameters.append(try parseParameter(parameterEither, location: operationId)) + let (parameter, parameterHoisted) = try parseParameter(parameterEither, location: operationId) + parameters.append(parameter) + if let parameterHoisted { + hoisted.append(parameterHoisted) + } } var requestBody: IRRequestBody? if let requestBodyEither = operation.requestBody { diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift index 5288244c5..8e3e66ebe 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift @@ -23,12 +23,13 @@ struct ParameterParsingTests { let json = """ {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} """ - let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + let (irParameter, hoisted) = try OpenAPIParsing.parseParameter(parameter(json), location: "op") #expect(irParameter.name == "bucketId") #expect(irParameter.location == .path) #expect(irParameter.type == .string) #expect(irParameter.isOptional == false) + #expect(hoisted == nil) } @Test @@ -36,11 +37,12 @@ struct ParameterParsingTests { let json = """ {"name": "limit", "in": "query", "schema": {"type": "integer"}} """ - let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + let (irParameter, hoisted) = try OpenAPIParsing.parseParameter(parameter(json), location: "op") #expect(irParameter.location == .query) #expect(irParameter.type == .integer) #expect(irParameter.isOptional == true) + #expect(hoisted == nil) } @Test @@ -48,9 +50,10 @@ struct ParameterParsingTests { let json = """ {"name": "if-none-match", "in": "header", "schema": {"type": "string"}} """ - let irParameter = try OpenAPIParsing.parseParameter(parameter(json), location: "op") + let (irParameter, hoisted) = try OpenAPIParsing.parseParameter(parameter(json), location: "op") #expect(irParameter.location == .header) + #expect(hoisted == nil) } @Test @@ -62,4 +65,17 @@ struct ParameterParsingTests { try OpenAPIParsing.parseParameter(try parameter(json), location: "op") } } + + @Test + func hoistsInlineEnumParameterIntoItsOwnNamedSchema() throws { + let json = """ + {"name": "resize", "in": "query", "schema": {"type": "string", "enum": ["cover", "contain", "fill"]}} + """ + let (irParameter, hoisted) = try OpenAPIParsing.parseParameter(parameter(json), location: "renderImagePublic") + + #expect(irParameter.name == "resize") + #expect(irParameter.type == .schemaRef("renderImagePublic_resize")) + #expect(hoisted?.name == "renderImagePublic_resize") + #expect(hoisted?.kind == .stringEnum(cases: ["cover", "contain", "fill"])) + } } From 4cb345f6bf727dc1ee30ed7c6c2642d4dbcec3e0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 17:06:42 -0300 Subject: [PATCH 21/44] fix(codegen): skip operations without an operationId instead of failing GET /metrics (Storage's Prometheus scrape endpoint) has no operationId. It isn't part of the typed API surface anyone calls from Swift, so skip such operations rather than failing the whole parse. --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 3 +- .../ResponseParsingTests.swift | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 4cbd4badb..6bc3e6f72 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -311,9 +311,8 @@ public enum OpenAPIParsing { ] for (method, maybeOperation) in methodOperations { guard let operation = maybeOperation else { continue } - let operationLocation = "\(method.rawValue.uppercased()) \(path.rawValue)" guard let operationId = operation.operationId else { - throw UnsupportedSpecConstruct(location: operationLocation, reason: "missing operationId") + continue } var parameters: [IRParameter] = [] for parameterEither in pathItem.parameters + operation.parameters { diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift index 741fc040b..1c10c40f1 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift @@ -153,4 +153,37 @@ struct ResponseParsingTests { #expect(irDocument.operations[0].method == .get) #expect(irDocument.operations[0].path == "/bucket/{bucketId}") } + + @Test + func skipsOperationsWithoutAnOperationId() throws { + let json = """ + { + "openapi": "3.0.3", + "info": {"title": "Storage", "version": "1.0.0"}, + "paths": { + "/bucket/{bucketId}": { + "get": { + "operationId": "getBucket", + "parameters": [ + {"name": "bucketId", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": {"200": {"description": "ok", "content": {}}} + } + }, + "/metrics": { + "get": { + "responses": {"200": {"description": "ok", "content": {}}} + } + } + }, + "components": {"schemas": {}} + } + """ + let document = try JSONDecoder().decode(OpenAPI.Document.self, from: Data(json.utf8)) + + let irDocument = try OpenAPIParsing.parseDocument(document) + + #expect(irDocument.operations.count == 1) + #expect(irDocument.operations[0].operationId == "getBucket") + } } From 0e6affc59b4859e7b7ffca5b6fcb3e6e5da79b76 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 17:11:54 -0300 Subject: [PATCH 22/44] fix(codegen): sort path and schema iteration for deterministic parse failures --- .../Sources/OpenAPICodegenCore/OpenAPIParsing.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 6bc3e6f72..51be3efd1 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -294,7 +294,7 @@ public enum OpenAPIParsing { ) { var operations: [IROperation] = [] var hoisted: [IRSchema] = [] - for (path, pathItemEither) in document.paths { + for (path, pathItemEither) in document.paths.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { guard let pathItem = pathItemEither.pathItemValue else { throw UnsupportedSpecConstruct( location: path.rawValue, reason: "external path item reference") @@ -350,7 +350,7 @@ public enum OpenAPIParsing { public static func parseDocument(_ document: OpenAPI.Document) throws -> IRDocument { var schemas: [IRSchema] = [] - for (key, schema) in document.components.schemas { + for (key, schema) in document.components.schemas.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { schemas.append(contentsOf: try parseNamedSchema(name: key.rawValue, schema: schema)) } let (operations, operationHoisted) = try parseOperations(document) From 789782d23d413d6920c82803bd95a3f92ad7dbd1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 17:29:48 -0300 Subject: [PATCH 23/44] feat(codegen): support oneOf/anyOf unions as tagged Swift enums --- .../Sources/OpenAPICodegenCore/IR.swift | 11 +++ .../OpenAPICodegenCore/OpenAPIParsing.swift | 68 ++++++++++++++++++ .../OpenAPICodegenCore/SwiftEmitter.swift | 43 ++++++++++++ .../ModelEmitterTests.swift | 57 +++++++++++++++ .../UnionParsingTests.swift | 69 +++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/UnionParsingTests.swift diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift index ebb0dc777..e30cedae5 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/IR.swift @@ -18,6 +18,17 @@ public struct IRSchema: Equatable { public enum IRSchemaKind: Equatable { case object(properties: [IRProperty]) case stringEnum(cases: [String]) + case union(cases: [IRUnionCase]) +} + +public struct IRUnionCase: Equatable { + public var name: String + public var type: IRType + + public init(name: String, type: IRType) { + self.name = name + self.type = type + } } public struct IRProperty: Equatable { diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 51be3efd1..7cb119e1b 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -71,6 +71,14 @@ public enum OpenAPIParsing { continue } + if let (type, hoistedSchema) = try hoistUnionIfPresent( + name: "\(name)_\(propertyName)", schema: propertySchema, location: propertyLocation) + { + hoisted.append(hoistedSchema) + properties.append(IRProperty(name: propertyName, type: type, isOptional: isOptional)) + continue + } + properties.append( IRProperty( name: propertyName, @@ -82,6 +90,43 @@ public enum OpenAPIParsing { return (properties, hoisted) } + /// If `schema` is a `oneOf`/`anyOf` whose branches are all simple (scalar, + /// array-of-simple, `$ref`, or freeform object — never a nested union or an + /// inline object with properties), hoists it into a named tagged-union + /// schema. Returns `nil` if `schema` isn't a union at all, so callers fall + /// through to their existing logic. Propagates any error from parsing an + /// unsupported branch rather than silently dropping it. + private static func hoistUnionIfPresent( + name: String, + schema: JSONSchema, + location: String + ) throws -> (type: IRType, hoisted: IRSchema)? { + let branches: [JSONSchema] + switch schema.value { + case .one(let schemas, _): branches = schemas + case .any(let schemas, _): branches = schemas + default: return nil + } + var cases: [IRUnionCase] = [] + for (index, branch) in branches.enumerated() { + let branchType = try parseType(branch, location: "\(location)[\(index)]") + cases.append(IRUnionCase(name: unionCaseName(for: branchType), type: branchType)) + } + return (.schemaRef(name), IRSchema(name: name, kind: .union(cases: cases))) + } + + private static func unionCaseName(for type: IRType) -> String { + switch type { + case .string: return "string" + case .integer: return "integer" + case .number: return "number" + case .boolean: return "boolean" + case .array: return "array" + case .freeform: return "freeform" + case .schemaRef(let name): return SwiftNames.propertyName(name) + } + } + static func parseType(_ schema: JSONSchema, location: String) throws -> IRType { if case .string = schema.value, schema.allowedValues != nil { throw UnsupportedSpecConstruct( @@ -150,6 +195,17 @@ public enum OpenAPIParsing { ) return (irParameter, IRSchema(name: hoistedName, kind: .stringEnum(cases: cases))) } + if let (type, hoistedSchema) = try hoistUnionIfPresent( + name: "\(location)_\(parameter.name)", schema: schema, location: parameterLocation) + { + let irParameter = IRParameter( + name: parameter.name, + location: irLocation, + type: type, + isOptional: !parameter.required || schema.nullable + ) + return (irParameter, hoistedSchema) + } let irParameter = IRParameter( name: parameter.name, location: irLocation, @@ -204,6 +260,12 @@ public enum OpenAPIParsing { [IRSchema(name: hoistedName, kind: .object(properties: properties))] + nestedHoisted return (.json(.schemaRef(hoistedName)), hoisted) } + if case .b(let inlineSchema) = schemaEither, + let (type, hoistedSchema) = try hoistUnionIfPresent( + name: "\(location)_requestBody", schema: inlineSchema, location: location) + { + return (.json(type), [hoistedSchema]) + } return (.json(try resolveSchema(schemaEither, location: location)), []) } if let multipartContent = request.content.first(where: { @@ -282,6 +344,12 @@ public enum OpenAPIParsing { [IRSchema(name: hoistedName, kind: .object(properties: properties))] + nestedHoisted return (.json(.schemaRef(hoistedName)), hoisted) } + if case .b(let inlineSchema) = schemaEither, + let (type, hoistedSchema) = try hoistUnionIfPresent( + name: "\(operationId)_response\(statusCode)", schema: inlineSchema, location: location) + { + return (.json(type), [hoistedSchema]) + } return (.json(try resolveSchema(schemaEither, location: location)), []) } return (content.isEmpty ? .none : .binary, []) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift index 17a917218..2693b2e6c 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift @@ -39,6 +39,8 @@ public enum SwiftEmitter { return emitStruct(named: typeName, properties: properties, isError: isError) case .stringEnum(let cases): return emitStringEnum(named: typeName, cases: cases, isError: isError) + case .union(let cases): + return emitUnion(named: typeName, cases: cases, isError: isError) } } @@ -73,6 +75,47 @@ public enum SwiftEmitter { return lines.joined(separator: "\n") } + static func emitUnion(named typeName: String, cases: [IRUnionCase], isError: Bool) -> String { + let conformances = (["Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) + .joined(separator: ", ") + var lines = ["public enum \(typeName): \(conformances) {"] + for unionCase in cases { + let caseName = SwiftNames.propertyName(unionCase.name) + let typeRef = SwiftNames.typeReference(unionCase.type, isOptional: false) + lines.append(" case \(caseName)(\(typeRef))") + } + lines.append("") + lines.append(" public init(from decoder: Decoder) throws {") + lines.append(" let container = try decoder.singleValueContainer()") + for unionCase in cases { + let caseName = SwiftNames.propertyName(unionCase.name) + let typeRef = SwiftNames.typeReference(unionCase.type, isOptional: false) + lines.append(" if let value = try? container.decode(\(typeRef).self) {") + lines.append(" self = .\(caseName)(value)") + lines.append(" return") + lines.append(" }") + } + lines.append(" throw DecodingError.typeMismatch(") + lines.append(" \(typeName).self,") + lines.append( + " DecodingError.Context(codingPath: decoder.codingPath, debugDescription: \"no matching case\")" + ) + lines.append(" )") + lines.append(" }") + lines.append("") + lines.append(" public func encode(to encoder: Encoder) throws {") + lines.append(" var container = encoder.singleValueContainer()") + lines.append(" switch self {") + for unionCase in cases { + let caseName = SwiftNames.propertyName(unionCase.name) + lines.append(" case .\(caseName)(let value): try container.encode(value)") + } + lines.append(" }") + lines.append(" }") + lines.append("}") + return lines.joined(separator: "\n") + } + // MARK: - Client public static func emitClient(_ document: IRDocument, clientName: String) -> String { diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift index ca7a89fa4..c2dcc02b5 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift @@ -116,4 +116,61 @@ struct ModelEmitterTests { """ } } + + @Test + func emitsUnionAsTaggedEnum() { + let document = IRDocument( + schemas: [ + IRSchema( + name: "bucketCreate_fileSizeLimit", + kind: .union(cases: [ + IRUnionCase(name: "integer", type: .integer), + IRUnionCase(name: "string", type: .string), + ]) + ) + ], + operations: [] + ) + + let output = SwiftEmitter.emitModels(document) + + assertInlineSnapshot(of: output, as: .lines) { + """ + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + public enum BucketCreateFileSizeLimit: Codable, Sendable, Hashable { + case integer(Int) + case string(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int.self) { + self = .integer(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + throw DecodingError.typeMismatch( + BucketCreateFileSizeLimit.self, + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "no matching case") + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .integer(let value): try container.encode(value) + case .string(let value): try container.encode(value) + } + } + } + + """ + } + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/UnionParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/UnionParsingTests.swift new file mode 100644 index 000000000..43c5b7613 --- /dev/null +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/UnionParsingTests.swift @@ -0,0 +1,69 @@ +// +// UnionParsingTests.swift +// + +import Foundation +import OpenAPIKit30 +import Testing + +@testable import OpenAPICodegenCore + +@Suite +struct UnionParsingTests { + + @Test + func hoistsAnyOfWithScalarBranchesFromAnObjectProperty() throws { + let json = """ + { + "type": "object", + "properties": { + "fileSizeLimit": { + "anyOf": [ + {"type": "integer", "nullable": true}, + {"type": "string", "nullable": true} + ] + } + } + } + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + let irSchemas = try OpenAPIParsing.parseNamedSchema(name: "bucketCreate", schema: schema) + + #expect(irSchemas.count == 2) + guard case .object(let properties) = irSchemas[0].kind else { + Issue.record("expected the first schema to be the object") + return + } + #expect(properties[0].type == .schemaRef("bucketCreate_fileSizeLimit")) + #expect(irSchemas[1].name == "bucketCreate_fileSizeLimit") + #expect( + irSchemas[1].kind + == .union(cases: [ + IRUnionCase(name: "integer", type: .integer), + IRUnionCase(name: "string", type: .string), + ])) + } + + @Test + func rejectsUnionBranchThatIsItselfUnsupported() throws { + let json = """ + { + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "integer"}, + {"type": "object", "properties": {"nested": {"type": "string"}}} + ] + } + } + } + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseNamedSchema(name: "widget", schema: schema) + } + } +} From ee078a38606cb7db9b556143995e77c828d78a3e Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 17:38:36 -0300 Subject: [PATCH 24/44] fix(codegen): treat typeless fragment schemas as freeform --- .../Sources/OpenAPICodegenCore/OpenAPIParsing.swift | 2 ++ .../Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 7cb119e1b..3cd9f4bc7 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -150,6 +150,8 @@ public enum OpenAPIParsing { return .array(try parseType(items, location: location + "[]")) case .object(_, let objectContext) where objectContext.properties.isEmpty: return .freeform + case .fragment: + return .freeform case .reference(let reference, _): guard let name = reference.name else { throw UnsupportedSpecConstruct( diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift index 7cd682f48..145ccf765 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift @@ -67,4 +67,10 @@ struct TypeParsingTests { try OpenAPIParsing.parseType(try schema(json), location: "test") } } + + @Test + func parsesTypelessFragmentSchemaAsFreeform() throws { + let type = try OpenAPIParsing.parseType(schema(#"{"description": "Default Response"}"#), location: "test") + #expect(type == .freeform) + } } From ac67f33f5b7dfbe2c7e7991630e657a78a580f0d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 17:46:38 -0300 Subject: [PATCH 25/44] fix(codegen): disambiguate colliding union case names --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 7 ++++- .../UnionParsingTests.swift | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 3cd9f4bc7..ae7059dac 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -108,9 +108,14 @@ public enum OpenAPIParsing { default: return nil } var cases: [IRUnionCase] = [] + var usedNames: [String: Int] = [:] for (index, branch) in branches.enumerated() { let branchType = try parseType(branch, location: "\(location)[\(index)]") - cases.append(IRUnionCase(name: unionCaseName(for: branchType), type: branchType)) + let baseName = unionCaseName(for: branchType) + let occurrence = (usedNames[baseName] ?? 0) + 1 + usedNames[baseName] = occurrence + let caseName = occurrence == 1 ? baseName : "\(baseName)\(occurrence)" + cases.append(IRUnionCase(name: caseName, type: branchType)) } return (.schemaRef(name), IRSchema(name: name, kind: .union(cases: cases))) } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/UnionParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/UnionParsingTests.swift index 43c5b7613..db8bf40ed 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/UnionParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/UnionParsingTests.swift @@ -66,4 +66,32 @@ struct UnionParsingTests { try OpenAPIParsing.parseNamedSchema(name: "widget", schema: schema) } } + + @Test + func disambiguatesCollidingCaseNamesInTheSameUnion() throws { + let json = """ + { + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "array", "items": {"type": "integer"}} + ] + } + } + } + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + let irSchemas = try OpenAPIParsing.parseNamedSchema(name: "widget", schema: schema) + + #expect(irSchemas.count == 2) + #expect( + irSchemas[1].kind + == .union(cases: [ + IRUnionCase(name: "array", type: .array(.string)), + IRUnionCase(name: "array2", type: .array(.integer)), + ])) + } } From f9b42afeb9f89ed7bd6cecad6453ba2f2554b950 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 17:57:46 -0300 Subject: [PATCH 26/44] docs(storage): commit the OpenAPI spec fixed by storage#1215 --- openapi/storage.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 openapi/storage.json diff --git a/openapi/storage.json b/openapi/storage.json new file mode 100644 index 000000000..ddd5ed398 --- /dev/null +++ b/openapi/storage.json @@ -0,0 +1 @@ +{"openapi":"3.0.3","info":{"title":"Supabase Storage API","description":"API documentation for Supabase Storage","version":"0.0.0"},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"jwt"}},"schemas":{"authSchema":{"type":"object","properties":{"authorization":{"type":"string","example":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs"}},"required":["authorization"]},"errorSchema":{"type":"object","properties":{"statusCode":{"type":"string"},"error":{"type":"string"},"message":{"type":"string"}},"required":["statusCode","error","message"]},"bucketSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":"integer","nullable":true},"allowed_mime_types":{"type":"array","nullable":true,"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}},"objectSchema":{"type":"object","properties":{"name":{"type":"string"},"bucket_id":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"version":{"type":"string"},"id":{"type":"string","nullable":true},"updated_at":{"type":"string","nullable":true},"created_at":{"type":"string","nullable":true},"last_accessed_at":{"type":"string","nullable":true},"metadata":{"type":"object","additionalProperties":true,"nullable":true},"user_metadata":{"type":"object","additionalProperties":true,"nullable":true},"buckets":{"$ref":"#/components/schemas/bucketSchema"}},"required":["name"],"additionalProperties":false,"example":{"name":"folder/cat.png","bucket_id":"avatars","owner":"317eadce-631a-4429-a0bb-f19a7a517b4a","id":"eaa8bdb5-2e00-4767-b5a9-d2502efe2196","updated_at":"2021-04-06T16:30:35.394674+00:00","created_at":"2021-04-06T16:30:35.394674+00:00","last_accessed_at":"2021-04-06T16:30:35.394674+00:00","metadata":{"size":1234}}}}},"paths":{"/metrics":{"get":{"responses":{"200":{"description":"Default Response"}}},"head":{"responses":{"200":{"description":"Default Response"}}}},"/upload/resumable/":{"post":{"operationId":"tusUploadCreate","summary":"Handle POST request for TUS Resumable uploads","tags":["resumable"],"description":"Creates a new resumable upload and returns its Location URL; the sign/ variant authorizes via the x-signature header from a presigned token instead of a JWT","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"options":{"operationId":"tusOptions","summary":"Handle OPTIONS request for TUS Resumable uploads","tags":["resumable"],"description":"Handle OPTIONS request for TUS Resumable uploads","responses":{"200":{"description":"Default Response"}}}},"/upload/resumable/{wildcard}":{"post":{"operationId":"tusUploadCreatePost","summary":"Handle POST request for TUS Resumable uploads","tags":["resumable"],"description":"Creates a new resumable upload and returns its Location URL; the sign/ variant authorizes via the x-signature header from a presigned token instead of a JWT","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"put":{"operationId":"tusUploadPart","summary":"Handle PUT request for TUS Resumable uploads","tags":["resumable"],"description":"Appends a chunk of upload data at the given Upload-Offset to an in-progress resumable upload created by the POST step","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"patch":{"operationId":"tusUploadPartPatch","summary":"Handle PATCH request for TUS Resumable uploads","tags":["resumable"],"description":"Appends a chunk of upload data at the given Upload-Offset to an in-progress resumable upload, per the standard TUS PATCH semantics","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"tusUploadGet","summary":"Handle HEAD request for TUS Resumable uploads","tags":["resumable"],"description":"Returns the current Upload-Offset and Upload-Length of an in-progress upload without transferring any data, and does not require authorization","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"delete":{"operationId":"tusUploadDelete","summary":"Handle DELETE request for TUS Resumable uploads","tags":["resumable"],"description":"Cancels an in-progress resumable upload and discards any data received so far","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"options":{"operationId":"tusOptionsOptions","summary":"Handle OPTIONS request for TUS Resumable uploads","tags":["resumable"],"description":"Handle OPTIONS request for TUS Resumable uploads","parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/upload/resumable/sign/":{"post":{"operationId":"tusUploadCreateSigned","summary":"Handle POST request for TUS Resumable uploads","tags":["resumable"],"description":"Creates a new resumable upload and returns its Location URL; the sign/ variant authorizes via the x-signature header from a presigned token instead of a JWT","responses":{"200":{"description":"Default Response"}}},"options":{"operationId":"tusOptionsSigned","summary":"Handle OPTIONS request for TUS Resumable uploads","tags":["resumable"],"description":"Handle OPTIONS request for TUS Resumable uploads","responses":{"200":{"description":"Default Response"}}}},"/upload/resumable/sign/{wildcard}":{"post":{"operationId":"tusUploadCreateSignedPost","summary":"Handle POST request for TUS Resumable uploads","tags":["resumable"],"description":"Creates a new resumable upload and returns its Location URL; the sign/ variant authorizes via the x-signature header from a presigned token instead of a JWT","parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response"}}},"put":{"operationId":"tusUploadPartSigned","summary":"Handle PUT request for TUS Resumable uploads","tags":["resumable"],"description":"Appends a chunk of upload data at the given Upload-Offset to an in-progress resumable upload created by the POST step","parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response"}}},"patch":{"operationId":"tusUploadPartSignedPatch","summary":"Handle PATCH request for TUS Resumable uploads","tags":["resumable"],"description":"Appends a chunk of upload data at the given Upload-Offset to an in-progress resumable upload, per the standard TUS PATCH semantics","parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response"}}},"head":{"operationId":"tusUploadGetSigned","summary":"Handle HEAD request for TUS Resumable uploads","tags":["resumable"],"description":"Returns the current Upload-Offset and Upload-Length of an in-progress upload without transferring any data, and does not require authorization","parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"operationId":"tusUploadDeleteSigned","summary":"Handle DELETE request for TUS Resumable uploads","tags":["resumable"],"description":"Cancels an in-progress resumable upload and discards any data received so far","parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response"}}},"options":{"operationId":"tusOptionsSignedOptions","summary":"Handle OPTIONS request for TUS Resumable uploads","tags":["resumable"],"description":"Handle OPTIONS request for TUS Resumable uploads","parameters":[{"schema":{"type":"string"},"in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/bucket/{bucketId}/empty":{"post":{"operationId":"bucketEmpty","summary":"Empty a bucket","tags":["bucket"],"description":"Queues asynchronous deletion of all objects inside the bucket without deleting the bucket itself, which may take up to an hour to complete","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Empty bucket has been queued. Completion may take up to an hour."}}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/bucket":{"head":{"operationId":"bucketListHead","summary":"Gets all buckets","tags":["bucket"],"description":"Returns buckets owned by the authenticated user, supporting pagination via limit/offset plus sorting and search filters","parameters":[{"schema":{"type":"integer","minimum":1},"example":10,"in":"query","name":"limit","required":false},{"schema":{"type":"integer","minimum":0},"example":0,"in":"query","name":"offset","required":false},{"schema":{"type":"string","enum":["id","name","created_at","updated_at"]},"in":"query","name":"sortColumn","required":false},{"schema":{"type":"string","enum":["asc","desc"]},"in":"query","name":"sortOrder","required":false},{"schema":{"type":"string"},"example":"my-bucket","in":"query","name":"search","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"$ref":"#/components/schemas/bucketSchema"}},"example":[{"id":"avatars","type":"STANDARD","name":"avatars","owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}]}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"post":{"operationId":"bucketCreate","summary":"Create a bucket","tags":["bucket"],"description":"The bucket id defaults to the given name when not provided, and the owner is set from the authenticated caller","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","example":"avatars"},"id":{"type":"string","example":"avatars"},"public":{"type":"boolean","example":false},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"anyOf":[{"type":"integer","examples":[1000],"nullable":true,"minimum":0},{"type":"string","examples":["100MB"],"nullable":true}]},"allowed_mime_types":{"type":"array","nullable":true,"items":{"type":"string"},"example":["image/png","image/jpg"]}},"required":["name"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"name":{"type":"string","example":"avatars"}},"required":["name"]}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"get":{"operationId":"bucketList","summary":"Gets all buckets","tags":["bucket"],"description":"Returns buckets owned by the authenticated user, supporting pagination via limit/offset plus sorting and search filters","parameters":[{"schema":{"type":"integer","minimum":1},"example":10,"in":"query","name":"limit","required":false},{"schema":{"type":"integer","minimum":0},"example":0,"in":"query","name":"offset","required":false},{"schema":{"type":"string","enum":["id","name","created_at","updated_at"]},"in":"query","name":"sortColumn","required":false},{"schema":{"type":"string","enum":["asc","desc"]},"in":"query","name":"sortOrder","required":false},{"schema":{"type":"string"},"example":"my-bucket","in":"query","name":"search","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"$ref":"#/components/schemas/bucketSchema"}},"example":[{"id":"avatars","type":"STANDARD","name":"avatars","owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}]}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/bucket/{bucketId}":{"get":{"operationId":"bucketGet","summary":"Get details of a bucket","tags":["bucket"],"description":"Requires the caller to have access to the bucket via RLS policies, unlike the public bucket listing","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/bucketSchema"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"bucketGetHead","summary":"Get details of a bucket","tags":["bucket"],"description":"Requires the caller to have access to the bucket via RLS policies, unlike the public bucket listing","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/bucketSchema"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"put":{"operationId":"bucketUpdate","summary":"Update properties of a bucket","tags":["bucket"],"description":"Requires at least one of public, file_size_limit or allowed_mime_types in the body, and only the fields provided are changed","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"properties":{"public":{"type":"boolean","example":false},"file_size_limit":{"anyOf":[{"type":"integer","examples":[1000],"nullable":true,"minimum":0},{"type":"string","examples":["100MB"],"nullable":true}]},"allowed_mime_types":{"type":"array","nullable":true,"items":{"type":"string","example":["image/png","image/jpg"]}}}}}}},"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"bucketId","required":true}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Successfully updated"}},"required":["message"]}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"delete":{"operationId":"bucketDelete","summary":"Delete a bucket","tags":["bucket"],"description":"Fails if the bucket still contains objects, so it must be emptied first","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Successfully deleted"}}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/{bucketName}/{wildcard}":{"delete":{"operationId":"objectDelete","summary":"Delete an object","tags":["object"],"description":"Deletes a single object by its exact key, unlike the bulk-delete endpoint which accepts multiple prefixes","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Successfully deleted"}}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"get":{"operationId":"objectGetAuthenticatedGet","summary":"Get object","tags":["object"],"description":"Serve objects","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"put":{"operationId":"objectUploadUpdate","summary":"Update the object at an existing key","tags":["object"],"description":"Always overwrites the object at the given key, in contrast to the create-object endpoint which requires x-upsert to overwrite","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"Id":{"type":"string"},"Key":{"type":"string","example":"avatars/folder/cat.png"}},"required":["Key"]}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"objectHeadAuthenticatedInfoHead","summary":"Retrieve object info","tags":["object"],"description":"Head object info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"post":{"operationId":"objectUpload","summary":"Upload a new object","tags":["object"],"description":"Fails if an object already exists at the given key unless the x-upsert header is set to true","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"Id":{"type":"string"},"Key":{"type":"string","example":"avatars/folder/cat.png"}},"required":["Key"]}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/{bucketName}":{"delete":{"operationId":"objectDeleteMany","summary":"Delete multiple objects","tags":["object"],"description":"Accepts a list of object prefixes to delete in one request, capped by a configurable per-request limit","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"prefixes":{"type":"array","items":{"type":"string"},"minItems":1,"description":"At most 1000 objects can be deleted per request.","example":["folder/cat.png","folder/morecats.png"]}},"required":["prefixes"]}}}},"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"$ref":"#/components/schemas/objectSchema"}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/authenticated/{bucketName}/{wildcard}":{"get":{"operationId":"objectGetAuthenticated","summary":"Retrieve an object","tags":["object"],"description":"Requires a valid auth token and checks bucket/object access via RLS, regardless of whether the bucket is public","parameters":[{"schema":{"type":"string"},"examples":{"filename.jpg":{"value":"filename.jpg"},"example2":{"value":null}},"in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"objectHeadAuthenticatedInfo","summary":"Retrieve object info","tags":["object"],"description":"Returns object metadata in headers only, with no body, and requires a valid auth token even for public buckets","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/upload/sign/{bucketName}/{wildcard}":{"post":{"operationId":"objectSignUploadUrl","summary":"Generate a presigned url to upload an object","tags":["object"],"description":"The returned token is valid for the configured upload signed URL expiration time and must be submitted to the presigned upload endpoint; set x-upsert to allow overwriting an existing object","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"url":{"type":"string","example":"/object/sign/upload/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4"},"token":{"type":"string"}},"required":["url"]}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"put":{"operationId":"objectUploadSigned","summary":"Uploads an object via a presigned URL","tags":["object"],"description":"Verifies the token query parameter instead of requiring an authorization header, and does not need the caller to have direct bucket access","parameters":[{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"Key":{"type":"string","example":"avatars/folder/cat.png"}},"required":["Key"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/sign/{bucketName}/{wildcard}":{"post":{"operationId":"objectSign","summary":"Generate a presigned url to retrieve an object","tags":["object"],"description":"Returns a URL valid for the given expiresIn seconds; optional transform options are baked into the signed token so the download URL applies image transformations without further authorization","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"expiresIn":{"type":"integer","minimum":1,"example":60000},"transform":{"type":"object","properties":{"height":{"type":"integer","minimum":0,"example":100},"width":{"type":"integer","minimum":0,"example":100},"resize":{"type":"string","enum":["cover","contain","fill"]},"format":{"type":"string","enum":["origin","avif","webp"]},"quality":{"type":"integer","minimum":20,"maximum":100}}}},"required":["expiresIn"]}}}},"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"signedURL":{"type":"string","example":"/object/sign/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4"}},"required":["signedURL"]}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"get":{"operationId":"objectGetSigned","summary":"Retrieve an object via a presigned URL","tags":["object"],"description":"Requires no authorization header, relying instead on the signed token query parameter, and streams the object bytes rather than metadata","parameters":[{"schema":{"type":"string"},"examples":{"filename.jpg":{"value":"filename.jpg"},"example2":{"value":null}},"in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"objectGetSignedHead","summary":"Retrieve an object via a presigned URL","tags":["object"],"description":"Requires no authorization header, relying instead on the signed token query parameter, and streams the object bytes rather than metadata","parameters":[{"schema":{"type":"string"},"examples":{"filename.jpg":{"value":"filename.jpg"},"example2":{"value":null}},"in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/sign/{bucketName}":{"post":{"operationId":"objectSignMany","summary":"Generate presigned urls to retrieve objects","tags":["object"],"description":"Batches signed URL generation for multiple paths up to the configured max objects per request, returning a per-path error instead of failing the whole request when a path is inaccessible","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"expiresIn":{"type":"integer","minimum":1,"example":60000},"paths":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000,"example":["folder/cat.png","folder/morecats.png"]}},"required":["expiresIn","paths"]}}}},"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"type":"object","properties":{"error":{"type":"string","nullable":true,"example":"Either the object does not exist or you do not have access to it"},"path":{"type":"string","example":"folder/cat.png"},"signedURL":{"type":"string","nullable":true,"example":"/object/sign/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4"}},"required":["error","path","signedURL"]}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/move":{"post":{"operationId":"objectMove","summary":"Moves an object","tags":["object"],"description":"Renames the object within the same bucket, or relocates it to destinationBucket when provided, without re-uploading the file content","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"bucketId":{"type":"string","example":"avatars"},"sourceKey":{"type":"string","example":"folder/cat.png"},"destinationBucket":{"type":"string","example":"users"},"destinationKey":{"type":"string","example":"folder/newcat.png"}},"required":["bucketId","sourceKey","destinationKey"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Successfully moved"}},"required":["message"]}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/list-v2/{bucketName}":{"post":{"operationId":"objectListV2","summary":"Search for objects under a prefix","tags":["object"],"description":"Uses cursor-based pagination instead of limit/offset, and supports an optional delimiter to group results like folders, unlike the legacy list endpoint","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"prefix":{"type":"string","example":"folder/subfolder"},"limit":{"type":"integer","minimum":1,"example":10},"cursor":{"type":"string"},"with_delimiter":{"type":"boolean"},"sortBy":{"type":"object","properties":{"column":{"type":"string","enum":["name","updated_at","created_at"]},"order":{"type":"string","enum":["asc","desc"]}},"required":["column"]}}}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/list/{bucketName}":{"post":{"operationId":"objectList","summary":"Search for objects under a prefix","tags":["object"],"description":"Legacy listing endpoint using limit/offset pagination and a required prefix, without the cursor-based pagination or delimiter grouping supported by list-v2","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"prefix":{"type":"string","example":"folder/subfolder"},"limit":{"type":"integer","minimum":1,"example":10},"offset":{"type":"integer","minimum":0,"example":0},"sortBy":{"type":"object","properties":{"column":{"type":"string","enum":["name","updated_at","created_at","last_accessed_at"]},"order":{"type":"string","enum":["asc","desc"]}},"required":["column"]},"search":{"type":"string"}},"required":["prefix"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"$ref":"#/components/schemas/objectSchema"}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/info/authenticated/{bucketName}/{wildcard}":{"get":{"operationId":"objectGetAuthenticatedInfo","summary":"Retrieve object info","tags":["object"],"description":"Returns object metadata as a JSON body rather than headers only, and requires a valid auth token even for public buckets","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"objectGetAuthenticatedInfoHead","summary":"Retrieve object info","tags":["object"],"description":"Returns object metadata as a JSON body rather than headers only, and requires a valid auth token even for public buckets","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/info/{bucketName}/{wildcard}":{"get":{"operationId":"objectGetAuthenticatedInfoGet","summary":"Retrieve object info","tags":["object"],"description":"Object Info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"objectGetAuthenticatedInfo2","summary":"Retrieve object info","tags":["object"],"description":"Object Info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/copy":{"post":{"operationId":"objectCopy","summary":"Copies an object","tags":["object"],"description":"Copies metadata along with the object content by default (override via copyMetadata), can target a different destinationBucket, and requires x-upsert to overwrite an existing destination key","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"bucketId":{"type":"string","example":"avatars"},"sourceKey":{"type":"string","example":"folder/source.png"},"destinationBucket":{"type":"string","example":"users"},"destinationKey":{"type":"string","example":"folder/destination.png"},"metadata":{"type":"object","properties":{"cacheControl":{"type":"string"},"mimetype":{"type":"string"}}},"copyMetadata":{"type":"boolean","example":true}},"required":["sourceKey","bucketId","destinationKey"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"Id":{"type":"string"},"Key":{"type":"string","example":"folder/destination.png"},"name":{"type":"string"},"bucket_id":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"version":{"type":"string"},"id":{"type":"string","nullable":true},"updated_at":{"type":"string","nullable":true},"created_at":{"type":"string","nullable":true},"last_accessed_at":{"type":"string","nullable":true},"metadata":{"type":"object","additionalProperties":true,"nullable":true},"user_metadata":{"type":"object","additionalProperties":true,"nullable":true},"buckets":{"$ref":"#/components/schemas/bucketSchema"}},"required":["Key"]}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/public/{bucketName}/{wildcard}":{"get":{"operationId":"objectGetPublic","summary":"Retrieve an object from a public bucket","tags":["object"],"description":"Requires no authorization header but errors if the bucket is not marked public, unlike the authenticated retrieval endpoint","parameters":[{"schema":{"type":"string"},"examples":{"filename.jpg":{"value":"filename.jpg"},"example2":{"value":null}},"in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"objectInfoPublic","summary":"Get object info","tags":["object"],"description":"returns object info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/object/info/public/{bucketName}/{wildcard}":{"get":{"operationId":"objectInfoPublicGet","summary":"Get object info","tags":["object"],"description":"returns object info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/render/image/authenticated/{bucketName}/{wildcard}":{"get":{"operationId":"renderImageAuthenticated","summary":"Render an authenticated image with the given transformations","tags":["transformation"],"description":"Requires a valid auth token and checks RLS access to the object, with transformation options passed directly as query parameters","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"renderImageAuthenticatedHead","summary":"Render an authenticated image with the given transformations","tags":["transformation"],"description":"Requires a valid auth token and checks RLS access to the object, with transformation options passed directly as query parameters","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/render/image/sign/{bucketName}/{wildcard}":{"get":{"operationId":"renderImageSign","summary":"Render an authenticated image with the given transformations","tags":["transformation"],"description":"Requires no authorization header, verifying the token query parameter instead, with the transformations already encoded in the signed token rather than passed as separate query parameters","parameters":[{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"renderImageSignHead","summary":"Render an authenticated image with the given transformations","tags":["transformation"],"description":"Requires no authorization header, verifying the token query parameter instead, with the transformations already encoded in the signed token rather than passed as separate query parameters","parameters":[{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/render/image/public/{bucketName}/{wildcard}":{"get":{"operationId":"renderImagePublic","summary":"Render a public image with the given transformations","tags":["transformation"],"description":"Requires no authorization header but errors if the bucket is not marked public, with transformations passed as query parameters","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"head":{"operationId":"renderImagePublicHead","summary":"Render a public image with the given transformations","tags":["transformation"],"description":"Requires no authorization header but errors if the bucket is not marked public, with transformations passed as query parameters","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/cdn/":{"delete":{"operationId":"cdnPurgeTenantCache","summary":"Purge cache for entire tenant or tenant transformations","tags":["cdn"],"description":"Pass transformations=true in the query string to purge only cached image transformation renditions instead of the whole tenant cache","parameters":[{"schema":{"type":"boolean"},"in":"query","name":"transformations","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"success"}}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/cdn/{bucketName}":{"delete":{"operationId":"cdnPurgeBucketCache","summary":"Purge cache for an entire bucket or bucket transformations","tags":["cdn"],"description":"Pass transformations=true in the query string to purge only cached image transformation renditions for the bucket instead of all of its cached responses","parameters":[{"schema":{"type":"boolean"},"in":"query","name":"transformations","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"success"}}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/cdn/{bucketName}/{wildcard}":{"delete":{"operationId":"cdnPurgeObjectCache","summary":"Purge cache for an object or object transformations","tags":["cdn"],"description":"Pass transformations=true in the query string to purge only cached image transformation renditions of the object instead of its plain cached response","parameters":[{"schema":{"type":"boolean"},"in":"query","name":"transformations","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string","minLength":1},"example":"folder/cat.png","in":"path","name":"wildcard","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"success"}}}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}},"/health":{"head":{"operationId":"healthcheck-head","summary":"healthcheck","tags":["health"],"description":"Checks database connectivity and always responds with HTTP 200, reporting healthy: false in the body rather than an error status when the check fails","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}},"get":{"operationId":"healthcheck","summary":"healthcheck","tags":["health"],"description":"Checks database connectivity and always responds with HTTP 200, reporting healthy: false in the body rather than an error status when the check fails","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"description":"Default Response"}}}},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/errorSchema"}}}}}}}},"tags":[{"name":"object","description":"Object end-points"},{"name":"bucket","description":"Bucket end-points"},{"name":"s3","description":"S3-compatible protocol. Not enumerated here: each operation is dispatched by query string/header on a handful of shared routes, which OpenAPI cannot express as distinct operations. See src/http/routes/s3/commands for the per-command request/response contract, or use any S3 SDK against this endpoint."},{"name":"transformation","description":"Image transformation"},{"name":"resumable","description":"Resumable Upload end-points"},{"name":"cdn","description":"CDN cache management"},{"name":"health","description":"Health check end-points"},{"name":"iceberg","description":"Apache Iceberg REST catalog"},{"name":"vector","description":"Vector storage and search"}]} \ No newline at end of file From e7f22902149b66449e33f03756fe35d752a769a5 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 17:57:56 -0300 Subject: [PATCH 27/44] feat(codegen): hoist inline objects inside array items --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 67 +++++++++++++++++-- .../ParameterParsingTests.swift | 34 ++++++++-- .../RequestBodyParsingTests.swift | 25 +++++++ .../ResponseParsingTests.swift | 25 +++++++ .../SchemaParsingTests.swift | 29 ++++++++ 5 files changed, 168 insertions(+), 12 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index ae7059dac..53e2bb7e8 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -79,6 +79,14 @@ public enum OpenAPIParsing { continue } + if let (type, arrayHoisted) = try hoistArrayOfObjectIfPresent( + name: "\(name)_\(propertyName)", schema: propertySchema, location: propertyLocation) + { + hoisted.append(contentsOf: arrayHoisted) + properties.append(IRProperty(name: propertyName, type: type, isOptional: isOptional)) + continue + } + properties.append( IRProperty( name: propertyName, @@ -120,6 +128,30 @@ public enum OpenAPIParsing { return (.schemaRef(name), IRSchema(name: name, kind: .union(cases: cases))) } + /// If `schema` is an array whose `items` is an inline object with + /// properties (not a `$ref`, not empty), hoists the item type into its own + /// named schema instead of failing. Returns `nil` if `schema` isn't an + /// array-of-inline-object, so callers fall through to their existing logic. + private static func hoistArrayOfObjectIfPresent( + name: String, + schema: JSONSchema, + location: String + ) throws -> (type: IRType, hoisted: [IRSchema])? { + guard case .array(_, let arrayContext) = schema.value, + let items = arrayContext.items, + case .object(_, let objectContext) = items.value, + !objectContext.properties.isEmpty + else { + return nil + } + let hoistedName = "\(name)Item" + let (properties, nestedHoisted) = try parseObjectProperties( + name: hoistedName, objectContext: objectContext, location: "\(location)[]") + let hoisted = + [IRSchema(name: hoistedName, kind: .object(properties: properties))] + nestedHoisted + return (.array(.schemaRef(hoistedName)), hoisted) + } + private static func unionCaseName(for type: IRType) -> String { switch type { case .string: return "string" @@ -173,7 +205,7 @@ public enum OpenAPIParsing { static func parseParameter( _ either: Either, OpenAPI.Parameter>, location: String - ) throws -> (parameter: IRParameter, hoisted: IRSchema?) { + ) throws -> (parameter: IRParameter, hoisted: [IRSchema]) { guard let parameter = either.parameterValue else { throw UnsupportedSpecConstruct(location: location, reason: "external parameter reference") } @@ -200,7 +232,7 @@ public enum OpenAPIParsing { type: .schemaRef(hoistedName), isOptional: !parameter.required || schema.nullable ) - return (irParameter, IRSchema(name: hoistedName, kind: .stringEnum(cases: cases))) + return (irParameter, [IRSchema(name: hoistedName, kind: .stringEnum(cases: cases))]) } if let (type, hoistedSchema) = try hoistUnionIfPresent( name: "\(location)_\(parameter.name)", schema: schema, location: parameterLocation) @@ -211,7 +243,18 @@ public enum OpenAPIParsing { type: type, isOptional: !parameter.required || schema.nullable ) - return (irParameter, hoistedSchema) + return (irParameter, [hoistedSchema]) + } + if let (type, arrayHoisted) = try hoistArrayOfObjectIfPresent( + name: "\(location)_\(parameter.name)", schema: schema, location: parameterLocation) + { + let irParameter = IRParameter( + name: parameter.name, + location: irLocation, + type: type, + isOptional: !parameter.required || schema.nullable + ) + return (irParameter, arrayHoisted) } let irParameter = IRParameter( name: parameter.name, @@ -219,7 +262,7 @@ public enum OpenAPIParsing { type: try parseType(schema, location: parameterLocation), isOptional: !parameter.required || schema.nullable ) - return (irParameter, nil) + return (irParameter, []) } // MARK: - Schema references in content bodies @@ -273,6 +316,12 @@ public enum OpenAPIParsing { { return (.json(type), [hoistedSchema]) } + if case .b(let inlineSchema) = schemaEither, + let (type, arrayHoisted) = try hoistArrayOfObjectIfPresent( + name: "\(location)_requestBody", schema: inlineSchema, location: location) + { + return (.json(type), arrayHoisted) + } return (.json(try resolveSchema(schemaEither, location: location)), []) } if let multipartContent = request.content.first(where: { @@ -357,6 +406,12 @@ public enum OpenAPIParsing { { return (.json(type), [hoistedSchema]) } + if case .b(let inlineSchema) = schemaEither, + let (type, arrayHoisted) = try hoistArrayOfObjectIfPresent( + name: "\(operationId)_response\(statusCode)", schema: inlineSchema, location: location) + { + return (.json(type), arrayHoisted) + } return (.json(try resolveSchema(schemaEither, location: location)), []) } return (content.isEmpty ? .none : .binary, []) @@ -393,9 +448,7 @@ public enum OpenAPIParsing { for parameterEither in pathItem.parameters + operation.parameters { let (parameter, parameterHoisted) = try parseParameter(parameterEither, location: operationId) parameters.append(parameter) - if let parameterHoisted { - hoisted.append(parameterHoisted) - } + hoisted.append(contentsOf: parameterHoisted) } var requestBody: IRRequestBody? if let requestBodyEither = operation.requestBody { diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift index 8e3e66ebe..bf6cbcbd5 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift @@ -29,7 +29,7 @@ struct ParameterParsingTests { #expect(irParameter.location == .path) #expect(irParameter.type == .string) #expect(irParameter.isOptional == false) - #expect(hoisted == nil) + #expect(hoisted.isEmpty) } @Test @@ -42,7 +42,7 @@ struct ParameterParsingTests { #expect(irParameter.location == .query) #expect(irParameter.type == .integer) #expect(irParameter.isOptional == true) - #expect(hoisted == nil) + #expect(hoisted.isEmpty) } @Test @@ -53,7 +53,7 @@ struct ParameterParsingTests { let (irParameter, hoisted) = try OpenAPIParsing.parseParameter(parameter(json), location: "op") #expect(irParameter.location == .header) - #expect(hoisted == nil) + #expect(hoisted.isEmpty) } @Test @@ -75,7 +75,31 @@ struct ParameterParsingTests { #expect(irParameter.name == "resize") #expect(irParameter.type == .schemaRef("renderImagePublic_resize")) - #expect(hoisted?.name == "renderImagePublic_resize") - #expect(hoisted?.kind == .stringEnum(cases: ["cover", "contain", "fill"])) + #expect(hoisted.count == 1) + #expect(hoisted.first?.name == "renderImagePublic_resize") + #expect(hoisted.first?.kind == .stringEnum(cases: ["cover", "contain", "fill"])) + } + + @Test + func hoistsArrayOfInlineObjectParameterIntoItsOwnNamedSchema() throws { + let json = """ + { + "name": "paths", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": {"path": {"type": "string"}} + } + } + } + """ + let (irParameter, hoisted) = try OpenAPIParsing.parseParameter(parameter(json), location: "listObjects") + + #expect(irParameter.name == "paths") + #expect(irParameter.type == .array(.schemaRef("listObjects_pathsItem"))) + #expect(hoisted.count == 1) + #expect(hoisted.first?.name == "listObjects_pathsItem") } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift index 26fd227da..8bad90f07 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift @@ -102,4 +102,29 @@ struct RequestBodyParsingTests { #expect(hoisted[0].name == "copyObject_requestBody") #expect(hoisted[1].name == "copyObject_requestBody_metadata") } + + @Test + func hoistsArrayOfInlineObjectRequestBody() throws { + let json = """ + { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": {"path": {"type": "string"}} + } + } + } + } + } + """ + let (body, hoisted) = try OpenAPIParsing.parseRequestBody( + requestBody(json), location: "deleteObjects") + + #expect(body == .json(.array(.schemaRef("deleteObjects_requestBodyItem")))) + #expect(hoisted.count == 1) + #expect(hoisted[0].name == "deleteObjects_requestBodyItem") + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift index 1c10c40f1..c9bd68363 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ResponseParsingTests.swift @@ -108,6 +108,31 @@ struct ResponseParsingTests { #expect(hoisted[1].name == "copyObject_response200_metadata") } + @Test + func hoistsArrayOfInlineObjectResponseBody() throws { + let json = """ + { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": {"path": {"type": "string"}} + } + } + } + } + """ + let content = try JSONDecoder().decode(OpenAPI.Content.Map.self, from: Data(json.utf8)) + + let (body, hoisted) = try OpenAPIParsing.parseResponseBody( + content, operationId: "objectSignMany", statusCode: 200, location: "objectSignMany -> 200") + + #expect(body == .json(.array(.schemaRef("objectSignMany_response200Item")))) + #expect(hoisted.count == 1) + #expect(hoisted[0].name == "objectSignMany_response200Item") + } + @Test func parsesFullDocumentEndToEnd() throws { let json = """ diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift index 2f4554302..eb0b1a245 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/SchemaParsingTests.swift @@ -94,4 +94,33 @@ struct SchemaParsingTests { #expect(irSchemas[1].name == "bucketSchema_type") #expect(irSchemas[1].kind == .stringEnum(cases: ["STANDARD", "ANALYTICS"])) } + + @Test + func hoistsArrayOfInlineObjectPropertyIntoItsOwnNamedSchema() throws { + let json = """ + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": {"path": {"type": "string"}} + } + } + } + } + """ + let schema = try JSONDecoder().decode(JSONSchema.self, from: Data(json.utf8)) + + let irSchemas = try OpenAPIParsing.parseNamedSchema(name: "widget", schema: schema) + + #expect(irSchemas.count == 2) + guard case .object(let properties) = irSchemas[0].kind else { + Issue.record("expected the first schema to be the object") + return + } + #expect(properties[0].type == .array(.schemaRef("widget_itemsItem"))) + #expect(irSchemas[1].name == "widget_itemsItem") + } } From f84586f8c0ddb6be574307d9df58ca26d999e58c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 18:57:51 -0300 Subject: [PATCH 28/44] feat(storage): generate the OpenAPI-based Storage client Runs tools/openapi-codegen against openapi/storage.json and wires the output into the main package as an internal StorageOpenAPI target (not yet a public product, not yet consumed by Storage). Fixes surfaced by the first real build of generated output: - SwiftEmitter didn't mark import Foundation/HTTPRuntime as `public import`, so InternalImportsByDefault rejected the public APIError conformance and the client's public URL-typed init. - stringConversionExpression used String.init for enum-backed query/ header params, which is ambiguous for RawRepresentable types; now emits .rawValue for schema-ref parameters. - errorTypesLiteral emitted `[]` for operations with no error responses, which Swift can't infer as [Int: any APIError.Type]; now emits `[:]`. - HTTPRuntime.HTTPMethod was missing .options/.trace, needed by Storage's tusOptions* operations. Also tightens two ExistentialAny warnings in emitted Codable conformances (any Decoder/any Encoder) and adds emitter test coverage for the enum-backed query parameter case. --- Package.swift | 17 +- Sources/HTTPRuntime/HTTPMethod.swift | 2 + Sources/StorageOpenAPI/Models.swift | 683 +++++++++++++++++ .../StorageOpenAPI/StorageOpenAPIClient.swift | 710 ++++++++++++++++++ .../StorageOpenAPITests.swift | 15 + .../OpenAPICodegenCore/SwiftEmitter.swift | 33 +- .../ClientEmitterTests.swift | 66 +- .../ModelEmitterTests.swift | 12 +- .../openapi-codegenTests/EndToEndTests.swift | 6 +- 9 files changed, 1517 insertions(+), 27 deletions(-) create mode 100644 Sources/StorageOpenAPI/Models.swift create mode 100644 Sources/StorageOpenAPI/StorageOpenAPIClient.swift create mode 100644 Tests/StorageOpenAPITests/StorageOpenAPITests.swift diff --git a/Package.swift b/Package.swift index 7005d250a..ee55bed4e 100644 --- a/Package.swift +++ b/Package.swift @@ -199,6 +199,19 @@ let package = Package( .process("Fixtures"), ] ), + .target( + name: "StorageOpenAPI", + dependencies: [ + "HTTPRuntime" + ] + ), + .testTarget( + name: "StorageOpenAPITests", + dependencies: [ + "StorageOpenAPI", + "HTTPRuntime", + ] + ), .target( name: "Supabase", dependencies: [ @@ -239,7 +252,9 @@ let package = Package( // Test targets migrated to Swift Testing get full Swift 6 checking, same as // production targets. Everything else stays pinned to v5 until its migration // phase lands (see SDK-435). -let swift6TestTargets: Set = ["SupabaseTests", "HelpersTests", "HTTPRuntimeTests"] +let swift6TestTargets: Set = [ + "SupabaseTests", "HelpersTests", "HTTPRuntimeTests", "StorageOpenAPITests", +] for target in package.targets { // Test targets never opted into `ExistentialAny` below, so bumping swift-tools-version diff --git a/Sources/HTTPRuntime/HTTPMethod.swift b/Sources/HTTPRuntime/HTTPMethod.swift index 90c9ac6a5..f31ddfe00 100644 --- a/Sources/HTTPRuntime/HTTPMethod.swift +++ b/Sources/HTTPRuntime/HTTPMethod.swift @@ -14,4 +14,6 @@ public enum HTTPMethod: String, Sendable, Hashable { case patch = "PATCH" case delete = "DELETE" case head = "HEAD" + case options = "OPTIONS" + case trace = "TRACE" } diff --git a/Sources/StorageOpenAPI/Models.swift b/Sources/StorageOpenAPI/Models.swift new file mode 100644 index 000000000..4b5c3121d --- /dev/null +++ b/Sources/StorageOpenAPI/Models.swift @@ -0,0 +1,683 @@ +// Code generated by openapi-codegen. DO NOT EDIT. + +import Foundation +public import HTTPRuntime + +public struct AuthSchema: Codable, Sendable, Hashable { + public var authorization: String + + enum CodingKeys: String, CodingKey { + case authorization = "authorization" + } +} + +public struct BucketCreateRequestBody: Codable, Sendable, Hashable { + public var allowedMimeTypes: [String]? + public var fileSizeLimit: BucketCreateRequestBodyFileSizeLimit? + public var id: String? + public var name: String + public var `public`: Bool? + public var type: BucketCreateRequestBodyType? + + enum CodingKeys: String, CodingKey { + case allowedMimeTypes = "allowed_mime_types" + case fileSizeLimit = "file_size_limit" + case id = "id" + case name = "name" + case `public` = "public" + case type = "type" + } +} + +public enum BucketCreateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { + case integer(Int) + case string(String) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int.self) { + self = .integer(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + throw DecodingError.typeMismatch( + BucketCreateRequestBodyFileSizeLimit.self, + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "no matching case") + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .integer(let value): try container.encode(value) + case .string(let value): try container.encode(value) + } + } +} + +public enum BucketCreateRequestBodyType: String, Codable, Sendable, Hashable { + case STANDARD = "STANDARD" + case ANALYTICS = "ANALYTICS" +} + +public struct BucketCreateResponse200: Codable, Sendable, Hashable { + public var name: String + + enum CodingKeys: String, CodingKey { + case name = "name" + } +} + +public struct BucketDeleteResponse200: Codable, Sendable, Hashable { + public var message: String? + + enum CodingKeys: String, CodingKey { + case message = "message" + } +} + +public struct BucketEmptyResponse200: Codable, Sendable, Hashable { + public var message: String? + + enum CodingKeys: String, CodingKey { + case message = "message" + } +} + +public enum BucketListHeadSortColumn: String, Codable, Sendable, Hashable { + case id = "id" + case name = "name" + case createdAt = "created_at" + case updatedAt = "updated_at" +} + +public enum BucketListHeadSortOrder: String, Codable, Sendable, Hashable { + case asc = "asc" + case desc = "desc" +} + +public enum BucketListSortColumn: String, Codable, Sendable, Hashable { + case id = "id" + case name = "name" + case createdAt = "created_at" + case updatedAt = "updated_at" +} + +public enum BucketListSortOrder: String, Codable, Sendable, Hashable { + case asc = "asc" + case desc = "desc" +} + +public struct BucketSchema: Codable, Sendable, Hashable { + public var allowedMimeTypes: [String]? + public var createdAt: String? + public var fileSizeLimit: Int? + public var id: String + public var name: String + public var owner: String? + public var ownerId: String? + public var `public`: Bool? + public var type: BucketSchemaType? + public var updatedAt: String? + + enum CodingKeys: String, CodingKey { + case allowedMimeTypes = "allowed_mime_types" + case createdAt = "created_at" + case fileSizeLimit = "file_size_limit" + case id = "id" + case name = "name" + case owner = "owner" + case ownerId = "owner_id" + case `public` = "public" + case type = "type" + case updatedAt = "updated_at" + } +} + +public enum BucketSchemaType: String, Codable, Sendable, Hashable { + case STANDARD = "STANDARD" + case ANALYTICS = "ANALYTICS" +} + +public struct BucketUpdateRequestBody: Codable, Sendable, Hashable { + public var allowedMimeTypes: [String]? + public var fileSizeLimit: BucketUpdateRequestBodyFileSizeLimit? + public var `public`: Bool? + + enum CodingKeys: String, CodingKey { + case allowedMimeTypes = "allowed_mime_types" + case fileSizeLimit = "file_size_limit" + case `public` = "public" + } +} + +public enum BucketUpdateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { + case integer(Int) + case string(String) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int.self) { + self = .integer(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + throw DecodingError.typeMismatch( + BucketUpdateRequestBodyFileSizeLimit.self, + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "no matching case") + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .integer(let value): try container.encode(value) + case .string(let value): try container.encode(value) + } + } +} + +public struct BucketUpdateResponse200: Codable, Sendable, Hashable { + public var message: String + + enum CodingKeys: String, CodingKey { + case message = "message" + } +} + +public struct CdnPurgeBucketCacheResponse200: Codable, Sendable, Hashable { + public var message: String? + + enum CodingKeys: String, CodingKey { + case message = "message" + } +} + +public struct CdnPurgeObjectCacheResponse200: Codable, Sendable, Hashable { + public var message: String? + + enum CodingKeys: String, CodingKey { + case message = "message" + } +} + +public struct CdnPurgeTenantCacheResponse200: Codable, Sendable, Hashable { + public var message: String? + + enum CodingKeys: String, CodingKey { + case message = "message" + } +} + +public struct ErrorSchema: Codable, Sendable, Hashable, APIError { + public var error: String + public var message: String + public var statusCode: String + + enum CodingKeys: String, CodingKey { + case error = "error" + case message = "message" + case statusCode = "statusCode" + } +} + +public struct ObjectCopyRequestBody: Codable, Sendable, Hashable { + public var bucketId: String + public var copyMetadata: Bool? + public var destinationBucket: String? + public var destinationKey: String + public var metadata: ObjectCopyRequestBodyMetadata? + public var sourceKey: String + + enum CodingKeys: String, CodingKey { + case bucketId = "bucketId" + case copyMetadata = "copyMetadata" + case destinationBucket = "destinationBucket" + case destinationKey = "destinationKey" + case metadata = "metadata" + case sourceKey = "sourceKey" + } +} + +public struct ObjectCopyRequestBodyMetadata: Codable, Sendable, Hashable { + public var cacheControl: String? + public var mimetype: String? + + enum CodingKeys: String, CodingKey { + case cacheControl = "cacheControl" + case mimetype = "mimetype" + } +} + +public struct ObjectCopyResponse200: Codable, Sendable, Hashable { + public var Id: String? + public var Key: String + public var bucketId: String? + public var buckets: BucketSchema? + public var createdAt: String? + public var id: String? + public var lastAccessedAt: String? + public var metadata: [String: JSONValue]? + public var name: String? + public var owner: String? + public var ownerId: String? + public var updatedAt: String? + public var userMetadata: [String: JSONValue]? + public var version: String? + + enum CodingKeys: String, CodingKey { + case Id = "Id" + case Key = "Key" + case bucketId = "bucket_id" + case buckets = "buckets" + case createdAt = "created_at" + case id = "id" + case lastAccessedAt = "last_accessed_at" + case metadata = "metadata" + case name = "name" + case owner = "owner" + case ownerId = "owner_id" + case updatedAt = "updated_at" + case userMetadata = "user_metadata" + case version = "version" + } +} + +public struct ObjectDeleteManyRequestBody: Codable, Sendable, Hashable { + public var prefixes: [String] + + enum CodingKeys: String, CodingKey { + case prefixes = "prefixes" + } +} + +public struct ObjectDeleteResponse200: Codable, Sendable, Hashable { + public var message: String? + + enum CodingKeys: String, CodingKey { + case message = "message" + } +} + +public enum ObjectGetAuthenticatedInfo2Format: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectGetAuthenticatedInfo2Resize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum ObjectGetAuthenticatedInfoGetFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectGetAuthenticatedInfoGetResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum ObjectGetAuthenticatedInfoHeadFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectGetAuthenticatedInfoHeadResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum ObjectGetAuthenticatedInfoFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectGetAuthenticatedInfoResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum ObjectHeadAuthenticatedInfoHeadFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectHeadAuthenticatedInfoHeadResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum ObjectHeadAuthenticatedInfoFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectHeadAuthenticatedInfoResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum ObjectInfoPublicGetFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectInfoPublicGetResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum ObjectInfoPublicFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectInfoPublicResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public struct ObjectListV2RequestBody: Codable, Sendable, Hashable { + public var cursor: String? + public var limit: Int? + public var prefix: String? + public var sortBy: ObjectListV2RequestBodySortBy? + public var withDelimiter: Bool? + + enum CodingKeys: String, CodingKey { + case cursor = "cursor" + case limit = "limit" + case prefix = "prefix" + case sortBy = "sortBy" + case withDelimiter = "with_delimiter" + } +} + +public struct ObjectListV2RequestBodySortBy: Codable, Sendable, Hashable { + public var column: ObjectListV2RequestBodySortByColumn + public var order: ObjectListV2RequestBodySortByOrder? + + enum CodingKeys: String, CodingKey { + case column = "column" + case order = "order" + } +} + +public enum ObjectListV2RequestBodySortByColumn: String, Codable, Sendable, Hashable { + case name = "name" + case updatedAt = "updated_at" + case createdAt = "created_at" +} + +public enum ObjectListV2RequestBodySortByOrder: String, Codable, Sendable, Hashable { + case asc = "asc" + case desc = "desc" +} + +public struct ObjectListRequestBody: Codable, Sendable, Hashable { + public var limit: Int? + public var offset: Int? + public var prefix: String + public var search: String? + public var sortBy: ObjectListRequestBodySortBy? + + enum CodingKeys: String, CodingKey { + case limit = "limit" + case offset = "offset" + case prefix = "prefix" + case search = "search" + case sortBy = "sortBy" + } +} + +public struct ObjectListRequestBodySortBy: Codable, Sendable, Hashable { + public var column: ObjectListRequestBodySortByColumn + public var order: ObjectListRequestBodySortByOrder? + + enum CodingKeys: String, CodingKey { + case column = "column" + case order = "order" + } +} + +public enum ObjectListRequestBodySortByColumn: String, Codable, Sendable, Hashable { + case name = "name" + case updatedAt = "updated_at" + case createdAt = "created_at" + case lastAccessedAt = "last_accessed_at" +} + +public enum ObjectListRequestBodySortByOrder: String, Codable, Sendable, Hashable { + case asc = "asc" + case desc = "desc" +} + +public struct ObjectMoveRequestBody: Codable, Sendable, Hashable { + public var bucketId: String + public var destinationBucket: String? + public var destinationKey: String + public var sourceKey: String + + enum CodingKeys: String, CodingKey { + case bucketId = "bucketId" + case destinationBucket = "destinationBucket" + case destinationKey = "destinationKey" + case sourceKey = "sourceKey" + } +} + +public struct ObjectMoveResponse200: Codable, Sendable, Hashable { + public var message: String + + enum CodingKeys: String, CodingKey { + case message = "message" + } +} + +public struct ObjectSchema: Codable, Sendable, Hashable { + public var bucketId: String? + public var buckets: BucketSchema? + public var createdAt: String? + public var id: String? + public var lastAccessedAt: String? + public var metadata: [String: JSONValue]? + public var name: String + public var owner: String? + public var ownerId: String? + public var updatedAt: String? + public var userMetadata: [String: JSONValue]? + public var version: String? + + enum CodingKeys: String, CodingKey { + case bucketId = "bucket_id" + case buckets = "buckets" + case createdAt = "created_at" + case id = "id" + case lastAccessedAt = "last_accessed_at" + case metadata = "metadata" + case name = "name" + case owner = "owner" + case ownerId = "owner_id" + case updatedAt = "updated_at" + case userMetadata = "user_metadata" + case version = "version" + } +} + +public struct ObjectSignManyRequestBody: Codable, Sendable, Hashable { + public var expiresIn: Int + public var paths: [String] + + enum CodingKeys: String, CodingKey { + case expiresIn = "expiresIn" + case paths = "paths" + } +} + +public struct ObjectSignManyResponse200Item: Codable, Sendable, Hashable { + public var error: String? + public var path: String + public var signedURL: String? + + enum CodingKeys: String, CodingKey { + case error = "error" + case path = "path" + case signedURL = "signedURL" + } +} + +public struct ObjectSignUploadUrlResponse200: Codable, Sendable, Hashable { + public var token: String? + public var url: String + + enum CodingKeys: String, CodingKey { + case token = "token" + case url = "url" + } +} + +public struct ObjectSignRequestBody: Codable, Sendable, Hashable { + public var expiresIn: Int + public var transform: ObjectSignRequestBodyTransform? + + enum CodingKeys: String, CodingKey { + case expiresIn = "expiresIn" + case transform = "transform" + } +} + +public struct ObjectSignRequestBodyTransform: Codable, Sendable, Hashable { + public var format: ObjectSignRequestBodyTransformFormat? + public var height: Int? + public var quality: Int? + public var resize: ObjectSignRequestBodyTransformResize? + public var width: Int? + + enum CodingKeys: String, CodingKey { + case format = "format" + case height = "height" + case quality = "quality" + case resize = "resize" + case width = "width" + } +} + +public enum ObjectSignRequestBodyTransformFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum ObjectSignRequestBodyTransformResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public struct ObjectSignResponse200: Codable, Sendable, Hashable { + public var signedURL: String + + enum CodingKeys: String, CodingKey { + case signedURL = "signedURL" + } +} + +public struct ObjectUploadSignedResponse200: Codable, Sendable, Hashable { + public var Key: String + + enum CodingKeys: String, CodingKey { + case Key = "Key" + } +} + +public struct ObjectUploadUpdateResponse200: Codable, Sendable, Hashable { + public var Id: String? + public var Key: String + + enum CodingKeys: String, CodingKey { + case Id = "Id" + case Key = "Key" + } +} + +public struct ObjectUploadResponse200: Codable, Sendable, Hashable { + public var Id: String? + public var Key: String + + enum CodingKeys: String, CodingKey { + case Id = "Id" + case Key = "Key" + } +} + +public enum RenderImageAuthenticatedHeadFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum RenderImageAuthenticatedHeadResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum RenderImageAuthenticatedFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum RenderImageAuthenticatedResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum RenderImagePublicHeadFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum RenderImagePublicHeadResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} + +public enum RenderImagePublicFormat: String, Codable, Sendable, Hashable { + case origin = "origin" + case avif = "avif" + case webp = "webp" +} + +public enum RenderImagePublicResize: String, Codable, Sendable, Hashable { + case cover = "cover" + case contain = "contain" + case fill = "fill" +} diff --git a/Sources/StorageOpenAPI/StorageOpenAPIClient.swift b/Sources/StorageOpenAPI/StorageOpenAPIClient.swift new file mode 100644 index 000000000..32c06c344 --- /dev/null +++ b/Sources/StorageOpenAPI/StorageOpenAPIClient.swift @@ -0,0 +1,710 @@ +// Code generated by openapi-codegen. DO NOT EDIT. + +public import Foundation +public import HTTPRuntime + +public struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + public func bucketCreate(payload: BucketCreateRequestBody) async throws -> BucketCreateResponse200 + { + var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/bucket") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(BucketCreateResponse200.self, from: response.body) + } + + public func bucketDelete(bucketId: String) async throws -> BucketDeleteResponse200 { + var builder = HTTPRequestBuilder( + method: .delete, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(BucketDeleteResponse200.self, from: response.body) + } + + public func bucketEmpty(bucketId: String) async throws -> BucketEmptyResponse200 { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))/empty") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(BucketEmptyResponse200.self, from: response.body) + } + + public func bucketGet(bucketId: String) async throws -> BucketSchema { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body) + } + + public func bucketGetHead(bucketId: String) async throws -> BucketSchema { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body) + } + + public func bucketList( + limit: Int? = nil, offset: Int? = nil, search: String? = nil, + sortColumn: BucketListSortColumn? = nil, sortOrder: BucketListSortOrder? = nil + ) async throws -> [BucketSchema] { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/bucket") + builder.addQuery("limit", limit.map(String.init)) + builder.addQuery("offset", offset.map(String.init)) + builder.addQuery("search", search) + builder.addQuery("sortColumn", sortColumn?.rawValue) + builder.addQuery("sortOrder", sortOrder?.rawValue) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([BucketSchema].self, from: response.body) + } + + public func bucketListHead( + limit: Int? = nil, offset: Int? = nil, search: String? = nil, + sortColumn: BucketListHeadSortColumn? = nil, sortOrder: BucketListHeadSortOrder? = nil + ) async throws -> [BucketSchema] { + var builder = HTTPRequestBuilder(method: .head, baseURL: baseURL, path: "/bucket") + builder.addQuery("limit", limit.map(String.init)) + builder.addQuery("offset", offset.map(String.init)) + builder.addQuery("search", search) + builder.addQuery("sortColumn", sortColumn?.rawValue) + builder.addQuery("sortOrder", sortOrder?.rawValue) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([BucketSchema].self, from: response.body) + } + + public func bucketUpdate(bucketId: String, payload: BucketUpdateRequestBody) async throws + -> BucketUpdateResponse200 + { + var builder = HTTPRequestBuilder( + method: .put, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(BucketUpdateResponse200.self, from: response.body) + } + + public func cdnPurgeBucketCache(bucketName: String, transformations: Bool? = nil) async throws + -> CdnPurgeBucketCacheResponse200 + { + var builder = HTTPRequestBuilder( + method: .delete, baseURL: baseURL, path: "/cdn/\(PathEncoding.segment(bucketName))") + builder.addQuery("transformations", transformations.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(CdnPurgeBucketCacheResponse200.self, from: response.body) + } + + public func cdnPurgeObjectCache( + bucketName: String, wildcard: String, transformations: Bool? = nil + ) async throws -> CdnPurgeObjectCacheResponse200 { + var builder = HTTPRequestBuilder( + method: .delete, baseURL: baseURL, + path: "/cdn/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("transformations", transformations.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(CdnPurgeObjectCacheResponse200.self, from: response.body) + } + + public func cdnPurgeTenantCache(transformations: Bool? = nil) async throws + -> CdnPurgeTenantCacheResponse200 + { + var builder = HTTPRequestBuilder(method: .delete, baseURL: baseURL, path: "/cdn/") + builder.addQuery("transformations", transformations.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(CdnPurgeTenantCacheResponse200.self, from: response.body) + } + + public func healthcheck() async throws -> [String: JSONValue] { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/health") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func healthcheckHead() async throws -> [String: JSONValue] { + var builder = HTTPRequestBuilder(method: .head, baseURL: baseURL, path: "/health") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func objectCopy(payload: ObjectCopyRequestBody) async throws -> ObjectCopyResponse200 { + var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/object/copy") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(ObjectCopyResponse200.self, from: response.body) + } + + public func objectDelete(bucketName: String, wildcard: String) async throws + -> ObjectDeleteResponse200 + { + var builder = HTTPRequestBuilder( + method: .delete, baseURL: baseURL, + path: "/object/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(ObjectDeleteResponse200.self, from: response.body) + } + + public func objectDeleteMany(bucketName: String, payload: ObjectDeleteManyRequestBody) + async throws -> [ObjectSchema] + { + var builder = HTTPRequestBuilder( + method: .delete, baseURL: baseURL, path: "/object/\(PathEncoding.segment(bucketName))") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([ObjectSchema].self, from: response.body) + } + + public func objectGetAuthenticated(bucketName: String, wildcard: String, download: String? = nil) + async throws + { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: + "/object/authenticated/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))" + ) + builder.addQuery("download", download) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func objectGetAuthenticatedGet(bucketName: String, wildcard: String) async throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: "/object/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func objectGetAuthenticatedInfo( + bucketName: String, wildcard: String, format: ObjectGetAuthenticatedInfoFormat? = nil, + height: Int? = nil, quality: Int? = nil, resize: ObjectGetAuthenticatedInfoResize? = nil, + width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: + "/object/info/authenticated/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))" + ) + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func objectGetAuthenticatedInfo2( + bucketName: String, wildcard: String, format: ObjectGetAuthenticatedInfo2Format? = nil, + height: Int? = nil, quality: Int? = nil, resize: ObjectGetAuthenticatedInfo2Resize? = nil, + width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: "/object/info/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func objectGetAuthenticatedInfoGet( + bucketName: String, wildcard: String, format: ObjectGetAuthenticatedInfoGetFormat? = nil, + height: Int? = nil, quality: Int? = nil, resize: ObjectGetAuthenticatedInfoGetResize? = nil, + width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: "/object/info/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func objectGetAuthenticatedInfoHead( + bucketName: String, wildcard: String, format: ObjectGetAuthenticatedInfoHeadFormat? = nil, + height: Int? = nil, quality: Int? = nil, resize: ObjectGetAuthenticatedInfoHeadResize? = nil, + width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: + "/object/info/authenticated/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))" + ) + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func objectGetPublic(bucketName: String, wildcard: String, download: String? = nil) + async throws + { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: "/object/public/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("download", download) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func objectGetSigned( + bucketName: String, token: String, wildcard: String, download: String? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: "/object/sign/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("token", token) + builder.addQuery("download", download) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func objectGetSignedHead( + bucketName: String, token: String, wildcard: String, download: String? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: "/object/sign/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("token", token) + builder.addQuery("download", download) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func objectHeadAuthenticatedInfo( + bucketName: String, wildcard: String, format: ObjectHeadAuthenticatedInfoFormat? = nil, + height: Int? = nil, quality: Int? = nil, resize: ObjectHeadAuthenticatedInfoResize? = nil, + width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: + "/object/authenticated/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))" + ) + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func objectHeadAuthenticatedInfoHead( + bucketName: String, wildcard: String, format: ObjectHeadAuthenticatedInfoHeadFormat? = nil, + height: Int? = nil, quality: Int? = nil, resize: ObjectHeadAuthenticatedInfoHeadResize? = nil, + width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: "/object/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func objectInfoPublic( + bucketName: String, wildcard: String, format: ObjectInfoPublicFormat? = nil, height: Int? = nil, + quality: Int? = nil, resize: ObjectInfoPublicResize? = nil, width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: "/object/public/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func objectInfoPublicGet( + bucketName: String, wildcard: String, format: ObjectInfoPublicGetFormat? = nil, + height: Int? = nil, quality: Int? = nil, resize: ObjectInfoPublicGetResize? = nil, + width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: + "/object/info/public/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func objectList(bucketName: String, payload: ObjectListRequestBody) async throws + -> [ObjectSchema] + { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, path: "/object/list/\(PathEncoding.segment(bucketName))") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([ObjectSchema].self, from: response.body) + } + + public func objectListV2(bucketName: String, payload: ObjectListV2RequestBody) async throws + -> [String: JSONValue] + { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, path: "/object/list-v2/\(PathEncoding.segment(bucketName))") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func objectMove(payload: ObjectMoveRequestBody) async throws -> ObjectMoveResponse200 { + var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/object/move") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(ObjectMoveResponse200.self, from: response.body) + } + + public func objectSign(bucketName: String, wildcard: String, payload: ObjectSignRequestBody) + async throws -> ObjectSignResponse200 + { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, + path: "/object/sign/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(ObjectSignResponse200.self, from: response.body) + } + + public func objectSignMany(bucketName: String, payload: ObjectSignManyRequestBody) async throws + -> [ObjectSignManyResponse200Item] + { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, path: "/object/sign/\(PathEncoding.segment(bucketName))") + builder.setHeader("Content-Type", "application/json") + builder.setBody(.data(try JSONCoding.encoder.encode(payload))) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([ObjectSignManyResponse200Item].self, from: response.body) + } + + public func objectSignUploadUrl(bucketName: String, wildcard: String) async throws + -> ObjectSignUploadUrlResponse200 + { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, + path: + "/object/upload/sign/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(ObjectSignUploadUrlResponse200.self, from: response.body) + } + + public func objectUpload(bucketName: String, wildcard: String) async throws + -> ObjectUploadResponse200 + { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, + path: "/object/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(ObjectUploadResponse200.self, from: response.body) + } + + public func objectUploadSigned(bucketName: String, token: String, wildcard: String) async throws + -> ObjectUploadSignedResponse200 + { + var builder = HTTPRequestBuilder( + method: .put, baseURL: baseURL, + path: + "/object/upload/sign/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("token", token) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + return try JSONCoding.decoder.decode(ObjectUploadSignedResponse200.self, from: response.body) + } + + public func objectUploadUpdate(bucketName: String, wildcard: String) async throws + -> ObjectUploadUpdateResponse200 + { + var builder = HTTPRequestBuilder( + method: .put, baseURL: baseURL, + path: "/object/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode(ObjectUploadUpdateResponse200.self, from: response.body) + } + + public func renderImageAuthenticated( + bucketName: String, wildcard: String, download: String? = nil, + format: RenderImageAuthenticatedFormat? = nil, height: Int? = nil, quality: Int? = nil, + resize: RenderImageAuthenticatedResize? = nil, width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: + "/render/image/authenticated/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))" + ) + builder.addQuery("download", download) + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func renderImageAuthenticatedHead( + bucketName: String, wildcard: String, download: String? = nil, + format: RenderImageAuthenticatedHeadFormat? = nil, height: Int? = nil, quality: Int? = nil, + resize: RenderImageAuthenticatedHeadResize? = nil, width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: + "/render/image/authenticated/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))" + ) + builder.addQuery("download", download) + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + } + + public func renderImagePublic( + bucketName: String, wildcard: String, download: String? = nil, + format: RenderImagePublicFormat? = nil, height: Int? = nil, quality: Int? = nil, + resize: RenderImagePublicResize? = nil, width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: + "/render/image/public/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))" + ) + builder.addQuery("download", download) + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func renderImagePublicHead( + bucketName: String, wildcard: String, download: String? = nil, + format: RenderImagePublicHeadFormat? = nil, height: Int? = nil, quality: Int? = nil, + resize: RenderImagePublicHeadResize? = nil, width: Int? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: + "/render/image/public/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))" + ) + builder.addQuery("download", download) + builder.addQuery("format", format?.rawValue) + builder.addQuery("height", height.map(String.init)) + builder.addQuery("quality", quality.map(String.init)) + builder.addQuery("resize", resize?.rawValue) + builder.addQuery("width", width.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func renderImageSign( + bucketName: String, token: String, wildcard: String, download: String? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: baseURL, + path: + "/render/image/sign/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("token", token) + builder.addQuery("download", download) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func renderImageSignHead( + bucketName: String, token: String, wildcard: String, download: String? = nil + ) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: + "/render/image/sign/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") + builder.addQuery("token", token) + builder.addQuery("download", download) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusOptions() async throws { + var builder = HTTPRequestBuilder(method: .options, baseURL: baseURL, path: "/upload/resumable/") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusOptionsOptions(wildcard: String) async throws { + var builder = HTTPRequestBuilder( + method: .options, baseURL: baseURL, + path: "/upload/resumable/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusOptionsSigned() async throws { + var builder = HTTPRequestBuilder( + method: .options, baseURL: baseURL, path: "/upload/resumable/sign/") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusOptionsSignedOptions(wildcard: String) async throws { + var builder = HTTPRequestBuilder( + method: .options, baseURL: baseURL, + path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusUploadCreate() async throws -> [String: JSONValue] { + var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/upload/resumable/") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func tusUploadCreatePost(wildcard: String) async throws -> [String: JSONValue] { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func tusUploadCreateSigned() async throws { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, path: "/upload/resumable/sign/") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusUploadCreateSignedPost(wildcard: String) async throws { + var builder = HTTPRequestBuilder( + method: .post, baseURL: baseURL, + path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusUploadDelete(wildcard: String) async throws -> [String: JSONValue] { + var builder = HTTPRequestBuilder( + method: .delete, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))" + ) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func tusUploadDeleteSigned(wildcard: String) async throws { + var builder = HTTPRequestBuilder( + method: .delete, baseURL: baseURL, + path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusUploadGet(wildcard: String) async throws -> [String: JSONValue] { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func tusUploadGetSigned(wildcard: String) async throws { + var builder = HTTPRequestBuilder( + method: .head, baseURL: baseURL, + path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusUploadPart(wildcard: String) async throws -> [String: JSONValue] { + var builder = HTTPRequestBuilder( + method: .put, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func tusUploadPartPatch(wildcard: String) async throws -> [String: JSONValue] { + var builder = HTTPRequestBuilder( + method: .patch, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [403: ErrorSchema.self]) + return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) + } + + public func tusUploadPartSigned(wildcard: String) async throws { + var builder = HTTPRequestBuilder( + method: .put, baseURL: baseURL, + path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + + public func tusUploadPartSignedPatch(wildcard: String) async throws { + var builder = HTTPRequestBuilder( + method: .patch, baseURL: baseURL, + path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } +} diff --git a/Tests/StorageOpenAPITests/StorageOpenAPITests.swift b/Tests/StorageOpenAPITests/StorageOpenAPITests.swift new file mode 100644 index 000000000..062932298 --- /dev/null +++ b/Tests/StorageOpenAPITests/StorageOpenAPITests.swift @@ -0,0 +1,15 @@ +// +// StorageOpenAPITests.swift +// StorageOpenAPI +// +// Created by Guilherme Souza on 08/07/26. +// + +import Testing + +@testable import StorageOpenAPI + +@Suite +struct StorageOpenAPITests { + // Placeholder — real coverage lands in Task 14. +} diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift index 2693b2e6c..e184ef91b 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift @@ -10,7 +10,7 @@ public enum SwiftEmitter { "// Code generated by openapi-codegen. DO NOT EDIT.", "", "import Foundation", - "import HTTPRuntime", + "public import HTTPRuntime", "", ] for schema in document.schemas.sorted(by: { $0.name < $1.name }) { @@ -44,7 +44,8 @@ public enum SwiftEmitter { } } - static func emitStruct(named typeName: String, properties: [IRProperty], isError: Bool) -> String { + static func emitStruct(named typeName: String, properties: [IRProperty], isError: Bool) -> String + { let conformances = (["Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) .joined(separator: ", ") var lines = ["public struct \(typeName): \(conformances) {"] @@ -65,7 +66,8 @@ public enum SwiftEmitter { } static func emitStringEnum(named typeName: String, cases: [String], isError: Bool) -> String { - let conformances = (["String", "Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) + let conformances = + (["String", "Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) .joined(separator: ", ") var lines = ["public enum \(typeName): \(conformances) {"] for value in cases { @@ -85,7 +87,7 @@ public enum SwiftEmitter { lines.append(" case \(caseName)(\(typeRef))") } lines.append("") - lines.append(" public init(from decoder: Decoder) throws {") + lines.append(" public init(from decoder: any Decoder) throws {") lines.append(" let container = try decoder.singleValueContainer()") for unionCase in cases { let caseName = SwiftNames.propertyName(unionCase.name) @@ -103,7 +105,7 @@ public enum SwiftEmitter { lines.append(" )") lines.append(" }") lines.append("") - lines.append(" public func encode(to encoder: Encoder) throws {") + lines.append(" public func encode(to encoder: any Encoder) throws {") lines.append(" var container = encoder.singleValueContainer()") lines.append(" switch self {") for unionCase in cases { @@ -123,8 +125,8 @@ public enum SwiftEmitter { var lines: [String] = [ "// Code generated by openapi-codegen. DO NOT EDIT.", "", - "import Foundation", - "import HTTPRuntime", + "public import Foundation", + "public import HTTPRuntime", "", "public struct \(clientName): Sendable {", " private let baseURL: URL", @@ -165,10 +167,12 @@ public enum SwiftEmitter { " var builder = HTTPRequestBuilder(method: .\(operation.method.rawValue), baseURL: baseURL, path: \"\(pathTemplate(operation))\")", ] for parameter in parameters where parameter.location == .query { - lines.append(" builder.addQuery(\"\(parameter.name)\", \(stringConversionExpression(parameter)))") + lines.append( + " builder.addQuery(\"\(parameter.name)\", \(stringConversionExpression(parameter)))") } for parameter in parameters where parameter.location == .header { - lines.append(" builder.setHeader(\"\(parameter.name)\", \(stringConversionExpression(parameter)))") + lines.append( + " builder.setHeader(\"\(parameter.name)\", \(stringConversionExpression(parameter)))") } if let requestBody = operation.requestBody { lines.append(contentsOf: requestBodyLines(requestBody)) @@ -187,7 +191,8 @@ public enum SwiftEmitter { ) if let successResponse, case .json(let type) = successResponse.body { let typeName = SwiftNames.typeReference(type, isOptional: false) - lines.append(" return try JSONCoding.decoder.decode(\(typeName).self, from: response.body)") + lines.append( + " return try JSONCoding.decoder.decode(\(typeName).self, from: response.body)") } } lines.append(" }") @@ -208,6 +213,13 @@ public enum SwiftEmitter { static func stringConversionExpression(_ parameter: IRParameter) -> String { let name = SwiftNames.propertyName(parameter.name) if case .string = parameter.type { return name } + // Schema-ref parameters are always emitted as `String`-backed enums + // (see `emitStringEnum`), so `.rawValue` converts them — `String.init` + // would be ambiguous between the enum's `RawRepresentable` conformance + // and other `String` initializer overloads. + if case .schemaRef = parameter.type { + return parameter.isOptional ? "\(name)?.rawValue" : "\(name).rawValue" + } return parameter.isOptional ? "\(name).map(String.init)" : "String(\(name))" } @@ -267,6 +279,7 @@ public enum SwiftEmitter { entries.append("\(response.statusCode): \(SwiftNames.typeName(name)).self") } } + guard !entries.isEmpty else { return "[:]" } return "[\(entries.joined(separator: ", "))]" } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift index 96845353e..2df0a201d 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift @@ -37,8 +37,8 @@ struct ClientEmitterTests { #""" // Code generated by openapi-codegen. DO NOT EDIT. - import Foundation - import HTTPRuntime + public import Foundation + public import HTTPRuntime public struct StorageOpenAPIClient: Sendable { private let baseURL: URL @@ -89,8 +89,8 @@ struct ClientEmitterTests { #""" // Code generated by openapi-codegen. DO NOT EDIT. - import Foundation - import HTTPRuntime + public import Foundation + public import HTTPRuntime public struct StorageOpenAPIClient: Sendable { private let baseURL: URL @@ -109,7 +109,7 @@ struct ClientEmitterTests { builder.setHeader("Content-Type", formData.contentType) builder.setBody(.multipart(formData)) let response = try await transport.send(try builder.build()) - try response.checkStatus(errorTypes: []) + try response.checkStatus(errorTypes: [:]) } } """# @@ -140,8 +140,8 @@ struct ClientEmitterTests { #""" // Code generated by openapi-codegen. DO NOT EDIT. - import Foundation - import HTTPRuntime + public import Foundation + public import HTTPRuntime public struct StorageOpenAPIClient: Sendable { private let baseURL: URL @@ -164,4 +164,56 @@ struct ClientEmitterTests { """# } } + + @Test + func emitsSchemaRefQueryParameterUsingRawValue() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "listBuckets", + method: .get, + path: "/bucket", + parameters: [ + IRParameter( + name: "sortColumn", location: .query, type: .schemaRef("BucketListSortColumn"), + isOptional: true) + ], + requestBody: nil, + responses: [ + IRResponse(statusCode: 200, isError: false, body: .json(.schemaRef("bucketSchema"))) + ] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + assertInlineSnapshot(of: output, as: .lines) { + #""" + // Code generated by openapi-codegen. DO NOT EDIT. + + public import Foundation + public import HTTPRuntime + + public struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + public func listBuckets(sortColumn: BucketListSortColumn? = nil) async throws -> BucketSchema { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/bucket") + builder.addQuery("sortColumn", sortColumn?.rawValue) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body) + } + } + """# + } + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift index c2dcc02b5..3a869df8d 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift @@ -32,7 +32,7 @@ struct ModelEmitterTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - import HTTPRuntime + public import HTTPRuntime public struct BucketSchema: Codable, Sendable, Hashable { public var id: String @@ -62,7 +62,7 @@ struct ModelEmitterTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - import HTTPRuntime + public import HTTPRuntime public enum Visibility: String, Codable, Sendable, Hashable { case `public` = "public" @@ -103,7 +103,7 @@ struct ModelEmitterTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - import HTTPRuntime + public import HTTPRuntime public struct ErrorSchema: Codable, Sendable, Hashable, APIError { public var message: String @@ -139,13 +139,13 @@ struct ModelEmitterTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - import HTTPRuntime + public import HTTPRuntime public enum BucketCreateFileSizeLimit: Codable, Sendable, Hashable { case integer(Int) case string(String) - public init(from decoder: Decoder) throws { + public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(Int.self) { self = .integer(value) @@ -161,7 +161,7 @@ struct ModelEmitterTests { ) } - public func encode(to encoder: Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .integer(let value): try container.encode(value) diff --git a/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift b/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift index 8a62f1ce6..09a7ef9ff 100644 --- a/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift +++ b/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift @@ -72,7 +72,7 @@ struct EndToEndTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - import HTTPRuntime + public import HTTPRuntime public struct BucketSchema: Codable, Sendable, Hashable { public var id: String @@ -98,8 +98,8 @@ struct EndToEndTests { #""" // Code generated by openapi-codegen. DO NOT EDIT. - import Foundation - import HTTPRuntime + public import Foundation + public import HTTPRuntime public struct StorageOpenAPIClient: Sendable { private let baseURL: URL From c80d66ded647c37e33bdf3943185bb0d228e0dce Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 19:06:42 -0300 Subject: [PATCH 29/44] test(storage): cover the generated OpenAPI client's core paths Adds tests for StorageOpenAPIClient using a fake HTTPTransport: JSON success decoding (bucketGet), typed-error decoding via the real 403 error mapping (ErrorSchema), unmapped-status fallback (HTTPError), and the current bodyless behavior of objectUpload (multipart/file request bodies are not wired up yet in the generated client). --- .../BucketOperationsTests.swift | 54 +++++++++++++++++++ .../ErrorDecodingTests.swift | 47 ++++++++++++++++ .../ObjectUploadTests.swift | 43 +++++++++++++++ .../StorageOpenAPITests.swift | 15 ------ 4 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 Tests/StorageOpenAPITests/BucketOperationsTests.swift create mode 100644 Tests/StorageOpenAPITests/ErrorDecodingTests.swift create mode 100644 Tests/StorageOpenAPITests/ObjectUploadTests.swift delete mode 100644 Tests/StorageOpenAPITests/StorageOpenAPITests.swift diff --git a/Tests/StorageOpenAPITests/BucketOperationsTests.swift b/Tests/StorageOpenAPITests/BucketOperationsTests.swift new file mode 100644 index 000000000..7d04e3145 --- /dev/null +++ b/Tests/StorageOpenAPITests/BucketOperationsTests.swift @@ -0,0 +1,54 @@ +// +// BucketOperationsTests.swift +// StorageOpenAPI +// +// Created by Guilherme Souza on 08/07/26. +// + +import Foundation +import HTTPRuntime +import Testing + +@testable import StorageOpenAPI + +/// A fake `HTTPTransport` that answers every request from a caller-supplied +/// closure. Shared by the StorageOpenAPI test suite so no test ever touches +/// the network. +struct FakeTransport: HTTPTransport { + var onSend: @Sendable (HTTPRequest) throws -> HTTPResponse + + func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws -> HTTPResponse { + try onSend(request) + } + + func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { + let response = try onSend(request) + return HTTPResponseStream( + head: response.head, + body: AsyncThrowingStream { continuation in + continuation.yield(response.body) + continuation.finish() + } + ) + } +} + +@Suite +struct BucketOperationsTests { + + @Test + func bucketGetDecodesASuccessResponse() async throws { + let responseBody = Data(#"{"id":"avatars","name":"avatars","public":true}"#.utf8) + let transport = FakeTransport { request in + #expect(request.url.path.hasSuffix("/bucket/avatars")) + return HTTPResponse(head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) + } + let client = StorageOpenAPIClient( + baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) + + let bucket = try await client.bucketGet(bucketId: "avatars") + + #expect(bucket.id == "avatars") + #expect(bucket.public == true) + } +} diff --git a/Tests/StorageOpenAPITests/ErrorDecodingTests.swift b/Tests/StorageOpenAPITests/ErrorDecodingTests.swift new file mode 100644 index 000000000..4b0bb5326 --- /dev/null +++ b/Tests/StorageOpenAPITests/ErrorDecodingTests.swift @@ -0,0 +1,47 @@ +// +// ErrorDecodingTests.swift +// StorageOpenAPI +// +// Created by Guilherme Souza on 08/07/26. +// + +import Foundation +import HTTPRuntime +import Testing + +@testable import StorageOpenAPI + +@Suite +struct ErrorDecodingTests { + + // The real generated `bucketGet` only registers status 403 -> ErrorSchema + // (per the spec's documented error bindings for this operation); other + // non-success statuses fall through to `HTTPError.unexpectedStatus`. + @Test + func bucketGetThrowsATypedErrorOn403() async throws { + let errorBody = Data( + #"{"message":"Access denied","error":"Forbidden","statusCode":"403"}"#.utf8) + let transport = FakeTransport { _ in + HTTPResponse(head: HTTPResponseHead(status: 403, headers: [:]), body: errorBody) + } + let client = StorageOpenAPIClient( + baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) + + await #expect(throws: ErrorSchema.self) { + _ = try await client.bucketGet(bucketId: "missing") + } + } + + @Test + func bucketGetThrowsUnexpectedStatusOnUnmappedError() async throws { + let transport = FakeTransport { _ in + HTTPResponse(head: HTTPResponseHead(status: 404, headers: [:]), body: Data()) + } + let client = StorageOpenAPIClient( + baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) + + await #expect(throws: HTTPError.self) { + _ = try await client.bucketGet(bucketId: "missing") + } + } +} diff --git a/Tests/StorageOpenAPITests/ObjectUploadTests.swift b/Tests/StorageOpenAPITests/ObjectUploadTests.swift new file mode 100644 index 000000000..dc9b85959 --- /dev/null +++ b/Tests/StorageOpenAPITests/ObjectUploadTests.swift @@ -0,0 +1,43 @@ +// +// ObjectUploadTests.swift +// StorageOpenAPI +// +// Created by Guilherme Souza on 08/07/26. +// + +import Foundation +import HTTPRuntime +import Testing + +@testable import StorageOpenAPI + +@Suite +struct ObjectUploadTests { + + // NOTE: the real Storage OpenAPI spec's `object#createObject` operation + // declares a `multipart/form-data` (or raw binary) request body, but + // Task 13's generator does not yet wire request bodies for `multipart` + // content types into `StorageOpenAPIClient.objectUpload` — the generated + // method takes only `bucketName`/`wildcard` and never calls `setBody`, so + // no file content is ever sent. This test documents that real, current + // behavior rather than asserting a multipart body that doesn't exist yet. + @Test + func objectUploadSendsNoBodyYet() async throws { + let transport = FakeTransport { request in + #expect(request.url.path.hasSuffix("/object/avatars/a.txt")) + guard case .none = request.body else { + Issue.record( + "expected no request body to be set (multipart body generation is not yet implemented)") + return HTTPResponse(head: HTTPResponseHead(status: 500, headers: [:]), body: Data()) + } + let responseBody = Data(#"{"Key":"avatars/a.txt"}"#.utf8) + return HTTPResponse(head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) + } + let client = StorageOpenAPIClient( + baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) + + let result = try await client.objectUpload(bucketName: "avatars", wildcard: "a.txt") + + #expect(result.Key == "avatars/a.txt") + } +} diff --git a/Tests/StorageOpenAPITests/StorageOpenAPITests.swift b/Tests/StorageOpenAPITests/StorageOpenAPITests.swift deleted file mode 100644 index 062932298..000000000 --- a/Tests/StorageOpenAPITests/StorageOpenAPITests.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// StorageOpenAPITests.swift -// StorageOpenAPI -// -// Created by Guilherme Souza on 08/07/26. -// - -import Testing - -@testable import StorageOpenAPI - -@Suite -struct StorageOpenAPITests { - // Placeholder — real coverage lands in Task 14. -} From 0b399ce95b02494d7f9cfb842e1dbc1ec308d59d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 19:24:47 -0300 Subject: [PATCH 30/44] chore(ci): add HTTPRuntimeTests and StorageOpenAPITests to the Supabase scheme --- .../xcshareddata/xcschemes/Supabase.xcscheme | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme b/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme index 329c48f39..df59b80f2 100644 --- a/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme +++ b/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme @@ -166,6 +166,17 @@ ReferencedContainer = "container:"> + + + + @@ -210,6 +221,17 @@ ReferencedContainer = "container:"> + + + + From 340d3ec630dde52faa23ed49d7b4cc8f21fc30f1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 19:26:35 -0300 Subject: [PATCH 31/44] chore(spell-check): add terms from the OpenAPI codegen tool and generated client --- dictionary.txt | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/dictionary.txt b/dictionary.txt index 7858a3f3a..d7c885595 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -175,6 +175,31 @@ xcodeproj xctest XVCJ +# Terms from the OpenAPI-to-Swift code generator (tools/openapi-codegen) and +# the generated Storage HTTP client (Sources/StorageOpenAPI). +associatedtype +claude +Codegen +codegen +conformances +deinit +fileprivate +grdsdev +guilherme +healthcheck +IRHTTP +nullable +openapi +ramanujan +reencoded +subdata +Subrange +TESTBOUNDARY +typealias +Typeless +uppercased +xcworkspace + # Non-ASCII fixture data used to exercise Unicode-handling and URL-encoding # edge cases (BuildURLRequestTests, HTTPErrorTests). Cigányka From 51dc415ba8fae7bf86c68af573fc2493f646b385 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 19:52:36 -0300 Subject: [PATCH 32/44] fix(codegen): sort multipart field iteration for deterministic output --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 2 +- .../RequestBodyParsingTests.swift | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 53e2bb7e8..27f865898 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -334,7 +334,7 @@ public enum OpenAPIParsing { location: location, reason: "multipart request body must be an inline object schema") } var fields: [IRMultipartField] = [] - for (fieldName, fieldSchema) in objectContext.properties { + for (fieldName, fieldSchema) in objectContext.properties.sorted(by: { $0.key < $1.key }) { var isFile = false if case .string(let core, _) = fieldSchema.value, core.format == .binary { isFile = true diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift index 8bad90f07..54475e2d3 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/RequestBodyParsingTests.swift @@ -64,6 +64,33 @@ struct RequestBodyParsingTests { #expect(hoisted.isEmpty) } + @Test + func multipartFieldsAreSortedDeterministically() throws { + let json = """ + { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "zebra": {"type": "string"}, + "apple": {"type": "string"}, + "mango": {"type": "string"} + } + } + } + } + } + """ + let (body, _) = try OpenAPIParsing.parseRequestBody(requestBody(json), location: "op") + + guard case .multipart(let fields) = body else { + Issue.record("expected a multipart request body") + return + } + #expect(fields.map(\.name) == ["apple", "mango", "zebra"]) + } + @Test func rejectsUnsupportedContentType() throws { let json = """ From 3939d73c39aee20334fcd619d35d1fc561d60baf Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 21:19:51 -0300 Subject: [PATCH 33/44] fix(codegen): correctly emit array query parameters, reject union parameters --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 17 ++-- .../OpenAPICodegenCore/SwiftEmitter.swift | 4 + .../ClientEmitterTests.swift | 94 +++++++++++++++++++ .../ParameterParsingTests.swift | 14 +++ 4 files changed, 120 insertions(+), 9 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 27f865898..48f39dafa 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -234,16 +234,15 @@ public enum OpenAPIParsing { ) return (irParameter, [IRSchema(name: hoistedName, kind: .stringEnum(cases: cases))]) } - if let (type, hoistedSchema) = try hoistUnionIfPresent( - name: "\(location)_\(parameter.name)", schema: schema, location: parameterLocation) - { - let irParameter = IRParameter( - name: parameter.name, - location: irLocation, - type: type, - isOptional: !parameter.required || schema.nullable + switch schema.value { + case .one, .any: + throw UnsupportedSpecConstruct( + location: parameterLocation, + reason: + "union parameters aren't supported (no well-defined query/header string representation)" ) - return (irParameter, [hoistedSchema]) + default: + break } if let (type, arrayHoisted) = try hoistArrayOfObjectIfPresent( name: "\(location)_\(parameter.name)", schema: schema, location: parameterLocation) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift index e184ef91b..a1337df55 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift @@ -220,6 +220,10 @@ public enum SwiftEmitter { if case .schemaRef = parameter.type { return parameter.isOptional ? "\(name)?.rawValue" : "\(name).rawValue" } + if case .array(let element) = parameter.type { + if case .string = element { return name } + return parameter.isOptional ? "\(name)?.map(String.init)" : "\(name).map(String.init)" + } return parameter.isOptional ? "\(name).map(String.init)" : "String(\(name))" } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift index 2df0a201d..7f0359a70 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift @@ -216,4 +216,98 @@ struct ClientEmitterTests { """# } } + + @Test + func emitsArrayQueryParameter() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "listItems", + method: .get, + path: "/items", + parameters: [ + IRParameter(name: "tags", location: .query, type: .array(.string), isOptional: true) + ], + requestBody: nil, + responses: [IRResponse(statusCode: 200, isError: false, body: .none)] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + assertInlineSnapshot(of: output, as: .lines) { + #""" + // Code generated by openapi-codegen. DO NOT EDIT. + + public import Foundation + public import HTTPRuntime + + public struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + public func listItems(tags: [String]? = nil) async throws { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/items") + builder.addQuery("tags", tags) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + } + """# + } + } + + @Test + func emitsArrayOfIntegerQueryParameter() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "listItems", + method: .get, + path: "/items", + parameters: [ + IRParameter(name: "ids", location: .query, type: .array(.integer), isOptional: true) + ], + requestBody: nil, + responses: [IRResponse(statusCode: 200, isError: false, body: .none)] + ) + ] + ) + + let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + + assertInlineSnapshot(of: output, as: .lines) { + #""" + // Code generated by openapi-codegen. DO NOT EDIT. + + public import Foundation + public import HTTPRuntime + + public struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + public func listItems(ids: [Int]? = nil) async throws { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/items") + builder.addQuery("ids", ids?.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + } + """# + } + } } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift index bf6cbcbd5..f2de7f367 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift @@ -102,4 +102,18 @@ struct ParameterParsingTests { #expect(hoisted.count == 1) #expect(hoisted.first?.name == "listObjects_pathsItem") } + + @Test + func rejectsUnionParameter() throws { + let json = """ + { + "name": "filter", + "in": "query", + "schema": {"anyOf": [{"type": "integer"}, {"type": "string"}]} + } + """ + #expect(throws: UnsupportedSpecConstruct.self) { + try OpenAPIParsing.parseParameter(try parameter(json), location: "op") + } + } } From b6f8c560f6cf8244e2366a2615a6793b5db649fb Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 21:30:38 -0300 Subject: [PATCH 34/44] refactor(codegen): extract shared inline-object hoisting helper Consolidates the inline-object-with-properties hoisting check that was hand-copied at three call sites (parseObjectProperties, parseRequestBody, parseResponseBody) into a single hoistInlineObjectIfPresent helper, matching the pattern already used for hoistUnionIfPresent and hoistArrayOfObjectIfPresent. parseParameter is intentionally left untouched since it doesn't support inline-object hoisting. --- .../OpenAPICodegenCore/OpenAPIParsing.swift | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 48f39dafa..81a783be1 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -58,16 +58,11 @@ public enum OpenAPIParsing { continue } - if case .object(_, let nestedContext) = propertySchema.value, - !nestedContext.properties.isEmpty + if let (type, objectHoisted) = try hoistInlineObjectIfPresent( + name: "\(name)_\(propertyName)", schema: propertySchema, location: propertyLocation) { - let hoistedName = "\(name)_\(propertyName)" - let (nestedProperties, nestedHoisted) = try parseObjectProperties( - name: hoistedName, objectContext: nestedContext, location: propertyLocation) - hoisted.append(IRSchema(name: hoistedName, kind: .object(properties: nestedProperties))) - hoisted.append(contentsOf: nestedHoisted) - properties.append( - IRProperty(name: propertyName, type: .schemaRef(hoistedName), isOptional: isOptional)) + hoisted.append(contentsOf: objectHoisted) + properties.append(IRProperty(name: propertyName, type: type, isOptional: isOptional)) continue } @@ -152,6 +147,25 @@ public enum OpenAPIParsing { return (.array(.schemaRef(hoistedName)), hoisted) } + /// If `schema` is an inline object with properties (not a `$ref`, not + /// empty), hoists it into a named schema instead of failing. Returns `nil` + /// if `schema` isn't an inline object-with-properties, so callers fall + /// through to their existing logic. + private static func hoistInlineObjectIfPresent( + name: String, + schema: JSONSchema, + location: String + ) throws -> (type: IRType, hoisted: [IRSchema])? { + guard case .object(_, let objectContext) = schema.value, !objectContext.properties.isEmpty + else { + return nil + } + let (properties, nestedHoisted) = try parseObjectProperties( + name: name, objectContext: objectContext, location: location) + let hoisted = [IRSchema(name: name, kind: .object(properties: properties))] + nestedHoisted + return (.schemaRef(name), hoisted) + } + private static func unionCaseName(for type: IRType) -> String { switch type { case .string: return "string" @@ -299,15 +313,11 @@ public enum OpenAPIParsing { location: location, reason: "JSON request body without a schema") } if case .b(let inlineSchema) = schemaEither, - case .object(_, let objectContext) = inlineSchema.value, - !objectContext.properties.isEmpty + let (type, objectHoisted) = try hoistInlineObjectIfPresent( + name: "\(location)_requestBody", schema: inlineSchema, location: "\(location)_requestBody" + ) { - let hoistedName = "\(location)_requestBody" - let (properties, nestedHoisted) = try parseObjectProperties( - name: hoistedName, objectContext: objectContext, location: hoistedName) - let hoisted = - [IRSchema(name: hoistedName, kind: .object(properties: properties))] + nestedHoisted - return (.json(.schemaRef(hoistedName)), hoisted) + return (.json(type), objectHoisted) } if case .b(let inlineSchema) = schemaEither, let (type, hoistedSchema) = try hoistUnionIfPresent( @@ -389,15 +399,12 @@ public enum OpenAPIParsing { { guard let schemaEither = jsonContent.schema else { return (.none, []) } if case .b(let inlineSchema) = schemaEither, - case .object(_, let objectContext) = inlineSchema.value, - !objectContext.properties.isEmpty + let (type, objectHoisted) = try hoistInlineObjectIfPresent( + name: "\(operationId)_response\(statusCode)", + schema: inlineSchema, + location: "\(operationId)_response\(statusCode)") { - let hoistedName = "\(operationId)_response\(statusCode)" - let (properties, nestedHoisted) = try parseObjectProperties( - name: hoistedName, objectContext: objectContext, location: hoistedName) - let hoisted = - [IRSchema(name: hoistedName, kind: .object(properties: properties))] + nestedHoisted - return (.json(.schemaRef(hoistedName)), hoisted) + return (.json(type), objectHoisted) } if case .b(let inlineSchema) = schemaEither, let (type, hoistedSchema) = try hoistUnionIfPresent( From 6d9e110ae8cbb5402b25dad153eea3c93ddab15b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 21:36:02 -0300 Subject: [PATCH 35/44] refactor(codegen): use swift-argument-parser for the CLI --- tools/openapi-codegen/Package.resolved | 11 ++- tools/openapi-codegen/Package.swift | 6 +- .../Sources/openapi-codegen/main.swift | 78 ++++++++----------- 3 files changed, 46 insertions(+), 49 deletions(-) diff --git a/tools/openapi-codegen/Package.resolved b/tools/openapi-codegen/Package.resolved index 06d55d9cd..db5d61996 100644 --- a/tools/openapi-codegen/Package.resolved +++ b/tools/openapi-codegen/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "27b1f1c87d8f63db11d5c57414c1b5bd1aa188e072260a841c3626eed61728d8", + "originHash" : "b1130472818eb7b1dfc0fd74aca78c39f6f7abe1bf60dca06737e6063a8cc303", "pins" : [ { "identity" : "openapikit", @@ -10,6 +10,15 @@ "version" : "6.2.0" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", diff --git a/tools/openapi-codegen/Package.swift b/tools/openapi-codegen/Package.swift index 9356acf58..e0569efe7 100644 --- a/tools/openapi-codegen/Package.swift +++ b/tools/openapi-codegen/Package.swift @@ -10,6 +10,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "6.2.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), ], targets: [ .target( @@ -20,7 +21,10 @@ let package = Package( ), .executableTarget( name: "openapi-codegen", - dependencies: ["OpenAPICodegenCore"] + dependencies: [ + "OpenAPICodegenCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] ), .testTarget( name: "OpenAPICodegenCoreTests", diff --git a/tools/openapi-codegen/Sources/openapi-codegen/main.swift b/tools/openapi-codegen/Sources/openapi-codegen/main.swift index 60ca9800a..2849356b1 100644 --- a/tools/openapi-codegen/Sources/openapi-codegen/main.swift +++ b/tools/openapi-codegen/Sources/openapi-codegen/main.swift @@ -2,61 +2,45 @@ // main.swift // +import ArgumentParser import Foundation import OpenAPICodegenCore import OpenAPIKit30 -struct CLIError: Error, CustomStringConvertible { - var description: String -} +struct OpenAPICodegen: ParsableCommand { + @Option(help: "Path to the OpenAPI spec file.") + var spec: String -func parseArguments(_ arguments: [String]) throws -> (spec: URL, output: URL, module: String) { - var spec: String? - var output: String? - var module: String? - var index = 0 - while index < arguments.count { - switch arguments[index] { - case "--spec": - index += 1 - spec = arguments[index] - case "--output": - index += 1 - output = arguments[index] - case "--module": - index += 1 - module = arguments[index] - default: - throw CLIError(description: "unknown argument: \(arguments[index])") - } - index += 1 - } - guard let spec, let output, let module else { - throw CLIError( - description: "usage: openapi-codegen --spec --output --module ") - } - return ( - URL(fileURLWithPath: spec), URL(fileURLWithPath: output, isDirectory: true), module - ) -} + @Option(help: "Directory to write the generated Swift files into.") + var output: String -let (specURL, outputURL, moduleName) = try parseArguments(Array(CommandLine.arguments.dropFirst())) + @Option(help: "Name of the generated module (used to derive the client type name).") + var module: String -let data = try Data(contentsOf: specURL) -let document = try JSONDecoder().decode(OpenAPI.Document.self, from: data) -let irDocument = try OpenAPIParsing.parseDocument(document) + func run() throws { + let specURL = URL(fileURLWithPath: spec) + let outputURL = URL(fileURLWithPath: output, isDirectory: true) -try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + let data = try Data(contentsOf: specURL) + let document = try JSONDecoder().decode(OpenAPI.Document.self, from: data) + let irDocument = try OpenAPIParsing.parseDocument(document) -let models = SwiftEmitter.emitModels(irDocument) -try models.write( - to: outputURL.appendingPathComponent("Models.swift"), atomically: true, encoding: .utf8) + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) -let clientName = "\(moduleName)Client" -let client = SwiftEmitter.emitClient(irDocument, clientName: clientName) -try client.write( - to: outputURL.appendingPathComponent("\(clientName).swift"), atomically: true, encoding: .utf8) + let models = SwiftEmitter.emitModels(irDocument) + try models.write( + to: outputURL.appendingPathComponent("Models.swift"), atomically: true, encoding: .utf8) + + let clientName = "\(module)Client" + let client = SwiftEmitter.emitClient(irDocument, clientName: clientName) + try client.write( + to: outputURL.appendingPathComponent("\(clientName).swift"), atomically: true, + encoding: .utf8) + + print( + "Generated \(irDocument.schemas.count) schemas and \(irDocument.operations.count) operations into \(outputURL.path)" + ) + } +} -print( - "Generated \(irDocument.schemas.count) schemas and \(irDocument.operations.count) operations into \(outputURL.path)" -) +OpenAPICodegen.main() From 29d9e116e134868294a9ca5607f6844074e58c75 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 21:48:59 -0300 Subject: [PATCH 36/44] fix(codegen): set explicit ArgumentParser command name ArgumentParser's default kebab-casing of "OpenAPICodegen" produces "open-api-codegen", which doesn't match the actual executable name and would mislead anyone who copies the --help/error text verbatim. --- tools/openapi-codegen/Sources/openapi-codegen/main.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/openapi-codegen/Sources/openapi-codegen/main.swift b/tools/openapi-codegen/Sources/openapi-codegen/main.swift index 2849356b1..ef220d3e5 100644 --- a/tools/openapi-codegen/Sources/openapi-codegen/main.swift +++ b/tools/openapi-codegen/Sources/openapi-codegen/main.swift @@ -8,6 +8,8 @@ import OpenAPICodegenCore import OpenAPIKit30 struct OpenAPICodegen: ParsableCommand { + static let configuration = CommandConfiguration(commandName: "openapi-codegen") + @Option(help: "Path to the OpenAPI spec file.") var spec: String From 5200e95b5dd1c8e88e9a68b03b42c31f1b92ad11 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 05:24:36 -0300 Subject: [PATCH 37/44] style: format openapi-codegen --- .../Sources/OpenAPICodegenCore/OpenAPIParsing.swift | 10 +++++++--- .../ParameterParsingTests.swift | 6 ++++-- .../OpenAPICodegenCoreTests/TypeParsingTests.swift | 3 ++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift index 81a783be1..e59159bde 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/OpenAPIParsing.swift @@ -381,7 +381,8 @@ public enum OpenAPIParsing { location: location, reason: "external response reference for status \(code)") } let (body, bodyHoisted) = try parseResponseBody( - response.content, operationId: location, statusCode: code, location: "\(location) -> \(code)") + response.content, operationId: location, statusCode: code, + location: "\(location) -> \(code)") hoisted.append(contentsOf: bodyHoisted) results.append(IRResponse(statusCode: code, isError: !statusCode.isSuccess, body: body)) } @@ -452,7 +453,8 @@ public enum OpenAPIParsing { } var parameters: [IRParameter] = [] for parameterEither in pathItem.parameters + operation.parameters { - let (parameter, parameterHoisted) = try parseParameter(parameterEither, location: operationId) + let (parameter, parameterHoisted) = try parseParameter( + parameterEither, location: operationId) parameters.append(parameter) hoisted.append(contentsOf: parameterHoisted) } @@ -484,7 +486,9 @@ public enum OpenAPIParsing { public static func parseDocument(_ document: OpenAPI.Document) throws -> IRDocument { var schemas: [IRSchema] = [] - for (key, schema) in document.components.schemas.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { + for (key, schema) in document.components.schemas.sorted(by: { + $0.key.rawValue < $1.key.rawValue + }) { schemas.append(contentsOf: try parseNamedSchema(name: key.rawValue, schema: schema)) } let (operations, operationHoisted) = try parseOperations(document) diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift index f2de7f367..934208bcd 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ParameterParsingTests.swift @@ -71,7 +71,8 @@ struct ParameterParsingTests { let json = """ {"name": "resize", "in": "query", "schema": {"type": "string", "enum": ["cover", "contain", "fill"]}} """ - let (irParameter, hoisted) = try OpenAPIParsing.parseParameter(parameter(json), location: "renderImagePublic") + let (irParameter, hoisted) = try OpenAPIParsing.parseParameter( + parameter(json), location: "renderImagePublic") #expect(irParameter.name == "resize") #expect(irParameter.type == .schemaRef("renderImagePublic_resize")) @@ -95,7 +96,8 @@ struct ParameterParsingTests { } } """ - let (irParameter, hoisted) = try OpenAPIParsing.parseParameter(parameter(json), location: "listObjects") + let (irParameter, hoisted) = try OpenAPIParsing.parseParameter( + parameter(json), location: "listObjects") #expect(irParameter.name == "paths") #expect(irParameter.type == .array(.schemaRef("listObjects_pathsItem"))) diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift index 145ccf765..c711ce5e6 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/TypeParsingTests.swift @@ -70,7 +70,8 @@ struct TypeParsingTests { @Test func parsesTypelessFragmentSchemaAsFreeform() throws { - let type = try OpenAPIParsing.parseType(schema(#"{"description": "Default Response"}"#), location: "test") + let type = try OpenAPIParsing.parseType( + schema(#"{"description": "Default Response"}"#), location: "test") #expect(type == .freeform) } } From 3da0c8a3561e08533d03b5ddb15bebf04dd323f2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 05:25:48 -0300 Subject: [PATCH 38/44] publicaly import FoundationNetworking --- Sources/HTTPRuntime/URLSessionTransport.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/HTTPRuntime/URLSessionTransport.swift b/Sources/HTTPRuntime/URLSessionTransport.swift index 7761acc3e..9e4eda714 100644 --- a/Sources/HTTPRuntime/URLSessionTransport.swift +++ b/Sources/HTTPRuntime/URLSessionTransport.swift @@ -7,7 +7,7 @@ public import Foundation #if canImport(FoundationNetworking) - import FoundationNetworking + public import FoundationNetworking #endif /// The default, zero-dependency `HTTPTransport` backed by `URLSession`. From 75ed2696e9ded878ebe85172d6cd469db04d46b8 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 05:27:28 -0300 Subject: [PATCH 39/44] ci: drop PR title subject requirement --- .github/workflows/conventional-commits.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml index f26840827..665730ebf 100644 --- a/.github/workflows/conventional-commits.yml +++ b/.github/workflows/conventional-commits.yml @@ -20,9 +20,3 @@ jobs: - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - subjectPattern: ^(?![A-Z]).+$ - subjectPatternError: | - The subject "{subject}" found in the pull request title "{title}" - didn't match the configured pattern. Please ensure that the subject - doesn't start with an uppercase character. From 52b0f6a55351485650ef1833cb0cc242678a0142 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 05:43:14 -0300 Subject: [PATCH 40/44] do not expose HTTPRuntime publically --- Sources/HTTPRuntime/HTTPError.swift | 8 +- Sources/HTTPRuntime/HTTPMethod.swift | 2 +- Sources/HTTPRuntime/HTTPRequest.swift | 37 ++- Sources/HTTPRuntime/HTTPResponse.swift | 34 +- Sources/HTTPRuntime/HTTPTransport.swift | 4 +- Sources/HTTPRuntime/JSONCoding.swift | 10 +- Sources/HTTPRuntime/JSONValue.swift | 6 +- Sources/HTTPRuntime/MultipartFormData.swift | 294 ++++++++++++++---- Sources/HTTPRuntime/PathEncoding.swift | 6 +- Sources/HTTPRuntime/ServerSentEvents.swift | 97 ------ Sources/HTTPRuntime/URLSessionTransport.swift | 18 +- 11 files changed, 297 insertions(+), 219 deletions(-) delete mode 100644 Sources/HTTPRuntime/ServerSentEvents.swift diff --git a/Sources/HTTPRuntime/HTTPError.swift b/Sources/HTTPRuntime/HTTPError.swift index dae456220..9f6fd51f0 100644 --- a/Sources/HTTPRuntime/HTTPError.swift +++ b/Sources/HTTPRuntime/HTTPError.swift @@ -4,11 +4,11 @@ // // Created by Guilherme Souza on 08/07/26. // -public import Foundation +package import Foundation /// Errors surfaced by the runtime itself (transport/encoding/decoding), as /// distinct from typed API errors decoded from a response body. -public enum HTTPError: Error, Sendable { +package enum HTTPError: Error, Sendable { case invalidURL(base: URL, path: String) case transport(any Error) case decoding(any Error) @@ -19,14 +19,14 @@ public enum HTTPError: Error, Sendable { /// Marker protocol for generated, typed API errors decoded from a response /// body for a known status code. -public protocol APIError: Error, Sendable, Decodable {} +package protocol APIError: Error, Sendable, Decodable {} extension HTTPResponse { /// Validates the status code, decoding a modeled error when the status /// matches one of the provided error types. Generated code passes the /// `status -> ErrorType` table declared by the operation's Smithy/TypeSpec /// error bindings. - public func checkStatus(errorTypes: [Int: any APIError.Type]) throws { + package func checkStatus(errorTypes: [Int: any APIError.Type]) throws { guard !isSuccess else { return } if let errorType = errorTypes[status] { do { diff --git a/Sources/HTTPRuntime/HTTPMethod.swift b/Sources/HTTPRuntime/HTTPMethod.swift index f31ddfe00..242b22fb7 100644 --- a/Sources/HTTPRuntime/HTTPMethod.swift +++ b/Sources/HTTPRuntime/HTTPMethod.swift @@ -7,7 +7,7 @@ // Part of the internal HTTP runtime. NEVER exposed as public SDK surface. /// HTTP verbs supported by the generated operations. -public enum HTTPMethod: String, Sendable, Hashable { +package enum HTTPMethod: String, Sendable, Hashable { case get = "GET" case post = "POST" case put = "PUT" diff --git a/Sources/HTTPRuntime/HTTPRequest.swift b/Sources/HTTPRuntime/HTTPRequest.swift index fa2515952..4b60acf26 100644 --- a/Sources/HTTPRuntime/HTTPRequest.swift +++ b/Sources/HTTPRuntime/HTTPRequest.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -public import Foundation +package import Foundation /// The body of an outgoing request. /// @@ -12,25 +12,24 @@ public import Foundation /// memory: `URLSession` streams the file from disk. `.multipart` assembles a /// `multipart/form-data` body onto a temporary file and then uploads that file, /// so even large multipart parts never materialize fully in memory. -public enum HTTPBody: Sendable { - case none +package enum HTTPBody: Sendable { case data(Data) case file(URL) case multipart(MultipartFormData) } /// A fully-resolved HTTP request: absolute URL, headers, and body. -public struct HTTPRequest: Sendable { - public var method: HTTPMethod - public var url: URL - public var headers: [String: String] - public var body: HTTPBody +package struct HTTPRequest: Sendable { + package var method: HTTPMethod + package var url: URL + package var headers: [String: String] + package var body: HTTPBody? - public init( + package init( method: HTTPMethod, url: URL, headers: [String: String] = [:], - body: HTTPBody = .none + body: HTTPBody? = nil ) { self.method = method self.url = url @@ -45,42 +44,42 @@ public struct HTTPRequest: Sendable { /// Generated code drives this builder; it never constructs `URLComponents` /// directly. Query values use repeated-key encoding (`?k=a&k=b`) to match the /// Smithy/OpenAPI list conventions in the specs. -public struct HTTPRequestBuilder: Sendable { - public let method: HTTPMethod +package struct HTTPRequestBuilder: Sendable { + private let method: HTTPMethod private let baseURL: URL private let path: String private var queryItems: [URLQueryItem] = [] private var headers: [String: String] = [:] - private var body: HTTPBody = .none + private var body: HTTPBody? = nil - public init(method: HTTPMethod, baseURL: URL, path: String) { + package init(method: HTTPMethod, baseURL: URL, path: String) { self.method = method self.baseURL = baseURL self.path = path } - public mutating func addQuery(_ name: String, _ value: String?) { + package mutating func addQuery(_ name: String, _ value: String?) { guard let value else { return } queryItems.append(URLQueryItem(name: name, value: value)) } - public mutating func addQuery(_ name: String, _ values: [String]?) { + package mutating func addQuery(_ name: String, _ values: [String]?) { guard let values else { return } for value in values { queryItems.append(URLQueryItem(name: name, value: value)) } } - public mutating func setHeader(_ name: String, _ value: String?) { + package mutating func setHeader(_ name: String, _ value: String?) { guard let value else { return } headers[name] = value } - public mutating func setBody(_ body: HTTPBody) { + package mutating func setBody(_ body: HTTPBody?) { self.body = body } - public func build() throws -> HTTPRequest { + package func build() throws -> HTTPRequest { // Compose by string so slashes inside greedy path params ({path+}) are // preserved. Generated code percent-encodes individual label values. var base = baseURL.absoluteString diff --git a/Sources/HTTPRuntime/HTTPResponse.swift b/Sources/HTTPRuntime/HTTPResponse.swift index 6d1c2b8a2..788ed2056 100644 --- a/Sources/HTTPRuntime/HTTPResponse.swift +++ b/Sources/HTTPRuntime/HTTPResponse.swift @@ -4,54 +4,50 @@ // // Created by Guilherme Souza on 08/07/26. // -public import Foundation +package import Foundation /// The status line and headers of a response, without the body. /// /// Used on its own for streaming responses (where the body is delivered /// separately as an `AsyncSequence`) and embedded in ``HTTPResponse`` for /// buffered responses. -public struct HTTPResponseHead: Sendable { - public let status: Int - public let headers: [String: String] +package struct HTTPResponseHead: Sendable { + package let status: Int + package let headers: [String: String] - public init(status: Int, headers: [String: String]) { + package init(status: Int, headers: [String: String]) { self.status = status self.headers = headers } - public func header(_ name: String) -> String? { + package func header(_ name: String) -> String? { // HTTP header names are case-insensitive. if let exact = headers[name] { return exact } let lowered = name.lowercased() return headers.first { $0.key.lowercased() == lowered }?.value } - public var isSuccess: Bool { (200..<300).contains(status) } + package var isSuccess: Bool { (200..<300).contains(status) } } /// A fully-buffered response. -public struct HTTPResponse: Sendable { - public let head: HTTPResponseHead - public let body: Data +package struct HTTPResponse: Sendable { + package let head: HTTPResponseHead + package let body: Data - public init(head: HTTPResponseHead, body: Data) { + package init(head: HTTPResponseHead, body: Data) { self.head = head self.body = body } - - public var status: Int { head.status } - public func header(_ name: String) -> String? { head.header(name) } - public var isSuccess: Bool { head.isSuccess } } /// A streaming response: the head arrives first, the body is an async sequence /// of `Data` chunks (used for large downloads and event streams). -public struct HTTPResponseStream: Sendable { - public let head: HTTPResponseHead - public let body: AsyncThrowingStream +package struct HTTPResponseStream: Sendable { + package let head: HTTPResponseHead + package let body: AsyncThrowingStream - public init(head: HTTPResponseHead, body: AsyncThrowingStream) { + package init(head: HTTPResponseHead, body: AsyncThrowingStream) { self.head = head self.body = body } diff --git a/Sources/HTTPRuntime/HTTPTransport.swift b/Sources/HTTPRuntime/HTTPTransport.swift index 1f2118b6c..f9760e28f 100644 --- a/Sources/HTTPRuntime/HTTPTransport.swift +++ b/Sources/HTTPRuntime/HTTPTransport.swift @@ -7,7 +7,7 @@ /// The abstraction generated clients depend on. Kept deliberately small so the /// generated code never touches `URLSession` directly and so tests can inject a /// mock transport. -public protocol HTTPTransport: Sendable { +package protocol HTTPTransport: Sendable { /// Buffered request/response. func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws -> HTTPResponse @@ -17,7 +17,7 @@ public protocol HTTPTransport: Sendable { } extension HTTPTransport { - public func send(_ request: HTTPRequest) async throws -> HTTPResponse { + package func send(_ request: HTTPRequest) async throws -> HTTPResponse { try await send(request, uploadProgress: nil) } } diff --git a/Sources/HTTPRuntime/JSONCoding.swift b/Sources/HTTPRuntime/JSONCoding.swift index 81a829084..b7b25262b 100644 --- a/Sources/HTTPRuntime/JSONCoding.swift +++ b/Sources/HTTPRuntime/JSONCoding.swift @@ -4,12 +4,12 @@ // // Created by Guilherme Souza on 08/07/26. // -public import Foundation +package import Foundation /// Shared JSON coders used by generated code. Dates are ISO-8601 with /// fractional seconds, matching the mock server and the spec timestamps. -public enum JSONCoding { - public static let encoder: JSONEncoder = { +package enum JSONCoding { + package static let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .custom { date, enc in var container = enc.singleValueContainer() @@ -18,7 +18,7 @@ public enum JSONCoding { return encoder }() - public static let decoder: JSONDecoder = { + package static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { dec in let container = try dec.singleValueContainer() @@ -35,7 +35,7 @@ public enum JSONCoding { }() /// ISO-8601 string with fractional seconds, for `@httpQuery` timestamp params. - public static func iso8601String(_ date: Date) -> String { + package static func iso8601String(_ date: Date) -> String { iso8601.string(from: date) } diff --git a/Sources/HTTPRuntime/JSONValue.swift b/Sources/HTTPRuntime/JSONValue.swift index 8c4ba4d67..0a2fb4bd4 100644 --- a/Sources/HTTPRuntime/JSONValue.swift +++ b/Sources/HTTPRuntime/JSONValue.swift @@ -7,7 +7,7 @@ import Foundation /// A free-form JSON value, used for spec `document` / `Record` types. -public enum JSONValue: Codable, Sendable, Hashable { +package enum JSONValue: Codable, Sendable, Hashable { case null case bool(Bool) case number(Double) @@ -15,7 +15,7 @@ public enum JSONValue: Codable, Sendable, Hashable { case array([JSONValue]) case object([String: JSONValue]) - public init(from decoder: any Decoder) throws { + package init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { self = .null @@ -37,7 +37,7 @@ public enum JSONValue: Codable, Sendable, Hashable { } } - public func encode(to encoder: any Encoder) throws { + package func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .null: try container.encodeNil() diff --git a/Sources/HTTPRuntime/MultipartFormData.swift b/Sources/HTTPRuntime/MultipartFormData.swift index 9da8df0c5..6abf26401 100644 --- a/Sources/HTTPRuntime/MultipartFormData.swift +++ b/Sources/HTTPRuntime/MultipartFormData.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -public import Foundation +import Foundation /// Builds a `multipart/form-data` body by streaming its parts onto a temporary /// file, so large file parts never load fully into memory. The resulting file @@ -13,80 +13,260 @@ public import Foundation /// This is the runtime's answer to "streaming multipart upload of large files /// without loading into memory": file parts are copied chunk-by-chunk to the /// staging file. -public struct MultipartFormData: Sendable { - public struct Part: Sendable { - public enum Source: Sendable { - case data(Data) - case file(URL) - } - public var name: String - public var filename: String? - public var contentType: String? - public var source: Source - - public init(name: String, filename: String? = nil, contentType: String? = nil, source: Source) { - self.name = name - self.filename = filename - self.contentType = contentType - self.source = source - } - } +package struct MultipartFormData: Sendable { + let boundary: String + private var parts: [Part] = [] - public let boundary: String - public private(set) var parts: [Part] + var contentType: String { "multipart/form-data; boundary=\(boundary)" } + + enum Part { + case text(name: String, value: String) + case data(name: String, data: Data, fileName: String?, mimeType: String?) + case file(name: String, fileURL: URL, fileName: String?, mimeType: String?) + } - public init(boundary: String = "Boundary-\(UUID().uuidString)", parts: [Part] = []) { + init(boundary: String = "----sb-\(UUID().uuidString)") { self.boundary = boundary - self.parts = parts } - public mutating func append(_ part: Part) { - parts.append(part) + /// Add a text field to the multipart payload + func addText(name: String, value: String) -> MultipartFormData { + var builder = self + builder.parts.append(.text(name: name, value: value)) + return builder } - public var contentType: String { - "multipart/form-data; boundary=\(boundary)" + /// Add an optional text field (only adds if value is non-nil) + func addOptionalText(name: String, value: String?) -> MultipartFormData { + if let value = value { + return addText(name: name, value: value) + } + return self + } + + /// Add a data field to the multipart payload. + func addData( + name: String, + data: Data, + fileName: String? = nil, + mimeType: String? = nil + ) -> MultipartFormData { + var builder = self + builder.parts.append(.data(name: name, data: data, fileName: fileName, mimeType: mimeType)) + return builder } - /// Streams all parts to a temporary file and returns its URL. The caller is - /// responsible for deleting the file after the upload completes. - public func writeToTemporaryFile() throws -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("multipart-\(UUID().uuidString).tmp") - FileManager.default.createFile(atPath: url.path, contents: nil) - let handle = try FileHandle(forWritingTo: url) + /// Add a file field to the multipart payload (loads entire file into memory) + func addFile( + name: String, + fileURL: URL, + fileName: String? = nil, + mimeType: String? = nil + ) -> MultipartFormData { + var builder = self + builder.parts.append( + .file(name: name, fileURL: fileURL, fileName: fileName, mimeType: mimeType)) + return builder + } + + /// Build the multipart payload in memory + /// - Note: Only suitable for small payloads. Use `buildToTempFile()` for large files. + /// - Returns: Complete multipart body data + func buildInMemory() throws -> Data { + guard !parts.isEmpty else { return Data() } + + var body = Data() + + for part in parts { + switch part { + case .text(let name, let value): + body.append(textPart(name: name, value: value)) + case .data(let name, let data, let fileName, let mimeType): + body.append(dataPart(name: name, data: data, fileName: fileName, mimeType: mimeType)) + case .file(let name, let fileURL, let fileName, let mimeType): + body.append( + try filePart(name: name, fileURL: fileURL, fileName: fileName, mimeType: mimeType) + ) + } + } + + body.append(closingBoundary()) + return body + } + + /// Build the multipart payload to a temporary file + /// - Note: Streams file contents to avoid memory pressure on large files + /// - Returns: URL of temporary file containing multipart body + func buildToTempFile() throws -> URL { + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent(UUID().uuidString) + + // Create temp file + guard FileManager.default.createFile(atPath: tempFile.path, contents: nil) else { + throw MultipartFormatDataError.createTempFileFailed + } + + var didReturnTempFile = false + defer { + if !didReturnTempFile { + try? FileManager.default.removeItem(at: tempFile) + } + } + + guard let handle = FileHandle(forWritingAtPath: tempFile.path) else { + throw MultipartFormatDataError.openTempFileFailed + } + defer { try? handle.close() } - func write(_ string: String) throws { - try handle.write(contentsOf: Data(string.utf8)) + guard !parts.isEmpty else { + didReturnTempFile = true + return tempFile } + // Write parts for part in parts { - try write("--\(boundary)\r\n") - var disposition = "Content-Disposition: form-data; name=\"\(part.name)\"" - if let filename = part.filename { - disposition += "; filename=\"\(filename)\"" - } - try write(disposition + "\r\n") - if let contentType = part.contentType { - try write("Content-Type: \(contentType)\r\n") + switch part { + case .text(let name, let value): + let data = textPart(name: name, value: value) + try handle.write(contentsOf: data) + + case .data(let name, let data, let fileName, let mimeType): + try handle.write(contentsOf: partHeader(name: name, fileName: fileName, mimeType: mimeType)) + try handle.write(contentsOf: data) + try handle.write(contentsOf: Data("\r\n".utf8)) + + case .file(let name, let fileURL, let fileName, let mimeType): + // Write file part header + let header = partHeader( + name: name, + fileName: fileName ?? fileURL.lastPathComponent, + mimeType: mimeType + ) + try handle.write(contentsOf: header) + + // Stream file contents in chunks + try streamFile(from: fileURL, to: handle) + + // Write trailing newline + try handle.write(contentsOf: Data("\r\n".utf8)) } - try write("\r\n") + } + + // Write closing boundary + try handle.write(contentsOf: closingBoundary()) + + didReturnTempFile = true + return tempFile + } + + // MARK: - Private Helpers + + private func textPart(name: String, value: String) -> Data { + var data = Data() + data.append(Data("--\(boundary)\r\n".utf8)) + data.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n".utf8)) + data.append(Data("\r\n".utf8)) + data.append(Data("\(value)\r\n".utf8)) + return data + } + + private func dataPart( + name: String, + data: Data, + fileName: String?, + mimeType: String? + ) -> Data { + var partData = Data() + partData.append(partHeader(name: name, fileName: fileName, mimeType: mimeType)) + partData.append(data) + partData.append(Data("\r\n".utf8)) + return partData + } + + private func filePart( + name: String, + fileURL: URL, + fileName: String?, + mimeType: String? + ) throws -> Data { + var data = Data() + + data.append( + partHeader(name: name, fileName: fileName ?? fileURL.lastPathComponent, mimeType: mimeType) + ) + + let fileData = try Data(contentsOf: fileURL) + data.append(fileData) + + data.append(Data("\r\n".utf8)) + + return data + } + + private func partHeader(name: String, fileName: String?, mimeType: String?) -> Data { + var header = Data() + header.append(Data("--\(boundary)\r\n".utf8)) + var disposition = "Content-Disposition: form-data; name=\"\(name)\"" + if let fileName { + disposition.append("; filename=\"\(fileName)\"") + } + header.append(Data("\(disposition)\r\n".utf8)) - switch part.source { - case .data(let data): + if let mimeType = mimeType { + header.append(Data("Content-Type: \(mimeType)\r\n".utf8)) + } + + header.append(Data("\r\n".utf8)) + return header + } + + private func closingBoundary() -> Data { + return Data("--\(boundary)--\r\n".utf8) + } + + private func streamFile(from url: URL, to handle: FileHandle) throws { + guard let input = InputStream(url: url) else { + throw MultipartFormatDataError.openInputStreamFailed + } + + input.open() + defer { input.close() } + + let bufferSize = 64 * 1024 // 64KB chunks + var buffer = [UInt8](repeating: 0, count: bufferSize) + + while input.hasBytesAvailable { + let bytesRead = input.read(&buffer, maxLength: bufferSize) + if bytesRead > 0 { + let data = Data(bytes: buffer, count: bytesRead) try handle.write(contentsOf: data) - case .file(let fileURL): - let reader = try FileHandle(forReadingFrom: fileURL) - defer { try? reader.close() } - // Copy in bounded chunks so a large file never fully buffers. - while case let chunk = reader.readData(ofLength: 64 * 1024), !chunk.isEmpty { - try handle.write(contentsOf: chunk) - } + } else if bytesRead < 0 { + throw MultipartFormatDataError.readInputStreamFailed(underlying: input.streamError) } - try write("\r\n") } - try write("--\(boundary)--\r\n") - return url + } +} + +// MARK: - Errors + +/// Errors that can occur during multipart building operations. +package enum MultipartFormatDataError: LocalizedError { + case createTempFileFailed + case openTempFileFailed + case openInputStreamFailed + case readInputStreamFailed(underlying: (any Error)?) + + package var errorDescription: String? { + switch self { + case .createTempFileFailed: + return "Failed to create temp file" + case .openTempFileFailed: + return "Failed to create temp file" + case .openInputStreamFailed: + return "Failed to open file for reading" + case .readInputStreamFailed: + return "Error reading file" + } } } diff --git a/Sources/HTTPRuntime/PathEncoding.swift b/Sources/HTTPRuntime/PathEncoding.swift index eb0d9ca14..4286447ef 100644 --- a/Sources/HTTPRuntime/PathEncoding.swift +++ b/Sources/HTTPRuntime/PathEncoding.swift @@ -8,7 +8,7 @@ import Foundation /// Percent-encoding for URL path parameters. Generated code calls these when /// substituting `@httpLabel` values into a path template. -public enum PathEncoding { +package enum PathEncoding { private static let segmentAllowed: CharacterSet = { var set = CharacterSet.urlPathAllowed set.remove("/") // a single path segment must escape slashes @@ -16,12 +16,12 @@ public enum PathEncoding { }() /// Encodes a single path segment (escapes `/`). - public static func segment(_ value: String) -> String { + package static func segment(_ value: String) -> String { value.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? value } /// Encodes a greedy label (`{path+}`) that may legitimately contain `/`. - public static func greedy(_ value: String) -> String { + package static func greedy(_ value: String) -> String { value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value } } diff --git a/Sources/HTTPRuntime/ServerSentEvents.swift b/Sources/HTTPRuntime/ServerSentEvents.swift deleted file mode 100644 index 121e6f5c1..000000000 --- a/Sources/HTTPRuntime/ServerSentEvents.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// ServerSentEvents.swift -// HTTPRuntime -// -// Created by Guilherme Souza on 08/07/26. -// -public import Foundation - -/// A single Server-Sent Event frame (`text/event-stream`). -public struct ServerSentEvent: Sendable, Hashable { - public var event: String? - public var data: String - public var id: String? - public var retry: Int? - - public init(event: String? = nil, data: String, id: String? = nil, retry: Int? = nil) { - self.event = event - self.data = data - self.id = id - self.retry = retry - } -} - -extension AsyncThrowingStream where Element == Data, Failure == any Error { - /// Parses this stream of raw byte chunks into SSE frames, splitting on the - /// blank-line frame delimiter and coalescing multi-line `data:` fields per - /// the SSE spec. Generated code maps each frame's `data` (JSON) into the - /// operation's typed event union. - public func serverSentEvents() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - var buffer = Data() - do { - for try await chunk in self { - buffer.append(chunk) - while let frame = Self.extractFrame(&buffer) { - if let event = Self.parseFrame(frame) { - continuation.yield(event) - } - } - } - // Flush any trailing frame without a terminating blank line. - if !buffer.isEmpty, let event = Self.parseFrame(buffer) { - continuation.yield(event) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in task.cancel() } - } - } - - /// Pulls one complete frame (bytes up to and including `\n\n`) out of the - /// buffer, or returns nil if no full frame is buffered yet. - private static func extractFrame(_ buffer: inout Data) -> Data? { - let delimiter = Data([0x0A, 0x0A]) // "\n\n" - guard let range = buffer.range(of: delimiter) else { return nil } - let frame = buffer.subdata(in: buffer.startIndex.. ServerSentEvent? { - guard let text = String(data: frame, encoding: .utf8) else { return nil } - var event: String? - var id: String? - var retry: Int? - var dataLines: [String] = [] - for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { - let line = rawLine.hasSuffix("\r") ? String(rawLine.dropLast()) : String(rawLine) - if line.isEmpty || line.hasPrefix(":") { continue } // comment / blank - let field: Substring - let value: String - if let colon = line.firstIndex(of: ":") { - field = line[line.startIndex.. HTTPResponse { let urlRequest = try Self.makeURLRequest(request) @@ -44,7 +44,7 @@ public struct URLSessionTransport: HTTPTransport { let response: URLResponse do { switch request.body { - case .none: + case nil: (data, response) = try await session.data(for: urlRequest, delegate: delegate) case .data(let payload): (data, response) = try await session.upload( @@ -53,7 +53,7 @@ public struct URLSessionTransport: HTTPTransport { (data, response) = try await session.upload( for: urlRequest, fromFile: fileURL, delegate: delegate) case .multipart(let form): - let fileURL = try form.writeToTemporaryFile() + let fileURL = try form.buildToTempFile() defer { try? FileManager.default.removeItem(at: fileURL) } var withBoundary = urlRequest withBoundary.setValue(form.contentType, forHTTPHeaderField: "Content-Type") @@ -66,7 +66,7 @@ public struct URLSessionTransport: HTTPTransport { return HTTPResponse(head: Self.makeHead(response), body: data) } - public func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { + package func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { let urlRequest = try Self.makeURLRequest(request) let bytes: URLSession.AsyncBytes let response: URLResponse From 9f4f54a5ca171275e9314152ef4f79bc5a3c23ad Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 06:02:23 -0300 Subject: [PATCH 41/44] fix(runtime): repair checkStatus, multipart access levels, and restore deleted SSE parser after the package-scoping edit The manual public->package access-control pass left HTTPRuntime non-compiling: checkStatus still referenced HTTPResponse's now-removed bare status/isSuccess (now only on .head), MultipartFormData's new builder API members defaulted to internal instead of package, and ServerSentEvents.swift was deleted outright rather than rescoped, breaking SSE stream parsing entirely. --- Sources/HTTPRuntime/HTTPError.swift | 6 +- Sources/HTTPRuntime/MultipartFormData.swift | 18 ++-- Sources/HTTPRuntime/ServerSentEvents.swift | 97 +++++++++++++++++++ Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift | 14 ++- 4 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 Sources/HTTPRuntime/ServerSentEvents.swift diff --git a/Sources/HTTPRuntime/HTTPError.swift b/Sources/HTTPRuntime/HTTPError.swift index 9f6fd51f0..f740aeef3 100644 --- a/Sources/HTTPRuntime/HTTPError.swift +++ b/Sources/HTTPRuntime/HTTPError.swift @@ -27,8 +27,8 @@ extension HTTPResponse { /// `status -> ErrorType` table declared by the operation's Smithy/TypeSpec /// error bindings. package func checkStatus(errorTypes: [Int: any APIError.Type]) throws { - guard !isSuccess else { return } - if let errorType = errorTypes[status] { + guard !head.isSuccess else { return } + if let errorType = errorTypes[head.status] { do { let decoded = try JSONCoding.decoder.decode(errorType, from: body) throw decoded @@ -38,6 +38,6 @@ extension HTTPResponse { throw HTTPError.decoding(error) } } - throw HTTPError.unexpectedStatus(status: status, body: body) + throw HTTPError.unexpectedStatus(status: head.status, body: body) } } diff --git a/Sources/HTTPRuntime/MultipartFormData.swift b/Sources/HTTPRuntime/MultipartFormData.swift index 6abf26401..9c65d7899 100644 --- a/Sources/HTTPRuntime/MultipartFormData.swift +++ b/Sources/HTTPRuntime/MultipartFormData.swift @@ -4,7 +4,7 @@ // // Created by Guilherme Souza on 08/07/26. // -import Foundation +package import Foundation /// Builds a `multipart/form-data` body by streaming its parts onto a temporary /// file, so large file parts never load fully into memory. The resulting file @@ -17,7 +17,7 @@ package struct MultipartFormData: Sendable { let boundary: String private var parts: [Part] = [] - var contentType: String { "multipart/form-data; boundary=\(boundary)" } + package var contentType: String { "multipart/form-data; boundary=\(boundary)" } enum Part { case text(name: String, value: String) @@ -25,19 +25,19 @@ package struct MultipartFormData: Sendable { case file(name: String, fileURL: URL, fileName: String?, mimeType: String?) } - init(boundary: String = "----sb-\(UUID().uuidString)") { + package init(boundary: String = "----sb-\(UUID().uuidString)") { self.boundary = boundary } /// Add a text field to the multipart payload - func addText(name: String, value: String) -> MultipartFormData { + package func addText(name: String, value: String) -> MultipartFormData { var builder = self builder.parts.append(.text(name: name, value: value)) return builder } /// Add an optional text field (only adds if value is non-nil) - func addOptionalText(name: String, value: String?) -> MultipartFormData { + package func addOptionalText(name: String, value: String?) -> MultipartFormData { if let value = value { return addText(name: name, value: value) } @@ -45,7 +45,7 @@ package struct MultipartFormData: Sendable { } /// Add a data field to the multipart payload. - func addData( + package func addData( name: String, data: Data, fileName: String? = nil, @@ -57,7 +57,7 @@ package struct MultipartFormData: Sendable { } /// Add a file field to the multipart payload (loads entire file into memory) - func addFile( + package func addFile( name: String, fileURL: URL, fileName: String? = nil, @@ -72,7 +72,7 @@ package struct MultipartFormData: Sendable { /// Build the multipart payload in memory /// - Note: Only suitable for small payloads. Use `buildToTempFile()` for large files. /// - Returns: Complete multipart body data - func buildInMemory() throws -> Data { + package func buildInMemory() throws -> Data { guard !parts.isEmpty else { return Data() } var body = Data() @@ -97,7 +97,7 @@ package struct MultipartFormData: Sendable { /// Build the multipart payload to a temporary file /// - Note: Streams file contents to avoid memory pressure on large files /// - Returns: URL of temporary file containing multipart body - func buildToTempFile() throws -> URL { + package func buildToTempFile() throws -> URL { let tempDir = FileManager.default.temporaryDirectory let tempFile = tempDir.appendingPathComponent(UUID().uuidString) diff --git a/Sources/HTTPRuntime/ServerSentEvents.swift b/Sources/HTTPRuntime/ServerSentEvents.swift new file mode 100644 index 000000000..724b5bae3 --- /dev/null +++ b/Sources/HTTPRuntime/ServerSentEvents.swift @@ -0,0 +1,97 @@ +// +// ServerSentEvents.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +package import Foundation + +/// A single Server-Sent Event frame (`text/event-stream`). +package struct ServerSentEvent: Sendable, Hashable { + package var event: String? + package var data: String + package var id: String? + package var retry: Int? + + package init(event: String? = nil, data: String, id: String? = nil, retry: Int? = nil) { + self.event = event + self.data = data + self.id = id + self.retry = retry + } +} + +extension AsyncThrowingStream where Element == Data, Failure == any Error { + /// Parses this stream of raw byte chunks into SSE frames, splitting on the + /// blank-line frame delimiter and coalescing multi-line `data:` fields per + /// the SSE spec. Generated code maps each frame's `data` (JSON) into the + /// operation's typed event union. + package func serverSentEvents() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + var buffer = Data() + do { + for try await chunk in self { + buffer.append(chunk) + while let frame = Self.extractFrame(&buffer) { + if let event = Self.parseFrame(frame) { + continuation.yield(event) + } + } + } + // Flush any trailing frame without a terminating blank line. + if !buffer.isEmpty, let event = Self.parseFrame(buffer) { + continuation.yield(event) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Pulls one complete frame (bytes up to and including `\n\n`) out of the + /// buffer, or returns nil if no full frame is buffered yet. + private static func extractFrame(_ buffer: inout Data) -> Data? { + let delimiter = Data([0x0A, 0x0A]) // "\n\n" + guard let range = buffer.range(of: delimiter) else { return nil } + let frame = buffer.subdata(in: buffer.startIndex.. ServerSentEvent? { + guard let text = String(data: frame, encoding: .utf8) else { return nil } + var event: String? + var id: String? + var retry: Int? + var dataLines: [String] = [] + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + let line = rawLine.hasSuffix("\r") ? String(rawLine.dropLast()) : String(rawLine) + if line.isEmpty || line.hasPrefix(":") { continue } // comment / blank + let field: Substring + let value: String + if let colon = line.firstIndex(of: ":") { + field = line[line.startIndex.. Date: Thu, 9 Jul 2026 06:20:28 -0300 Subject: [PATCH 42/44] feat(codegen): support configurable access level, target the new MultipartFormData API tools/openapi-codegen's emitter still hardcoded `public` on every generated declaration and emitted the old MultipartFormData.Part/.append API, both of which no longer compile against HTTPRuntime's package-scoped types and rewritten builder-style MultipartFormData. Threads a new AccessLevel enum (default .internal) through every emitter helper and adds a --access-level CLI option; multipart request bodies now build via the .addFile/.addText fluent chain. Regenerates Sources/StorageOpenAPI with the new internal default. --- Sources/StorageOpenAPI/Models.swift | 362 +++++++++--------- .../StorageOpenAPI/StorageOpenAPIClient.swift | 133 +++---- .../OpenAPICodegenCore/SwiftEmitter.swift | 99 +++-- .../Sources/openapi-codegen/main.swift | 16 +- .../ClientEmitterTests.swift | 117 ++++-- .../ModelEmitterTests.swift | 61 ++- .../openapi-codegenTests/EndToEndTests.swift | 22 +- 7 files changed, 467 insertions(+), 343 deletions(-) diff --git a/Sources/StorageOpenAPI/Models.swift b/Sources/StorageOpenAPI/Models.swift index 4b5c3121d..2e83ca34a 100644 --- a/Sources/StorageOpenAPI/Models.swift +++ b/Sources/StorageOpenAPI/Models.swift @@ -1,23 +1,23 @@ // Code generated by openapi-codegen. DO NOT EDIT. import Foundation -public import HTTPRuntime +import HTTPRuntime -public struct AuthSchema: Codable, Sendable, Hashable { - public var authorization: String +internal struct AuthSchema: Codable, Sendable, Hashable { + internal var authorization: String enum CodingKeys: String, CodingKey { case authorization = "authorization" } } -public struct BucketCreateRequestBody: Codable, Sendable, Hashable { - public var allowedMimeTypes: [String]? - public var fileSizeLimit: BucketCreateRequestBodyFileSizeLimit? - public var id: String? - public var name: String - public var `public`: Bool? - public var type: BucketCreateRequestBodyType? +internal struct BucketCreateRequestBody: Codable, Sendable, Hashable { + internal var allowedMimeTypes: [String]? + internal var fileSizeLimit: BucketCreateRequestBodyFileSizeLimit? + internal var id: String? + internal var name: String + internal var `public`: Bool? + internal var type: BucketCreateRequestBodyType? enum CodingKeys: String, CodingKey { case allowedMimeTypes = "allowed_mime_types" @@ -29,11 +29,11 @@ public struct BucketCreateRequestBody: Codable, Sendable, Hashable { } } -public enum BucketCreateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { +internal enum BucketCreateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { case integer(Int) case string(String) - public init(from decoder: any Decoder) throws { + internal init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(Int.self) { self = .integer(value) @@ -49,7 +49,7 @@ public enum BucketCreateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { ) } - public func encode(to encoder: any Encoder) throws { + internal func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .integer(let value): try container.encode(value) @@ -58,70 +58,70 @@ public enum BucketCreateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { } } -public enum BucketCreateRequestBodyType: String, Codable, Sendable, Hashable { +internal enum BucketCreateRequestBodyType: String, Codable, Sendable, Hashable { case STANDARD = "STANDARD" case ANALYTICS = "ANALYTICS" } -public struct BucketCreateResponse200: Codable, Sendable, Hashable { - public var name: String +internal struct BucketCreateResponse200: Codable, Sendable, Hashable { + internal var name: String enum CodingKeys: String, CodingKey { case name = "name" } } -public struct BucketDeleteResponse200: Codable, Sendable, Hashable { - public var message: String? +internal struct BucketDeleteResponse200: Codable, Sendable, Hashable { + internal var message: String? enum CodingKeys: String, CodingKey { case message = "message" } } -public struct BucketEmptyResponse200: Codable, Sendable, Hashable { - public var message: String? +internal struct BucketEmptyResponse200: Codable, Sendable, Hashable { + internal var message: String? enum CodingKeys: String, CodingKey { case message = "message" } } -public enum BucketListHeadSortColumn: String, Codable, Sendable, Hashable { +internal enum BucketListHeadSortColumn: String, Codable, Sendable, Hashable { case id = "id" case name = "name" case createdAt = "created_at" case updatedAt = "updated_at" } -public enum BucketListHeadSortOrder: String, Codable, Sendable, Hashable { +internal enum BucketListHeadSortOrder: String, Codable, Sendable, Hashable { case asc = "asc" case desc = "desc" } -public enum BucketListSortColumn: String, Codable, Sendable, Hashable { +internal enum BucketListSortColumn: String, Codable, Sendable, Hashable { case id = "id" case name = "name" case createdAt = "created_at" case updatedAt = "updated_at" } -public enum BucketListSortOrder: String, Codable, Sendable, Hashable { +internal enum BucketListSortOrder: String, Codable, Sendable, Hashable { case asc = "asc" case desc = "desc" } -public struct BucketSchema: Codable, Sendable, Hashable { - public var allowedMimeTypes: [String]? - public var createdAt: String? - public var fileSizeLimit: Int? - public var id: String - public var name: String - public var owner: String? - public var ownerId: String? - public var `public`: Bool? - public var type: BucketSchemaType? - public var updatedAt: String? +internal struct BucketSchema: Codable, Sendable, Hashable { + internal var allowedMimeTypes: [String]? + internal var createdAt: String? + internal var fileSizeLimit: Int? + internal var id: String + internal var name: String + internal var owner: String? + internal var ownerId: String? + internal var `public`: Bool? + internal var type: BucketSchemaType? + internal var updatedAt: String? enum CodingKeys: String, CodingKey { case allowedMimeTypes = "allowed_mime_types" @@ -137,15 +137,15 @@ public struct BucketSchema: Codable, Sendable, Hashable { } } -public enum BucketSchemaType: String, Codable, Sendable, Hashable { +internal enum BucketSchemaType: String, Codable, Sendable, Hashable { case STANDARD = "STANDARD" case ANALYTICS = "ANALYTICS" } -public struct BucketUpdateRequestBody: Codable, Sendable, Hashable { - public var allowedMimeTypes: [String]? - public var fileSizeLimit: BucketUpdateRequestBodyFileSizeLimit? - public var `public`: Bool? +internal struct BucketUpdateRequestBody: Codable, Sendable, Hashable { + internal var allowedMimeTypes: [String]? + internal var fileSizeLimit: BucketUpdateRequestBodyFileSizeLimit? + internal var `public`: Bool? enum CodingKeys: String, CodingKey { case allowedMimeTypes = "allowed_mime_types" @@ -154,11 +154,11 @@ public struct BucketUpdateRequestBody: Codable, Sendable, Hashable { } } -public enum BucketUpdateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { +internal enum BucketUpdateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { case integer(Int) case string(String) - public init(from decoder: any Decoder) throws { + internal init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(Int.self) { self = .integer(value) @@ -174,7 +174,7 @@ public enum BucketUpdateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { ) } - public func encode(to encoder: any Encoder) throws { + internal func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .integer(let value): try container.encode(value) @@ -183,42 +183,42 @@ public enum BucketUpdateRequestBodyFileSizeLimit: Codable, Sendable, Hashable { } } -public struct BucketUpdateResponse200: Codable, Sendable, Hashable { - public var message: String +internal struct BucketUpdateResponse200: Codable, Sendable, Hashable { + internal var message: String enum CodingKeys: String, CodingKey { case message = "message" } } -public struct CdnPurgeBucketCacheResponse200: Codable, Sendable, Hashable { - public var message: String? +internal struct CdnPurgeBucketCacheResponse200: Codable, Sendable, Hashable { + internal var message: String? enum CodingKeys: String, CodingKey { case message = "message" } } -public struct CdnPurgeObjectCacheResponse200: Codable, Sendable, Hashable { - public var message: String? +internal struct CdnPurgeObjectCacheResponse200: Codable, Sendable, Hashable { + internal var message: String? enum CodingKeys: String, CodingKey { case message = "message" } } -public struct CdnPurgeTenantCacheResponse200: Codable, Sendable, Hashable { - public var message: String? +internal struct CdnPurgeTenantCacheResponse200: Codable, Sendable, Hashable { + internal var message: String? enum CodingKeys: String, CodingKey { case message = "message" } } -public struct ErrorSchema: Codable, Sendable, Hashable, APIError { - public var error: String - public var message: String - public var statusCode: String +internal struct ErrorSchema: Codable, Sendable, Hashable, APIError { + internal var error: String + internal var message: String + internal var statusCode: String enum CodingKeys: String, CodingKey { case error = "error" @@ -227,13 +227,13 @@ public struct ErrorSchema: Codable, Sendable, Hashable, APIError { } } -public struct ObjectCopyRequestBody: Codable, Sendable, Hashable { - public var bucketId: String - public var copyMetadata: Bool? - public var destinationBucket: String? - public var destinationKey: String - public var metadata: ObjectCopyRequestBodyMetadata? - public var sourceKey: String +internal struct ObjectCopyRequestBody: Codable, Sendable, Hashable { + internal var bucketId: String + internal var copyMetadata: Bool? + internal var destinationBucket: String? + internal var destinationKey: String + internal var metadata: ObjectCopyRequestBodyMetadata? + internal var sourceKey: String enum CodingKeys: String, CodingKey { case bucketId = "bucketId" @@ -245,9 +245,9 @@ public struct ObjectCopyRequestBody: Codable, Sendable, Hashable { } } -public struct ObjectCopyRequestBodyMetadata: Codable, Sendable, Hashable { - public var cacheControl: String? - public var mimetype: String? +internal struct ObjectCopyRequestBodyMetadata: Codable, Sendable, Hashable { + internal var cacheControl: String? + internal var mimetype: String? enum CodingKeys: String, CodingKey { case cacheControl = "cacheControl" @@ -255,21 +255,21 @@ public struct ObjectCopyRequestBodyMetadata: Codable, Sendable, Hashable { } } -public struct ObjectCopyResponse200: Codable, Sendable, Hashable { - public var Id: String? - public var Key: String - public var bucketId: String? - public var buckets: BucketSchema? - public var createdAt: String? - public var id: String? - public var lastAccessedAt: String? - public var metadata: [String: JSONValue]? - public var name: String? - public var owner: String? - public var ownerId: String? - public var updatedAt: String? - public var userMetadata: [String: JSONValue]? - public var version: String? +internal struct ObjectCopyResponse200: Codable, Sendable, Hashable { + internal var Id: String? + internal var Key: String + internal var bucketId: String? + internal var buckets: BucketSchema? + internal var createdAt: String? + internal var id: String? + internal var lastAccessedAt: String? + internal var metadata: [String: JSONValue]? + internal var name: String? + internal var owner: String? + internal var ownerId: String? + internal var updatedAt: String? + internal var userMetadata: [String: JSONValue]? + internal var version: String? enum CodingKeys: String, CodingKey { case Id = "Id" @@ -289,124 +289,124 @@ public struct ObjectCopyResponse200: Codable, Sendable, Hashable { } } -public struct ObjectDeleteManyRequestBody: Codable, Sendable, Hashable { - public var prefixes: [String] +internal struct ObjectDeleteManyRequestBody: Codable, Sendable, Hashable { + internal var prefixes: [String] enum CodingKeys: String, CodingKey { case prefixes = "prefixes" } } -public struct ObjectDeleteResponse200: Codable, Sendable, Hashable { - public var message: String? +internal struct ObjectDeleteResponse200: Codable, Sendable, Hashable { + internal var message: String? enum CodingKeys: String, CodingKey { case message = "message" } } -public enum ObjectGetAuthenticatedInfo2Format: String, Codable, Sendable, Hashable { +internal enum ObjectGetAuthenticatedInfo2Format: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectGetAuthenticatedInfo2Resize: String, Codable, Sendable, Hashable { +internal enum ObjectGetAuthenticatedInfo2Resize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum ObjectGetAuthenticatedInfoGetFormat: String, Codable, Sendable, Hashable { +internal enum ObjectGetAuthenticatedInfoGetFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectGetAuthenticatedInfoGetResize: String, Codable, Sendable, Hashable { +internal enum ObjectGetAuthenticatedInfoGetResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum ObjectGetAuthenticatedInfoHeadFormat: String, Codable, Sendable, Hashable { +internal enum ObjectGetAuthenticatedInfoHeadFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectGetAuthenticatedInfoHeadResize: String, Codable, Sendable, Hashable { +internal enum ObjectGetAuthenticatedInfoHeadResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum ObjectGetAuthenticatedInfoFormat: String, Codable, Sendable, Hashable { +internal enum ObjectGetAuthenticatedInfoFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectGetAuthenticatedInfoResize: String, Codable, Sendable, Hashable { +internal enum ObjectGetAuthenticatedInfoResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum ObjectHeadAuthenticatedInfoHeadFormat: String, Codable, Sendable, Hashable { +internal enum ObjectHeadAuthenticatedInfoHeadFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectHeadAuthenticatedInfoHeadResize: String, Codable, Sendable, Hashable { +internal enum ObjectHeadAuthenticatedInfoHeadResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum ObjectHeadAuthenticatedInfoFormat: String, Codable, Sendable, Hashable { +internal enum ObjectHeadAuthenticatedInfoFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectHeadAuthenticatedInfoResize: String, Codable, Sendable, Hashable { +internal enum ObjectHeadAuthenticatedInfoResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum ObjectInfoPublicGetFormat: String, Codable, Sendable, Hashable { +internal enum ObjectInfoPublicGetFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectInfoPublicGetResize: String, Codable, Sendable, Hashable { +internal enum ObjectInfoPublicGetResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum ObjectInfoPublicFormat: String, Codable, Sendable, Hashable { +internal enum ObjectInfoPublicFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectInfoPublicResize: String, Codable, Sendable, Hashable { +internal enum ObjectInfoPublicResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public struct ObjectListV2RequestBody: Codable, Sendable, Hashable { - public var cursor: String? - public var limit: Int? - public var prefix: String? - public var sortBy: ObjectListV2RequestBodySortBy? - public var withDelimiter: Bool? +internal struct ObjectListV2RequestBody: Codable, Sendable, Hashable { + internal var cursor: String? + internal var limit: Int? + internal var prefix: String? + internal var sortBy: ObjectListV2RequestBodySortBy? + internal var withDelimiter: Bool? enum CodingKeys: String, CodingKey { case cursor = "cursor" @@ -417,9 +417,9 @@ public struct ObjectListV2RequestBody: Codable, Sendable, Hashable { } } -public struct ObjectListV2RequestBodySortBy: Codable, Sendable, Hashable { - public var column: ObjectListV2RequestBodySortByColumn - public var order: ObjectListV2RequestBodySortByOrder? +internal struct ObjectListV2RequestBodySortBy: Codable, Sendable, Hashable { + internal var column: ObjectListV2RequestBodySortByColumn + internal var order: ObjectListV2RequestBodySortByOrder? enum CodingKeys: String, CodingKey { case column = "column" @@ -427,23 +427,23 @@ public struct ObjectListV2RequestBodySortBy: Codable, Sendable, Hashable { } } -public enum ObjectListV2RequestBodySortByColumn: String, Codable, Sendable, Hashable { +internal enum ObjectListV2RequestBodySortByColumn: String, Codable, Sendable, Hashable { case name = "name" case updatedAt = "updated_at" case createdAt = "created_at" } -public enum ObjectListV2RequestBodySortByOrder: String, Codable, Sendable, Hashable { +internal enum ObjectListV2RequestBodySortByOrder: String, Codable, Sendable, Hashable { case asc = "asc" case desc = "desc" } -public struct ObjectListRequestBody: Codable, Sendable, Hashable { - public var limit: Int? - public var offset: Int? - public var prefix: String - public var search: String? - public var sortBy: ObjectListRequestBodySortBy? +internal struct ObjectListRequestBody: Codable, Sendable, Hashable { + internal var limit: Int? + internal var offset: Int? + internal var prefix: String + internal var search: String? + internal var sortBy: ObjectListRequestBodySortBy? enum CodingKeys: String, CodingKey { case limit = "limit" @@ -454,9 +454,9 @@ public struct ObjectListRequestBody: Codable, Sendable, Hashable { } } -public struct ObjectListRequestBodySortBy: Codable, Sendable, Hashable { - public var column: ObjectListRequestBodySortByColumn - public var order: ObjectListRequestBodySortByOrder? +internal struct ObjectListRequestBodySortBy: Codable, Sendable, Hashable { + internal var column: ObjectListRequestBodySortByColumn + internal var order: ObjectListRequestBodySortByOrder? enum CodingKeys: String, CodingKey { case column = "column" @@ -464,23 +464,23 @@ public struct ObjectListRequestBodySortBy: Codable, Sendable, Hashable { } } -public enum ObjectListRequestBodySortByColumn: String, Codable, Sendable, Hashable { +internal enum ObjectListRequestBodySortByColumn: String, Codable, Sendable, Hashable { case name = "name" case updatedAt = "updated_at" case createdAt = "created_at" case lastAccessedAt = "last_accessed_at" } -public enum ObjectListRequestBodySortByOrder: String, Codable, Sendable, Hashable { +internal enum ObjectListRequestBodySortByOrder: String, Codable, Sendable, Hashable { case asc = "asc" case desc = "desc" } -public struct ObjectMoveRequestBody: Codable, Sendable, Hashable { - public var bucketId: String - public var destinationBucket: String? - public var destinationKey: String - public var sourceKey: String +internal struct ObjectMoveRequestBody: Codable, Sendable, Hashable { + internal var bucketId: String + internal var destinationBucket: String? + internal var destinationKey: String + internal var sourceKey: String enum CodingKeys: String, CodingKey { case bucketId = "bucketId" @@ -490,27 +490,27 @@ public struct ObjectMoveRequestBody: Codable, Sendable, Hashable { } } -public struct ObjectMoveResponse200: Codable, Sendable, Hashable { - public var message: String +internal struct ObjectMoveResponse200: Codable, Sendable, Hashable { + internal var message: String enum CodingKeys: String, CodingKey { case message = "message" } } -public struct ObjectSchema: Codable, Sendable, Hashable { - public var bucketId: String? - public var buckets: BucketSchema? - public var createdAt: String? - public var id: String? - public var lastAccessedAt: String? - public var metadata: [String: JSONValue]? - public var name: String - public var owner: String? - public var ownerId: String? - public var updatedAt: String? - public var userMetadata: [String: JSONValue]? - public var version: String? +internal struct ObjectSchema: Codable, Sendable, Hashable { + internal var bucketId: String? + internal var buckets: BucketSchema? + internal var createdAt: String? + internal var id: String? + internal var lastAccessedAt: String? + internal var metadata: [String: JSONValue]? + internal var name: String + internal var owner: String? + internal var ownerId: String? + internal var updatedAt: String? + internal var userMetadata: [String: JSONValue]? + internal var version: String? enum CodingKeys: String, CodingKey { case bucketId = "bucket_id" @@ -528,9 +528,9 @@ public struct ObjectSchema: Codable, Sendable, Hashable { } } -public struct ObjectSignManyRequestBody: Codable, Sendable, Hashable { - public var expiresIn: Int - public var paths: [String] +internal struct ObjectSignManyRequestBody: Codable, Sendable, Hashable { + internal var expiresIn: Int + internal var paths: [String] enum CodingKeys: String, CodingKey { case expiresIn = "expiresIn" @@ -538,10 +538,10 @@ public struct ObjectSignManyRequestBody: Codable, Sendable, Hashable { } } -public struct ObjectSignManyResponse200Item: Codable, Sendable, Hashable { - public var error: String? - public var path: String - public var signedURL: String? +internal struct ObjectSignManyResponse200Item: Codable, Sendable, Hashable { + internal var error: String? + internal var path: String + internal var signedURL: String? enum CodingKeys: String, CodingKey { case error = "error" @@ -550,9 +550,9 @@ public struct ObjectSignManyResponse200Item: Codable, Sendable, Hashable { } } -public struct ObjectSignUploadUrlResponse200: Codable, Sendable, Hashable { - public var token: String? - public var url: String +internal struct ObjectSignUploadUrlResponse200: Codable, Sendable, Hashable { + internal var token: String? + internal var url: String enum CodingKeys: String, CodingKey { case token = "token" @@ -560,9 +560,9 @@ public struct ObjectSignUploadUrlResponse200: Codable, Sendable, Hashable { } } -public struct ObjectSignRequestBody: Codable, Sendable, Hashable { - public var expiresIn: Int - public var transform: ObjectSignRequestBodyTransform? +internal struct ObjectSignRequestBody: Codable, Sendable, Hashable { + internal var expiresIn: Int + internal var transform: ObjectSignRequestBodyTransform? enum CodingKeys: String, CodingKey { case expiresIn = "expiresIn" @@ -570,12 +570,12 @@ public struct ObjectSignRequestBody: Codable, Sendable, Hashable { } } -public struct ObjectSignRequestBodyTransform: Codable, Sendable, Hashable { - public var format: ObjectSignRequestBodyTransformFormat? - public var height: Int? - public var quality: Int? - public var resize: ObjectSignRequestBodyTransformResize? - public var width: Int? +internal struct ObjectSignRequestBodyTransform: Codable, Sendable, Hashable { + internal var format: ObjectSignRequestBodyTransformFormat? + internal var height: Int? + internal var quality: Int? + internal var resize: ObjectSignRequestBodyTransformResize? + internal var width: Int? enum CodingKeys: String, CodingKey { case format = "format" @@ -586,37 +586,37 @@ public struct ObjectSignRequestBodyTransform: Codable, Sendable, Hashable { } } -public enum ObjectSignRequestBodyTransformFormat: String, Codable, Sendable, Hashable { +internal enum ObjectSignRequestBodyTransformFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum ObjectSignRequestBodyTransformResize: String, Codable, Sendable, Hashable { +internal enum ObjectSignRequestBodyTransformResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public struct ObjectSignResponse200: Codable, Sendable, Hashable { - public var signedURL: String +internal struct ObjectSignResponse200: Codable, Sendable, Hashable { + internal var signedURL: String enum CodingKeys: String, CodingKey { case signedURL = "signedURL" } } -public struct ObjectUploadSignedResponse200: Codable, Sendable, Hashable { - public var Key: String +internal struct ObjectUploadSignedResponse200: Codable, Sendable, Hashable { + internal var Key: String enum CodingKeys: String, CodingKey { case Key = "Key" } } -public struct ObjectUploadUpdateResponse200: Codable, Sendable, Hashable { - public var Id: String? - public var Key: String +internal struct ObjectUploadUpdateResponse200: Codable, Sendable, Hashable { + internal var Id: String? + internal var Key: String enum CodingKeys: String, CodingKey { case Id = "Id" @@ -624,9 +624,9 @@ public struct ObjectUploadUpdateResponse200: Codable, Sendable, Hashable { } } -public struct ObjectUploadResponse200: Codable, Sendable, Hashable { - public var Id: String? - public var Key: String +internal struct ObjectUploadResponse200: Codable, Sendable, Hashable { + internal var Id: String? + internal var Key: String enum CodingKeys: String, CodingKey { case Id = "Id" @@ -634,49 +634,49 @@ public struct ObjectUploadResponse200: Codable, Sendable, Hashable { } } -public enum RenderImageAuthenticatedHeadFormat: String, Codable, Sendable, Hashable { +internal enum RenderImageAuthenticatedHeadFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum RenderImageAuthenticatedHeadResize: String, Codable, Sendable, Hashable { +internal enum RenderImageAuthenticatedHeadResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum RenderImageAuthenticatedFormat: String, Codable, Sendable, Hashable { +internal enum RenderImageAuthenticatedFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum RenderImageAuthenticatedResize: String, Codable, Sendable, Hashable { +internal enum RenderImageAuthenticatedResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum RenderImagePublicHeadFormat: String, Codable, Sendable, Hashable { +internal enum RenderImagePublicHeadFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum RenderImagePublicHeadResize: String, Codable, Sendable, Hashable { +internal enum RenderImagePublicHeadResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" } -public enum RenderImagePublicFormat: String, Codable, Sendable, Hashable { +internal enum RenderImagePublicFormat: String, Codable, Sendable, Hashable { case origin = "origin" case avif = "avif" case webp = "webp" } -public enum RenderImagePublicResize: String, Codable, Sendable, Hashable { +internal enum RenderImagePublicResize: String, Codable, Sendable, Hashable { case cover = "cover" case contain = "contain" case fill = "fill" diff --git a/Sources/StorageOpenAPI/StorageOpenAPIClient.swift b/Sources/StorageOpenAPI/StorageOpenAPIClient.swift index 32c06c344..509a43be6 100644 --- a/Sources/StorageOpenAPI/StorageOpenAPIClient.swift +++ b/Sources/StorageOpenAPI/StorageOpenAPIClient.swift @@ -1,18 +1,19 @@ // Code generated by openapi-codegen. DO NOT EDIT. -public import Foundation -public import HTTPRuntime +import Foundation +import HTTPRuntime -public struct StorageOpenAPIClient: Sendable { +internal struct StorageOpenAPIClient: Sendable { private let baseURL: URL private let transport: any HTTPTransport - public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + internal init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { self.baseURL = baseURL self.transport = transport } - public func bucketCreate(payload: BucketCreateRequestBody) async throws -> BucketCreateResponse200 + internal func bucketCreate(payload: BucketCreateRequestBody) async throws + -> BucketCreateResponse200 { var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/bucket") builder.setHeader("Content-Type", "application/json") @@ -22,7 +23,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(BucketCreateResponse200.self, from: response.body) } - public func bucketDelete(bucketId: String) async throws -> BucketDeleteResponse200 { + internal func bucketDelete(bucketId: String) async throws -> BucketDeleteResponse200 { var builder = HTTPRequestBuilder( method: .delete, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") let response = try await transport.send(try builder.build()) @@ -30,7 +31,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(BucketDeleteResponse200.self, from: response.body) } - public func bucketEmpty(bucketId: String) async throws -> BucketEmptyResponse200 { + internal func bucketEmpty(bucketId: String) async throws -> BucketEmptyResponse200 { var builder = HTTPRequestBuilder( method: .post, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))/empty") let response = try await transport.send(try builder.build()) @@ -38,7 +39,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(BucketEmptyResponse200.self, from: response.body) } - public func bucketGet(bucketId: String) async throws -> BucketSchema { + internal func bucketGet(bucketId: String) async throws -> BucketSchema { var builder = HTTPRequestBuilder( method: .get, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") let response = try await transport.send(try builder.build()) @@ -46,7 +47,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body) } - public func bucketGetHead(bucketId: String) async throws -> BucketSchema { + internal func bucketGetHead(bucketId: String) async throws -> BucketSchema { var builder = HTTPRequestBuilder( method: .head, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") let response = try await transport.send(try builder.build()) @@ -54,7 +55,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body) } - public func bucketList( + internal func bucketList( limit: Int? = nil, offset: Int? = nil, search: String? = nil, sortColumn: BucketListSortColumn? = nil, sortOrder: BucketListSortOrder? = nil ) async throws -> [BucketSchema] { @@ -69,7 +70,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([BucketSchema].self, from: response.body) } - public func bucketListHead( + internal func bucketListHead( limit: Int? = nil, offset: Int? = nil, search: String? = nil, sortColumn: BucketListHeadSortColumn? = nil, sortOrder: BucketListHeadSortOrder? = nil ) async throws -> [BucketSchema] { @@ -84,7 +85,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([BucketSchema].self, from: response.body) } - public func bucketUpdate(bucketId: String, payload: BucketUpdateRequestBody) async throws + internal func bucketUpdate(bucketId: String, payload: BucketUpdateRequestBody) async throws -> BucketUpdateResponse200 { var builder = HTTPRequestBuilder( @@ -96,7 +97,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(BucketUpdateResponse200.self, from: response.body) } - public func cdnPurgeBucketCache(bucketName: String, transformations: Bool? = nil) async throws + internal func cdnPurgeBucketCache(bucketName: String, transformations: Bool? = nil) async throws -> CdnPurgeBucketCacheResponse200 { var builder = HTTPRequestBuilder( @@ -107,7 +108,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(CdnPurgeBucketCacheResponse200.self, from: response.body) } - public func cdnPurgeObjectCache( + internal func cdnPurgeObjectCache( bucketName: String, wildcard: String, transformations: Bool? = nil ) async throws -> CdnPurgeObjectCacheResponse200 { var builder = HTTPRequestBuilder( @@ -119,7 +120,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(CdnPurgeObjectCacheResponse200.self, from: response.body) } - public func cdnPurgeTenantCache(transformations: Bool? = nil) async throws + internal func cdnPurgeTenantCache(transformations: Bool? = nil) async throws -> CdnPurgeTenantCacheResponse200 { var builder = HTTPRequestBuilder(method: .delete, baseURL: baseURL, path: "/cdn/") @@ -129,21 +130,21 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(CdnPurgeTenantCacheResponse200.self, from: response.body) } - public func healthcheck() async throws -> [String: JSONValue] { + internal func healthcheck() async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/health") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [403: ErrorSchema.self]) return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func healthcheckHead() async throws -> [String: JSONValue] { + internal func healthcheckHead() async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder(method: .head, baseURL: baseURL, path: "/health") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [403: ErrorSchema.self]) return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func objectCopy(payload: ObjectCopyRequestBody) async throws -> ObjectCopyResponse200 { + internal func objectCopy(payload: ObjectCopyRequestBody) async throws -> ObjectCopyResponse200 { var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/object/copy") builder.setHeader("Content-Type", "application/json") builder.setBody(.data(try JSONCoding.encoder.encode(payload))) @@ -152,7 +153,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(ObjectCopyResponse200.self, from: response.body) } - public func objectDelete(bucketName: String, wildcard: String) async throws + internal func objectDelete(bucketName: String, wildcard: String) async throws -> ObjectDeleteResponse200 { var builder = HTTPRequestBuilder( @@ -163,7 +164,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(ObjectDeleteResponse200.self, from: response.body) } - public func objectDeleteMany(bucketName: String, payload: ObjectDeleteManyRequestBody) + internal func objectDeleteMany(bucketName: String, payload: ObjectDeleteManyRequestBody) async throws -> [ObjectSchema] { var builder = HTTPRequestBuilder( @@ -175,9 +176,9 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([ObjectSchema].self, from: response.body) } - public func objectGetAuthenticated(bucketName: String, wildcard: String, download: String? = nil) - async throws - { + internal func objectGetAuthenticated( + bucketName: String, wildcard: String, download: String? = nil + ) async throws { var builder = HTTPRequestBuilder( method: .get, baseURL: baseURL, path: @@ -188,7 +189,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func objectGetAuthenticatedGet(bucketName: String, wildcard: String) async throws { + internal func objectGetAuthenticatedGet(bucketName: String, wildcard: String) async throws { var builder = HTTPRequestBuilder( method: .get, baseURL: baseURL, path: "/object/\(PathEncoding.segment(bucketName))/\(PathEncoding.segment(wildcard))") @@ -196,7 +197,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func objectGetAuthenticatedInfo( + internal func objectGetAuthenticatedInfo( bucketName: String, wildcard: String, format: ObjectGetAuthenticatedInfoFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: ObjectGetAuthenticatedInfoResize? = nil, width: Int? = nil @@ -215,7 +216,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func objectGetAuthenticatedInfo2( + internal func objectGetAuthenticatedInfo2( bucketName: String, wildcard: String, format: ObjectGetAuthenticatedInfo2Format? = nil, height: Int? = nil, quality: Int? = nil, resize: ObjectGetAuthenticatedInfo2Resize? = nil, width: Int? = nil @@ -232,7 +233,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func objectGetAuthenticatedInfoGet( + internal func objectGetAuthenticatedInfoGet( bucketName: String, wildcard: String, format: ObjectGetAuthenticatedInfoGetFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: ObjectGetAuthenticatedInfoGetResize? = nil, width: Int? = nil @@ -249,7 +250,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func objectGetAuthenticatedInfoHead( + internal func objectGetAuthenticatedInfoHead( bucketName: String, wildcard: String, format: ObjectGetAuthenticatedInfoHeadFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: ObjectGetAuthenticatedInfoHeadResize? = nil, width: Int? = nil @@ -268,7 +269,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func objectGetPublic(bucketName: String, wildcard: String, download: String? = nil) + internal func objectGetPublic(bucketName: String, wildcard: String, download: String? = nil) async throws { var builder = HTTPRequestBuilder( @@ -279,7 +280,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func objectGetSigned( + internal func objectGetSigned( bucketName: String, token: String, wildcard: String, download: String? = nil ) async throws { var builder = HTTPRequestBuilder( @@ -291,7 +292,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func objectGetSignedHead( + internal func objectGetSignedHead( bucketName: String, token: String, wildcard: String, download: String? = nil ) async throws { var builder = HTTPRequestBuilder( @@ -303,7 +304,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func objectHeadAuthenticatedInfo( + internal func objectHeadAuthenticatedInfo( bucketName: String, wildcard: String, format: ObjectHeadAuthenticatedInfoFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: ObjectHeadAuthenticatedInfoResize? = nil, width: Int? = nil @@ -322,7 +323,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func objectHeadAuthenticatedInfoHead( + internal func objectHeadAuthenticatedInfoHead( bucketName: String, wildcard: String, format: ObjectHeadAuthenticatedInfoHeadFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: ObjectHeadAuthenticatedInfoHeadResize? = nil, width: Int? = nil @@ -339,7 +340,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func objectInfoPublic( + internal func objectInfoPublic( bucketName: String, wildcard: String, format: ObjectInfoPublicFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: ObjectInfoPublicResize? = nil, width: Int? = nil ) async throws { @@ -355,7 +356,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func objectInfoPublicGet( + internal func objectInfoPublicGet( bucketName: String, wildcard: String, format: ObjectInfoPublicGetFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: ObjectInfoPublicGetResize? = nil, width: Int? = nil @@ -373,7 +374,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func objectList(bucketName: String, payload: ObjectListRequestBody) async throws + internal func objectList(bucketName: String, payload: ObjectListRequestBody) async throws -> [ObjectSchema] { var builder = HTTPRequestBuilder( @@ -385,7 +386,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([ObjectSchema].self, from: response.body) } - public func objectListV2(bucketName: String, payload: ObjectListV2RequestBody) async throws + internal func objectListV2(bucketName: String, payload: ObjectListV2RequestBody) async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder( @@ -397,7 +398,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func objectMove(payload: ObjectMoveRequestBody) async throws -> ObjectMoveResponse200 { + internal func objectMove(payload: ObjectMoveRequestBody) async throws -> ObjectMoveResponse200 { var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/object/move") builder.setHeader("Content-Type", "application/json") builder.setBody(.data(try JSONCoding.encoder.encode(payload))) @@ -406,7 +407,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(ObjectMoveResponse200.self, from: response.body) } - public func objectSign(bucketName: String, wildcard: String, payload: ObjectSignRequestBody) + internal func objectSign(bucketName: String, wildcard: String, payload: ObjectSignRequestBody) async throws -> ObjectSignResponse200 { var builder = HTTPRequestBuilder( @@ -419,7 +420,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(ObjectSignResponse200.self, from: response.body) } - public func objectSignMany(bucketName: String, payload: ObjectSignManyRequestBody) async throws + internal func objectSignMany(bucketName: String, payload: ObjectSignManyRequestBody) async throws -> [ObjectSignManyResponse200Item] { var builder = HTTPRequestBuilder( @@ -431,7 +432,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([ObjectSignManyResponse200Item].self, from: response.body) } - public func objectSignUploadUrl(bucketName: String, wildcard: String) async throws + internal func objectSignUploadUrl(bucketName: String, wildcard: String) async throws -> ObjectSignUploadUrlResponse200 { var builder = HTTPRequestBuilder( @@ -443,7 +444,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(ObjectSignUploadUrlResponse200.self, from: response.body) } - public func objectUpload(bucketName: String, wildcard: String) async throws + internal func objectUpload(bucketName: String, wildcard: String) async throws -> ObjectUploadResponse200 { var builder = HTTPRequestBuilder( @@ -454,7 +455,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(ObjectUploadResponse200.self, from: response.body) } - public func objectUploadSigned(bucketName: String, token: String, wildcard: String) async throws + internal func objectUploadSigned(bucketName: String, token: String, wildcard: String) async throws -> ObjectUploadSignedResponse200 { var builder = HTTPRequestBuilder( @@ -467,7 +468,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(ObjectUploadSignedResponse200.self, from: response.body) } - public func objectUploadUpdate(bucketName: String, wildcard: String) async throws + internal func objectUploadUpdate(bucketName: String, wildcard: String) async throws -> ObjectUploadUpdateResponse200 { var builder = HTTPRequestBuilder( @@ -478,7 +479,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode(ObjectUploadUpdateResponse200.self, from: response.body) } - public func renderImageAuthenticated( + internal func renderImageAuthenticated( bucketName: String, wildcard: String, download: String? = nil, format: RenderImageAuthenticatedFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: RenderImageAuthenticatedResize? = nil, width: Int? = nil @@ -498,7 +499,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func renderImageAuthenticatedHead( + internal func renderImageAuthenticatedHead( bucketName: String, wildcard: String, download: String? = nil, format: RenderImageAuthenticatedHeadFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: RenderImageAuthenticatedHeadResize? = nil, width: Int? = nil @@ -518,7 +519,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [403: ErrorSchema.self]) } - public func renderImagePublic( + internal func renderImagePublic( bucketName: String, wildcard: String, download: String? = nil, format: RenderImagePublicFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: RenderImagePublicResize? = nil, width: Int? = nil @@ -538,7 +539,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func renderImagePublicHead( + internal func renderImagePublicHead( bucketName: String, wildcard: String, download: String? = nil, format: RenderImagePublicHeadFormat? = nil, height: Int? = nil, quality: Int? = nil, resize: RenderImagePublicHeadResize? = nil, width: Int? = nil @@ -558,7 +559,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func renderImageSign( + internal func renderImageSign( bucketName: String, token: String, wildcard: String, download: String? = nil ) async throws { var builder = HTTPRequestBuilder( @@ -571,7 +572,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func renderImageSignHead( + internal func renderImageSignHead( bucketName: String, token: String, wildcard: String, download: String? = nil ) async throws { var builder = HTTPRequestBuilder( @@ -584,13 +585,13 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func tusOptions() async throws { + internal func tusOptions() async throws { var builder = HTTPRequestBuilder(method: .options, baseURL: baseURL, path: "/upload/resumable/") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [:]) } - public func tusOptionsOptions(wildcard: String) async throws { + internal func tusOptionsOptions(wildcard: String) async throws { var builder = HTTPRequestBuilder( method: .options, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") @@ -598,14 +599,14 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func tusOptionsSigned() async throws { + internal func tusOptionsSigned() async throws { var builder = HTTPRequestBuilder( method: .options, baseURL: baseURL, path: "/upload/resumable/sign/") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [:]) } - public func tusOptionsSignedOptions(wildcard: String) async throws { + internal func tusOptionsSignedOptions(wildcard: String) async throws { var builder = HTTPRequestBuilder( method: .options, baseURL: baseURL, path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") @@ -613,14 +614,14 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func tusUploadCreate() async throws -> [String: JSONValue] { + internal func tusUploadCreate() async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/upload/resumable/") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [403: ErrorSchema.self]) return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func tusUploadCreatePost(wildcard: String) async throws -> [String: JSONValue] { + internal func tusUploadCreatePost(wildcard: String) async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder( method: .post, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") let response = try await transport.send(try builder.build()) @@ -628,14 +629,14 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func tusUploadCreateSigned() async throws { + internal func tusUploadCreateSigned() async throws { var builder = HTTPRequestBuilder( method: .post, baseURL: baseURL, path: "/upload/resumable/sign/") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [:]) } - public func tusUploadCreateSignedPost(wildcard: String) async throws { + internal func tusUploadCreateSignedPost(wildcard: String) async throws { var builder = HTTPRequestBuilder( method: .post, baseURL: baseURL, path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") @@ -643,7 +644,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func tusUploadDelete(wildcard: String) async throws -> [String: JSONValue] { + internal func tusUploadDelete(wildcard: String) async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder( method: .delete, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))" ) @@ -652,7 +653,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func tusUploadDeleteSigned(wildcard: String) async throws { + internal func tusUploadDeleteSigned(wildcard: String) async throws { var builder = HTTPRequestBuilder( method: .delete, baseURL: baseURL, path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") @@ -660,7 +661,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func tusUploadGet(wildcard: String) async throws -> [String: JSONValue] { + internal func tusUploadGet(wildcard: String) async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder( method: .head, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") let response = try await transport.send(try builder.build()) @@ -668,7 +669,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func tusUploadGetSigned(wildcard: String) async throws { + internal func tusUploadGetSigned(wildcard: String) async throws { var builder = HTTPRequestBuilder( method: .head, baseURL: baseURL, path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") @@ -676,7 +677,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func tusUploadPart(wildcard: String) async throws -> [String: JSONValue] { + internal func tusUploadPart(wildcard: String) async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder( method: .put, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") let response = try await transport.send(try builder.build()) @@ -684,7 +685,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func tusUploadPartPatch(wildcard: String) async throws -> [String: JSONValue] { + internal func tusUploadPartPatch(wildcard: String) async throws -> [String: JSONValue] { var builder = HTTPRequestBuilder( method: .patch, baseURL: baseURL, path: "/upload/resumable/\(PathEncoding.segment(wildcard))") let response = try await transport.send(try builder.build()) @@ -692,7 +693,7 @@ public struct StorageOpenAPIClient: Sendable { return try JSONCoding.decoder.decode([String: JSONValue].self, from: response.body) } - public func tusUploadPartSigned(wildcard: String) async throws { + internal func tusUploadPartSigned(wildcard: String) async throws { var builder = HTTPRequestBuilder( method: .put, baseURL: baseURL, path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") @@ -700,7 +701,7 @@ public struct StorageOpenAPIClient: Sendable { try response.checkStatus(errorTypes: [:]) } - public func tusUploadPartSignedPatch(wildcard: String) async throws { + internal func tusUploadPartSignedPatch(wildcard: String) async throws { var builder = HTTPRequestBuilder( method: .patch, baseURL: baseURL, path: "/upload/resumable/sign/\(PathEncoding.segment(wildcard))") diff --git a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift index a1337df55..b860d8933 100644 --- a/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift +++ b/tools/openapi-codegen/Sources/OpenAPICodegenCore/SwiftEmitter.swift @@ -2,19 +2,34 @@ // SwiftEmitter.swift // +/// The access level generated declarations get. Defaults to the narrowest +/// (`.internal`) — callers opt into wider visibility explicitly rather than +/// generated code defaulting to `public`. +public enum AccessLevel: String, CaseIterable, Sendable { + case `public` + case package + case `internal` +} + public enum SwiftEmitter { - public static func emitModels(_ document: IRDocument) -> String { + public static func emitModels(_ document: IRDocument, accessLevel: AccessLevel = .internal) + -> String + { let errorSchemaNames = self.errorSchemaNames(in: document) + let importKeyword = accessLevel == .public ? "public import" : "import" var lines: [String] = [ "// Code generated by openapi-codegen. DO NOT EDIT.", "", "import Foundation", - "public import HTTPRuntime", + "\(importKeyword) HTTPRuntime", "", ] for schema in document.schemas.sorted(by: { $0.name < $1.name }) { - lines.append(emitSchema(schema, isError: errorSchemaNames.contains(schema.name))) + lines.append( + emitSchema( + schema, isError: errorSchemaNames.contains(schema.name), accessLevel: accessLevel) + ) lines.append("") } return lines.joined(separator: "\n") @@ -32,27 +47,30 @@ public enum SwiftEmitter { return names } - static func emitSchema(_ schema: IRSchema, isError: Bool) -> String { + static func emitSchema(_ schema: IRSchema, isError: Bool, accessLevel: AccessLevel) -> String { let typeName = SwiftNames.typeName(schema.name) switch schema.kind { case .object(let properties): - return emitStruct(named: typeName, properties: properties, isError: isError) + return emitStruct( + named: typeName, properties: properties, isError: isError, accessLevel: accessLevel) case .stringEnum(let cases): - return emitStringEnum(named: typeName, cases: cases, isError: isError) + return emitStringEnum( + named: typeName, cases: cases, isError: isError, accessLevel: accessLevel) case .union(let cases): - return emitUnion(named: typeName, cases: cases, isError: isError) + return emitUnion(named: typeName, cases: cases, isError: isError, accessLevel: accessLevel) } } - static func emitStruct(named typeName: String, properties: [IRProperty], isError: Bool) -> String - { + static func emitStruct( + named typeName: String, properties: [IRProperty], isError: Bool, accessLevel: AccessLevel + ) -> String { let conformances = (["Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) .joined(separator: ", ") - var lines = ["public struct \(typeName): \(conformances) {"] + var lines = ["\(accessLevel.rawValue) struct \(typeName): \(conformances) {"] for property in properties { let name = SwiftNames.propertyName(property.name) let type = SwiftNames.typeReference(property.type, isOptional: property.isOptional) - lines.append(" public var \(name): \(type)") + lines.append(" \(accessLevel.rawValue) var \(name): \(type)") } lines.append("") lines.append(" enum CodingKeys: String, CodingKey {") @@ -65,11 +83,13 @@ public enum SwiftEmitter { return lines.joined(separator: "\n") } - static func emitStringEnum(named typeName: String, cases: [String], isError: Bool) -> String { + static func emitStringEnum( + named typeName: String, cases: [String], isError: Bool, accessLevel: AccessLevel + ) -> String { let conformances = (["String", "Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) .joined(separator: ", ") - var lines = ["public enum \(typeName): \(conformances) {"] + var lines = ["\(accessLevel.rawValue) enum \(typeName): \(conformances) {"] for value in cases { lines.append(" case \(SwiftNames.propertyName(value)) = \"\(value)\"") } @@ -77,17 +97,19 @@ public enum SwiftEmitter { return lines.joined(separator: "\n") } - static func emitUnion(named typeName: String, cases: [IRUnionCase], isError: Bool) -> String { + static func emitUnion( + named typeName: String, cases: [IRUnionCase], isError: Bool, accessLevel: AccessLevel + ) -> String { let conformances = (["Codable", "Sendable", "Hashable"] + (isError ? ["APIError"] : [])) .joined(separator: ", ") - var lines = ["public enum \(typeName): \(conformances) {"] + var lines = ["\(accessLevel.rawValue) enum \(typeName): \(conformances) {"] for unionCase in cases { let caseName = SwiftNames.propertyName(unionCase.name) let typeRef = SwiftNames.typeReference(unionCase.type, isOptional: false) lines.append(" case \(caseName)(\(typeRef))") } lines.append("") - lines.append(" public init(from decoder: any Decoder) throws {") + lines.append(" \(accessLevel.rawValue) init(from decoder: any Decoder) throws {") lines.append(" let container = try decoder.singleValueContainer()") for unionCase in cases { let caseName = SwiftNames.propertyName(unionCase.name) @@ -105,7 +127,7 @@ public enum SwiftEmitter { lines.append(" )") lines.append(" }") lines.append("") - lines.append(" public func encode(to encoder: any Encoder) throws {") + lines.append(" \(accessLevel.rawValue) func encode(to encoder: any Encoder) throws {") lines.append(" var container = encoder.singleValueContainer()") lines.append(" switch self {") for unionCase in cases { @@ -120,32 +142,39 @@ public enum SwiftEmitter { // MARK: - Client - public static func emitClient(_ document: IRDocument, clientName: String) -> String { + public static func emitClient( + _ document: IRDocument, clientName: String, accessLevel: AccessLevel = .internal + ) -> String { let errorSchemaNames = self.errorSchemaNames(in: document) + let importKeyword = accessLevel == .public ? "public import" : "import" var lines: [String] = [ "// Code generated by openapi-codegen. DO NOT EDIT.", "", - "public import Foundation", - "public import HTTPRuntime", + "\(importKeyword) Foundation", + "\(importKeyword) HTTPRuntime", "", - "public struct \(clientName): Sendable {", + "\(accessLevel.rawValue) struct \(clientName): Sendable {", " private let baseURL: URL", " private let transport: any HTTPTransport", "", - " public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) {", + " \(accessLevel.rawValue) init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) {", " self.baseURL = baseURL", " self.transport = transport", " }", ] for operation in document.operations.sorted(by: { $0.operationId < $1.operationId }) { lines.append("") - lines.append(contentsOf: emitOperation(operation, errorSchemaNames: errorSchemaNames)) + lines.append( + contentsOf: emitOperation( + operation, errorSchemaNames: errorSchemaNames, accessLevel: accessLevel)) } lines.append("}") return lines.joined(separator: "\n") } - static func emitOperation(_ operation: IROperation, errorSchemaNames: Set) -> [String] { + static func emitOperation( + _ operation: IROperation, errorSchemaNames: Set, accessLevel: AccessLevel + ) -> [String] { let methodName = SwiftNames.propertyName(operation.operationId) let parameters = operation.parameters.sorted { lhs, rhs in lhs.isOptional != rhs.isOptional ? !lhs.isOptional : lhs.name < rhs.name @@ -162,7 +191,7 @@ public enum SwiftEmitter { let returnType = returnTypeReference(for: successResponse) var lines: [String] = [ - " public func \(methodName)(\(signatureParts.joined(separator: ", "))) async throws" + " \(accessLevel.rawValue) func \(methodName)(\(signatureParts.joined(separator: ", "))) async throws" + (returnType.map { " -> \($0)" } ?? "") + " {", " var builder = HTTPRequestBuilder(method: .\(operation.method.rawValue), baseURL: baseURL, path: \"\(pathTemplate(operation))\")", ] @@ -248,22 +277,22 @@ public enum SwiftEmitter { " builder.setBody(.data(try JSONCoding.encoder.encode(payload)))", ] case .multipart(let fields): - var lines = [" var formData = MultipartFormData()"] + var formDataExpression = "MultipartFormData()" for field in fields { let name = SwiftNames.propertyName(field.name) if field.isFile { - lines.append( - " formData.append(MultipartFormData.Part(name: \"\(field.name)\", filename: \(name).lastPathComponent, contentType: \"application/octet-stream\", source: .file(\(name))))" - ) + formDataExpression += + "\n .addFile(name: \"\(field.name)\", fileURL: \(name), fileName: \(name).lastPathComponent, mimeType: \"application/octet-stream\")" } else { - lines.append( - " formData.append(MultipartFormData.Part(name: \"\(field.name)\", source: .data(Data(String(describing: \(name)).utf8))))" - ) + formDataExpression += + "\n .addText(name: \"\(field.name)\", value: String(describing: \(name)))" } } - lines.append(" builder.setHeader(\"Content-Type\", formData.contentType)") - lines.append(" builder.setBody(.multipart(formData))") - return lines + return [ + " let formData = \(formDataExpression)", + " builder.setHeader(\"Content-Type\", formData.contentType)", + " builder.setBody(.multipart(formData))", + ] } } diff --git a/tools/openapi-codegen/Sources/openapi-codegen/main.swift b/tools/openapi-codegen/Sources/openapi-codegen/main.swift index ef220d3e5..d86b84006 100644 --- a/tools/openapi-codegen/Sources/openapi-codegen/main.swift +++ b/tools/openapi-codegen/Sources/openapi-codegen/main.swift @@ -19,7 +19,18 @@ struct OpenAPICodegen: ParsableCommand { @Option(help: "Name of the generated module (used to derive the client type name).") var module: String + @Option( + help: + "Access level for generated declarations: public, package, or internal (default: internal).") + var accessLevel: String = "internal" + func run() throws { + guard let accessLevel = AccessLevel(rawValue: accessLevel) else { + throw ValidationError( + "--access-level must be one of: \(AccessLevel.allCases.map(\.rawValue).joined(separator: ", "))" + ) + } + let specURL = URL(fileURLWithPath: spec) let outputURL = URL(fileURLWithPath: output, isDirectory: true) @@ -29,12 +40,13 @@ struct OpenAPICodegen: ParsableCommand { try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) - let models = SwiftEmitter.emitModels(irDocument) + let models = SwiftEmitter.emitModels(irDocument, accessLevel: accessLevel) try models.write( to: outputURL.appendingPathComponent("Models.swift"), atomically: true, encoding: .utf8) let clientName = "\(module)Client" - let client = SwiftEmitter.emitClient(irDocument, clientName: clientName) + let client = SwiftEmitter.emitClient( + irDocument, clientName: clientName, accessLevel: accessLevel) try client.write( to: outputURL.appendingPathComponent("\(clientName).swift"), atomically: true, encoding: .utf8) diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift index 7f0359a70..1b94aca92 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ClientEmitterTests.swift @@ -37,19 +37,19 @@ struct ClientEmitterTests { #""" // Code generated by openapi-codegen. DO NOT EDIT. - public import Foundation - public import HTTPRuntime + import Foundation + import HTTPRuntime - public struct StorageOpenAPIClient: Sendable { + internal struct StorageOpenAPIClient: Sendable { private let baseURL: URL private let transport: any HTTPTransport - public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + internal init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { self.baseURL = baseURL self.transport = transport } - public func getBucket(bucketId: String) async throws -> BucketSchema { + internal func getBucket(bucketId: String) async throws -> BucketSchema { var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [404: ErrorSchema.self]) @@ -89,23 +89,23 @@ struct ClientEmitterTests { #""" // Code generated by openapi-codegen. DO NOT EDIT. - public import Foundation - public import HTTPRuntime + import Foundation + import HTTPRuntime - public struct StorageOpenAPIClient: Sendable { + internal struct StorageOpenAPIClient: Sendable { private let baseURL: URL private let transport: any HTTPTransport - public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + internal init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { self.baseURL = baseURL self.transport = transport } - public func createObject(bucketId: String, file: URL, cacheControl: String) async throws { + internal func createObject(bucketId: String, file: URL, cacheControl: String) async throws { var builder = HTTPRequestBuilder(method: .post, baseURL: baseURL, path: "/object/\(PathEncoding.segment(bucketId))") - var formData = MultipartFormData() - formData.append(MultipartFormData.Part(name: "file", filename: file.lastPathComponent, contentType: "application/octet-stream", source: .file(file))) - formData.append(MultipartFormData.Part(name: "cacheControl", source: .data(Data(String(describing: cacheControl).utf8)))) + let formData = MultipartFormData() + .addFile(name: "file", fileURL: file, fileName: file.lastPathComponent, mimeType: "application/octet-stream") + .addText(name: "cacheControl", value: String(describing: cacheControl)) builder.setHeader("Content-Type", formData.contentType) builder.setBody(.multipart(formData)) let response = try await transport.send(try builder.build()) @@ -140,19 +140,19 @@ struct ClientEmitterTests { #""" // Code generated by openapi-codegen. DO NOT EDIT. - public import Foundation - public import HTTPRuntime + import Foundation + import HTTPRuntime - public struct StorageOpenAPIClient: Sendable { + internal struct StorageOpenAPIClient: Sendable { private let baseURL: URL private let transport: any HTTPTransport - public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + internal init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { self.baseURL = baseURL self.transport = transport } - public func download(bucketId: String) async throws -> AsyncThrowingStream { + internal func download(bucketId: String) async throws -> AsyncThrowingStream { var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/object/\(PathEncoding.segment(bucketId))") let stream = try await transport.stream(try builder.build()) guard stream.head.isSuccess else { @@ -190,22 +190,22 @@ struct ClientEmitterTests { let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") assertInlineSnapshot(of: output, as: .lines) { - #""" + """ // Code generated by openapi-codegen. DO NOT EDIT. - public import Foundation - public import HTTPRuntime + import Foundation + import HTTPRuntime - public struct StorageOpenAPIClient: Sendable { + internal struct StorageOpenAPIClient: Sendable { private let baseURL: URL private let transport: any HTTPTransport - public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + internal init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { self.baseURL = baseURL self.transport = transport } - public func listBuckets(sortColumn: BucketListSortColumn? = nil) async throws -> BucketSchema { + internal func listBuckets(sortColumn: BucketListSortColumn? = nil) async throws -> BucketSchema { var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/bucket") builder.addQuery("sortColumn", sortColumn?.rawValue) let response = try await transport.send(try builder.build()) @@ -213,7 +213,7 @@ struct ClientEmitterTests { return try JSONCoding.decoder.decode(BucketSchema.self, from: response.body) } } - """# + """ } } @@ -238,29 +238,29 @@ struct ClientEmitterTests { let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") assertInlineSnapshot(of: output, as: .lines) { - #""" + """ // Code generated by openapi-codegen. DO NOT EDIT. - public import Foundation - public import HTTPRuntime + import Foundation + import HTTPRuntime - public struct StorageOpenAPIClient: Sendable { + internal struct StorageOpenAPIClient: Sendable { private let baseURL: URL private let transport: any HTTPTransport - public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + internal init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { self.baseURL = baseURL self.transport = transport } - public func listItems(tags: [String]? = nil) async throws { + internal func listItems(tags: [String]? = nil) async throws { var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/items") builder.addQuery("tags", tags) let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [:]) } } - """# + """ } } @@ -284,6 +284,54 @@ struct ClientEmitterTests { let output = SwiftEmitter.emitClient(document, clientName: "StorageOpenAPIClient") + assertInlineSnapshot(of: output, as: .lines) { + """ + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + import HTTPRuntime + + internal struct StorageOpenAPIClient: Sendable { + private let baseURL: URL + private let transport: any HTTPTransport + + internal init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + internal func listItems(ids: [Int]? = nil) async throws { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/items") + builder.addQuery("ids", ids?.map(String.init)) + let response = try await transport.send(try builder.build()) + try response.checkStatus(errorTypes: [:]) + } + } + """ + } + } + + @Test + func emitsPublicAccessLevelWhenRequested() { + let document = IRDocument( + schemas: [], + operations: [ + IROperation( + operationId: "getBucket", + method: .get, + path: "/bucket/{bucketId}", + parameters: [ + IRParameter(name: "bucketId", location: .path, type: .string, isOptional: false) + ], + requestBody: nil, + responses: [IRResponse(statusCode: 200, isError: false, body: .none)] + ) + ] + ) + + let output = SwiftEmitter.emitClient( + document, clientName: "StorageOpenAPIClient", accessLevel: .public) + assertInlineSnapshot(of: output, as: .lines) { #""" // Code generated by openapi-codegen. DO NOT EDIT. @@ -300,9 +348,8 @@ struct ClientEmitterTests { self.transport = transport } - public func listItems(ids: [Int]? = nil) async throws { - var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/items") - builder.addQuery("ids", ids?.map(String.init)) + public func getBucket(bucketId: String) async throws { + var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [:]) } diff --git a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift index 3a869df8d..9ce47082c 100644 --- a/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift +++ b/tools/openapi-codegen/Tests/OpenAPICodegenCoreTests/ModelEmitterTests.swift @@ -32,11 +32,11 @@ struct ModelEmitterTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - public import HTTPRuntime + import HTTPRuntime - public struct BucketSchema: Codable, Sendable, Hashable { - public var id: String - public var fileSizeLimit: Int? + internal struct BucketSchema: Codable, Sendable, Hashable { + internal var id: String + internal var fileSizeLimit: Int? enum CodingKeys: String, CodingKey { case id = "id" @@ -62,9 +62,9 @@ struct ModelEmitterTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - public import HTTPRuntime + import HTTPRuntime - public enum Visibility: String, Codable, Sendable, Hashable { + internal enum Visibility: String, Codable, Sendable, Hashable { case `public` = "public" case `private` = "private" } @@ -103,10 +103,10 @@ struct ModelEmitterTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - public import HTTPRuntime + import HTTPRuntime - public struct ErrorSchema: Codable, Sendable, Hashable, APIError { - public var message: String + internal struct ErrorSchema: Codable, Sendable, Hashable, APIError { + internal var message: String enum CodingKeys: String, CodingKey { case message = "message" @@ -139,13 +139,13 @@ struct ModelEmitterTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - public import HTTPRuntime + import HTTPRuntime - public enum BucketCreateFileSizeLimit: Codable, Sendable, Hashable { + internal enum BucketCreateFileSizeLimit: Codable, Sendable, Hashable { case integer(Int) case string(String) - public init(from decoder: any Decoder) throws { + internal init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(Int.self) { self = .integer(value) @@ -161,7 +161,7 @@ struct ModelEmitterTests { ) } - public func encode(to encoder: any Encoder) throws { + internal func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .integer(let value): try container.encode(value) @@ -173,4 +173,39 @@ struct ModelEmitterTests { """ } } + + @Test + func emitsPublicAccessLevelWhenRequested() { + let document = IRDocument( + schemas: [ + IRSchema( + name: "bucketSchema", + kind: .object(properties: [ + IRProperty(name: "id", type: .string, isOptional: false) + ]) + ) + ], + operations: [] + ) + + let output = SwiftEmitter.emitModels(document, accessLevel: .public) + + assertInlineSnapshot(of: output, as: .lines) { + """ + // Code generated by openapi-codegen. DO NOT EDIT. + + import Foundation + public import HTTPRuntime + + public struct BucketSchema: Codable, Sendable, Hashable { + public var id: String + + enum CodingKeys: String, CodingKey { + case id = "id" + } + } + + """ + } + } } diff --git a/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift b/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift index 09a7ef9ff..43827ba8b 100644 --- a/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift +++ b/tools/openapi-codegen/Tests/openapi-codegenTests/EndToEndTests.swift @@ -72,11 +72,11 @@ struct EndToEndTests { // Code generated by openapi-codegen. DO NOT EDIT. import Foundation - public import HTTPRuntime + import HTTPRuntime - public struct BucketSchema: Codable, Sendable, Hashable { - public var id: String - public var `public`: Bool + internal struct BucketSchema: Codable, Sendable, Hashable { + internal var id: String + internal var `public`: Bool enum CodingKeys: String, CodingKey { case id = "id" @@ -84,8 +84,8 @@ struct EndToEndTests { } } - public struct ErrorSchema: Codable, Sendable, Hashable, APIError { - public var message: String + internal struct ErrorSchema: Codable, Sendable, Hashable, APIError { + internal var message: String enum CodingKeys: String, CodingKey { case message = "message" @@ -98,19 +98,19 @@ struct EndToEndTests { #""" // Code generated by openapi-codegen. DO NOT EDIT. - public import Foundation - public import HTTPRuntime + import Foundation + import HTTPRuntime - public struct StorageOpenAPIClient: Sendable { + internal struct StorageOpenAPIClient: Sendable { private let baseURL: URL private let transport: any HTTPTransport - public init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { + internal init(baseURL: URL, transport: any HTTPTransport = URLSessionTransport()) { self.baseURL = baseURL self.transport = transport } - public func getBucket(bucketId: String) async throws -> BucketSchema { + internal func getBucket(bucketId: String) async throws -> BucketSchema { var builder = HTTPRequestBuilder(method: .get, baseURL: baseURL, path: "/bucket/\(PathEncoding.segment(bucketId))") let response = try await transport.send(try builder.build()) try response.checkStatus(errorTypes: [404: ErrorSchema.self]) From 78facb76fb64cdcf9f96ee1ac706f0dc0fcdef86 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 06:36:31 -0300 Subject: [PATCH 43/44] refactor(storage): move generated OpenAPI client into Sources/Storage/Generated Drop the separate StorageOpenAPI SPM target and generate the client directly into Sources/Storage/Generated with internal access control, so the generated types share a module with the hand-written Storage API. Fixes a same-module HTTPRuntime/Helpers type name collision (HTTPRequest/HTTPResponse/HTTPError) surfaced by the move in the 3 relocated test files by qualifying with the HTTPRuntime module name. --- Package.swift | 17 +++----------- .../Generated}/Models.swift | 0 .../Generated}/StorageOpenAPIClient.swift | 0 .../xcshareddata/xcschemes/Supabase.xcscheme | 11 --------- .../BucketOperationsTests.swift | 23 +++++++++++++------ .../ErrorDecodingTests.swift | 8 +++---- .../ObjectUploadTests.swift | 8 ++++--- 7 files changed, 28 insertions(+), 39 deletions(-) rename Sources/{StorageOpenAPI => Storage/Generated}/Models.swift (100%) rename Sources/{StorageOpenAPI => Storage/Generated}/StorageOpenAPIClient.swift (100%) rename Tests/{StorageOpenAPITests => StorageTests}/BucketOperationsTests.swift (58%) rename Tests/{StorageOpenAPITests => StorageTests}/ErrorDecodingTests.swift (81%) rename Tests/{StorageOpenAPITests => StorageTests}/ObjectUploadTests.swift (84%) diff --git a/Package.swift b/Package.swift index ee55bed4e..7959fbcf5 100644 --- a/Package.swift +++ b/Package.swift @@ -179,6 +179,7 @@ let package = Package( .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "HTTPTypes", package: "swift-http-types"), "Helpers", + "HTTPRuntime", ] ), .testTarget( @@ -190,6 +191,7 @@ let package = Package( "Mocker", "TestHelpers", "Storage", + "HTTPRuntime", ], exclude: [ "__Snapshots__" @@ -199,19 +201,6 @@ let package = Package( .process("Fixtures"), ] ), - .target( - name: "StorageOpenAPI", - dependencies: [ - "HTTPRuntime" - ] - ), - .testTarget( - name: "StorageOpenAPITests", - dependencies: [ - "StorageOpenAPI", - "HTTPRuntime", - ] - ), .target( name: "Supabase", dependencies: [ @@ -253,7 +242,7 @@ let package = Package( // production targets. Everything else stays pinned to v5 until its migration // phase lands (see SDK-435). let swift6TestTargets: Set = [ - "SupabaseTests", "HelpersTests", "HTTPRuntimeTests", "StorageOpenAPITests", + "SupabaseTests", "HelpersTests", "HTTPRuntimeTests", ] for target in package.targets { diff --git a/Sources/StorageOpenAPI/Models.swift b/Sources/Storage/Generated/Models.swift similarity index 100% rename from Sources/StorageOpenAPI/Models.swift rename to Sources/Storage/Generated/Models.swift diff --git a/Sources/StorageOpenAPI/StorageOpenAPIClient.swift b/Sources/Storage/Generated/StorageOpenAPIClient.swift similarity index 100% rename from Sources/StorageOpenAPI/StorageOpenAPIClient.swift rename to Sources/Storage/Generated/StorageOpenAPIClient.swift diff --git a/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme b/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme index df59b80f2..f494d3cb4 100644 --- a/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme +++ b/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme @@ -221,17 +221,6 @@ ReferencedContainer = "container:"> - - - - diff --git a/Tests/StorageOpenAPITests/BucketOperationsTests.swift b/Tests/StorageTests/BucketOperationsTests.swift similarity index 58% rename from Tests/StorageOpenAPITests/BucketOperationsTests.swift rename to Tests/StorageTests/BucketOperationsTests.swift index 7d04e3145..7d5a4a285 100644 --- a/Tests/StorageOpenAPITests/BucketOperationsTests.swift +++ b/Tests/StorageTests/BucketOperationsTests.swift @@ -9,19 +9,27 @@ import Foundation import HTTPRuntime import Testing -@testable import StorageOpenAPI +@testable import Storage /// A fake `HTTPTransport` that answers every request from a caller-supplied /// closure. Shared by the StorageOpenAPI test suite so no test ever touches /// the network. -struct FakeTransport: HTTPTransport { - var onSend: @Sendable (HTTPRequest) throws -> HTTPResponse - - func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws -> HTTPResponse { +/// +/// `HTTPRequest`/`HTTPResponse` are qualified with the `HTTPRuntime` module +/// name here because `@testable import Storage` also re-exports `Helpers` +/// (see `Sources/Storage/Exports.swift`), which declares its own same-named +/// package types — an ambiguity that only surfaces when both modules are +/// imported side by side, as in this file. +struct FakeTransport: HTTPRuntime.HTTPTransport { + var onSend: @Sendable (HTTPRuntime.HTTPRequest) throws -> HTTPRuntime.HTTPResponse + + func send(_ request: HTTPRuntime.HTTPRequest, uploadProgress: ProgressHandler?) async throws + -> HTTPRuntime.HTTPResponse + { try onSend(request) } - func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { + func stream(_ request: HTTPRuntime.HTTPRequest) async throws -> HTTPResponseStream { let response = try onSend(request) return HTTPResponseStream( head: response.head, @@ -41,7 +49,8 @@ struct BucketOperationsTests { let responseBody = Data(#"{"id":"avatars","name":"avatars","public":true}"#.utf8) let transport = FakeTransport { request in #expect(request.url.path.hasSuffix("/bucket/avatars")) - return HTTPResponse(head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) + return HTTPRuntime.HTTPResponse( + head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) } let client = StorageOpenAPIClient( baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) diff --git a/Tests/StorageOpenAPITests/ErrorDecodingTests.swift b/Tests/StorageTests/ErrorDecodingTests.swift similarity index 81% rename from Tests/StorageOpenAPITests/ErrorDecodingTests.swift rename to Tests/StorageTests/ErrorDecodingTests.swift index 4b0bb5326..a5988c646 100644 --- a/Tests/StorageOpenAPITests/ErrorDecodingTests.swift +++ b/Tests/StorageTests/ErrorDecodingTests.swift @@ -9,7 +9,7 @@ import Foundation import HTTPRuntime import Testing -@testable import StorageOpenAPI +@testable import Storage @Suite struct ErrorDecodingTests { @@ -22,7 +22,7 @@ struct ErrorDecodingTests { let errorBody = Data( #"{"message":"Access denied","error":"Forbidden","statusCode":"403"}"#.utf8) let transport = FakeTransport { _ in - HTTPResponse(head: HTTPResponseHead(status: 403, headers: [:]), body: errorBody) + HTTPRuntime.HTTPResponse(head: HTTPResponseHead(status: 403, headers: [:]), body: errorBody) } let client = StorageOpenAPIClient( baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) @@ -35,12 +35,12 @@ struct ErrorDecodingTests { @Test func bucketGetThrowsUnexpectedStatusOnUnmappedError() async throws { let transport = FakeTransport { _ in - HTTPResponse(head: HTTPResponseHead(status: 404, headers: [:]), body: Data()) + HTTPRuntime.HTTPResponse(head: HTTPResponseHead(status: 404, headers: [:]), body: Data()) } let client = StorageOpenAPIClient( baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) - await #expect(throws: HTTPError.self) { + await #expect(throws: HTTPRuntime.HTTPError.self) { _ = try await client.bucketGet(bucketId: "missing") } } diff --git a/Tests/StorageOpenAPITests/ObjectUploadTests.swift b/Tests/StorageTests/ObjectUploadTests.swift similarity index 84% rename from Tests/StorageOpenAPITests/ObjectUploadTests.swift rename to Tests/StorageTests/ObjectUploadTests.swift index dc9b85959..82eddc2d8 100644 --- a/Tests/StorageOpenAPITests/ObjectUploadTests.swift +++ b/Tests/StorageTests/ObjectUploadTests.swift @@ -9,7 +9,7 @@ import Foundation import HTTPRuntime import Testing -@testable import StorageOpenAPI +@testable import Storage @Suite struct ObjectUploadTests { @@ -28,10 +28,12 @@ struct ObjectUploadTests { guard case .none = request.body else { Issue.record( "expected no request body to be set (multipart body generation is not yet implemented)") - return HTTPResponse(head: HTTPResponseHead(status: 500, headers: [:]), body: Data()) + return HTTPRuntime.HTTPResponse( + head: HTTPResponseHead(status: 500, headers: [:]), body: Data()) } let responseBody = Data(#"{"Key":"avatars/a.txt"}"#.utf8) - return HTTPResponse(head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) + return HTTPRuntime.HTTPResponse( + head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) } let client = StorageOpenAPIClient( baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) From 2bab4f73420272d29993e9c24e785e5b2106a617 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 09:38:51 -0300 Subject: [PATCH 44/44] feat(storage): wire listBuckets/getBucket to the generated client Starts wiring StorageBucketApi's read methods to the generated StorageGeneratedClient instead of hand-rolled requests, with a backward-compatible error translation layer (ErrorSchema/HTTPError -> StorageError). Fixes along the way: - StorageApi's generatedClient transport now merges this instance's dynamic headers (apikey/Authorization/custom setHeader values) into every request via a new HeaderInjectingTransport wrapper - the generated client builds requests with empty headers otherwise, which was sending unauthenticated requests. - Updated the remaining StorageHTTPSession(fetch:upload:) call sites (tests) for the uploadFromFile/bytes parameters added earlier. - Renamed StorageOpenAPIClient -> StorageGeneratedClient in the 3 moved test files to match the regenerated client's actual name. --- Sources/HTTPRuntime/URLSessionTransport.swift | 75 +++++++++++++---- ...ent.swift => StorageGeneratedClient.swift} | 2 +- Sources/Storage/StorageApi.swift | 84 +++++++++++++++++++ Sources/Storage/StorageBucketApi.swift | 21 ++--- Sources/Storage/StorageHTTPClient.swift | 24 +++++- Sources/Storage/Types+Generated.swift | 14 ++++ Sources/Supabase/SupabaseClient.swift | 24 +++++- .../StorageTests/BucketOperationsTests.swift | 2 +- Tests/StorageTests/ErrorDecodingTests.swift | 4 +- Tests/StorageTests/ObjectUploadTests.swift | 4 +- .../StorageTests/StorageBucketAPITests.swift | 4 +- Tests/StorageTests/StorageFileAPITests.swift | 4 +- Tests/StorageTests/SupabaseStorageTests.swift | 4 +- 13 files changed, 227 insertions(+), 39 deletions(-) rename Sources/Storage/Generated/{StorageOpenAPIClient.swift => StorageGeneratedClient.swift} (99%) create mode 100644 Sources/Storage/Types+Generated.swift diff --git a/Sources/HTTPRuntime/URLSessionTransport.swift b/Sources/HTTPRuntime/URLSessionTransport.swift index 34ab9540e..166e5db78 100644 --- a/Sources/HTTPRuntime/URLSessionTransport.swift +++ b/Sources/HTTPRuntime/URLSessionTransport.swift @@ -24,19 +24,69 @@ package import Foundation /// that complete out-of-process. That path is documented as a known limitation /// rather than faked here. package struct URLSessionTransport: HTTPTransport { - private let session: URLSession + private let _data: + @Sendable (URLRequest, (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) + + private let _uploadFromBodyData: + @Sendable (URLRequest, Data, (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) + + private let _uploadFromFile: + @Sendable (URLRequest, URL, (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) + + private let _bytes: + @Sendable (URLRequest, (any URLSessionTaskDelegate)?) async throws -> ( + URLSession.AsyncBytes, URLResponse + ) + + package init( + data: + @Sendable @escaping (URLRequest, (any URLSessionTaskDelegate)?) async throws -> ( + Data, URLResponse + ), + uploadFromBodyData: + @Sendable @escaping ( + URLRequest, Data, (any URLSessionTaskDelegate)? + ) async throws -> (Data, URLResponse), + uploadFromFile: + @Sendable @escaping ( + URLRequest, URL, (any URLSessionTaskDelegate)? + ) async throws -> (Data, URLResponse), + bytes: + @Sendable @escaping ( + URLRequest, (any URLSessionTaskDelegate)? + ) async throws -> (URLSession.AsyncBytes, URLResponse) + ) { + self._data = data + self._uploadFromBodyData = uploadFromBodyData + self._uploadFromFile = uploadFromFile + self._bytes = bytes + } package init(configuration: URLSessionConfiguration = .default) { - self.session = URLSession(configuration: configuration) + self.init(session: URLSession(configuration: configuration)) } package init(session: URLSession) { - self.session = session + self.init( + data: { request, delegate in + try await session.data(for: request, delegate: delegate) + }, + uploadFromBodyData: { request, data, delegate in + try await session.upload(for: request, from: data, delegate: delegate) + }, + uploadFromFile: { request, fileURL, delegate in + try await session.upload(for: request, fromFile: fileURL, delegate: delegate) + }, + bytes: { request, delegate in + try await session.bytes(for: request, delegate: delegate) + } + ) } - package func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws - -> HTTPResponse - { + package func send( + _ request: HTTPRequest, + uploadProgress: ProgressHandler? + ) async throws -> HTTPResponse { let urlRequest = try Self.makeURLRequest(request) let delegate = uploadProgress.map { ProgressDelegate(onProgress: $0) } @@ -45,20 +95,17 @@ package struct URLSessionTransport: HTTPTransport { do { switch request.body { case nil: - (data, response) = try await session.data(for: urlRequest, delegate: delegate) + (data, response) = try await _data(urlRequest, delegate) case .data(let payload): - (data, response) = try await session.upload( - for: urlRequest, from: payload, delegate: delegate) + (data, response) = try await _uploadFromBodyData(urlRequest, payload, delegate) case .file(let fileURL): - (data, response) = try await session.upload( - for: urlRequest, fromFile: fileURL, delegate: delegate) + (data, response) = try await _uploadFromFile(urlRequest, fileURL, delegate) case .multipart(let form): let fileURL = try form.buildToTempFile() defer { try? FileManager.default.removeItem(at: fileURL) } var withBoundary = urlRequest withBoundary.setValue(form.contentType, forHTTPHeaderField: "Content-Type") - (data, response) = try await session.upload( - for: withBoundary, fromFile: fileURL, delegate: delegate) + (data, response) = try await _uploadFromFile(withBoundary, fileURL, delegate) } } catch { throw HTTPError.transport(error) @@ -71,7 +118,7 @@ package struct URLSessionTransport: HTTPTransport { let bytes: URLSession.AsyncBytes let response: URLResponse do { - (bytes, response) = try await session.bytes(for: urlRequest) + (bytes, response) = try await _bytes(urlRequest, nil) } catch { throw HTTPError.transport(error) } diff --git a/Sources/Storage/Generated/StorageOpenAPIClient.swift b/Sources/Storage/Generated/StorageGeneratedClient.swift similarity index 99% rename from Sources/Storage/Generated/StorageOpenAPIClient.swift rename to Sources/Storage/Generated/StorageGeneratedClient.swift index 509a43be6..97d788fcb 100644 --- a/Sources/Storage/Generated/StorageOpenAPIClient.swift +++ b/Sources/Storage/Generated/StorageGeneratedClient.swift @@ -3,7 +3,7 @@ import Foundation import HTTPRuntime -internal struct StorageOpenAPIClient: Sendable { +internal struct StorageGeneratedClient: Sendable { private let baseURL: URL private let transport: any HTTPTransport diff --git a/Sources/Storage/StorageApi.swift b/Sources/Storage/StorageApi.swift index 9c4c7aecf..27dac91d1 100644 --- a/Sources/Storage/StorageApi.swift +++ b/Sources/Storage/StorageApi.swift @@ -1,5 +1,6 @@ import ConcurrencyExtras import Foundation +import HTTPRuntime import HTTPTypes #if canImport(FoundationNetworking) @@ -36,6 +37,8 @@ public class StorageApi: @unchecked Sendable { private let mutableState: LockIsolated private let http: any HTTPClientType + let generatedClient: StorageGeneratedClient + /// Creates a ``StorageApi`` with the given configuration. /// /// Subclasses call this initializer via `super.init(configuration:)`. @@ -83,6 +86,28 @@ public class StorageApi: @unchecked Sendable { fetch: configuration.session.fetch, interceptors: interceptors ) + + let mutableState = self.mutableState + generatedClient = StorageGeneratedClient( + baseURL: configuration.url, + transport: HeaderInjectingTransport( + inner: URLSessionTransport( + data: { [session = configuration.session] request, _ in + try await session.fetch(request) + }, + uploadFromBodyData: { [session = configuration.session] request, data, _ in + try await session.upload(request, data) + }, + uploadFromFile: { [session = configuration.session] request, fileURL, delegate in + try await session.uploadFromFile(request, fileURL, delegate) + }, + bytes: { [session = configuration.session] request, delegate in + try await session.bytes(request, delegate) + } + ), + headers: { mutableState.headers } + ) + ) } /// Sets an HTTP header that will be included in all subsequent requests made by this instance. @@ -125,6 +150,65 @@ public class StorageApi: @unchecked Sendable { return response } + + /// Wraps an async operation that may throw an ``ErrorSchema`` or ``HTTPRuntime.HTTPError`` and converts + /// it into a ``StorageError`` if applicable. This is used to maintain backward compatibility with the previous error handling behavior of the Storage API. + func withBackwardCompatibleErrorHandling(_ operation: () async throws -> T) async throws -> T { + // TODO: check for other error types that may need to be converted for backward compatibility. + do { + return try await operation() + } catch let error as ErrorSchema { + throw StorageError( + statusCode: error.statusCode, + message: error.message, + error: error.error + ) + } catch let error as HTTPRuntime.HTTPError { + if case .unexpectedStatus(let status, let body) = error { + if let error = try? configuration.decoder.decode(StorageError.self, from: body) { + throw error + } + + throw StorageError( + statusCode: "\(status)", + message: String(data: body, encoding: .utf8) ?? "Unknown error" + ) + } else { + throw error + } + } catch { + throw error + } + } +} + +/// Wraps an `HTTPTransport`, merging this client's dynamic per-instance +/// headers (`apikey`/`Authorization`/custom `setHeader` values) into every +/// outgoing request before delegating. The generated client builds requests +/// with empty headers by default — this gives calls that go through +/// `generatedClient` the same auth/header behavior as the hand-written +/// `execute(_:)` path, which merges `mutableState.headers` explicitly. +private struct HeaderInjectingTransport: HTTPTransport { + let inner: any HTTPTransport + let headers: @Sendable () -> [String: String] + + func send(_ request: HTTPRuntime.HTTPRequest, uploadProgress: ProgressHandler?) async throws + -> HTTPRuntime.HTTPResponse + { + try await inner.send(injectingHeaders(into: request), uploadProgress: uploadProgress) + } + + func stream(_ request: HTTPRuntime.HTTPRequest) async throws -> HTTPResponseStream { + try await inner.stream(injectingHeaders(into: request)) + } + + private func injectingHeaders(into request: HTTPRuntime.HTTPRequest) -> HTTPRuntime.HTTPRequest { + var request = request + for (key, value) in headers() where request.headers[key] == nil { + request.headers[key] = value + } + return request + } } extension Helpers.HTTPRequest { diff --git a/Sources/Storage/StorageBucketApi.swift b/Sources/Storage/StorageBucketApi.swift index 63e78c12a..793cbb202 100644 --- a/Sources/Storage/StorageBucketApi.swift +++ b/Sources/Storage/StorageBucketApi.swift @@ -35,13 +35,9 @@ public class StorageBucketApi: StorageApi, @unchecked Sendable { /// - Returns: An array of ``Bucket`` objects, one for each bucket in the project. /// - Throws: ``StorageError`` if the request fails or the caller is not authorized. public func listBuckets() async throws -> [Bucket] { - try await execute( - HTTPRequest( - url: configuration.url.appendingPathComponent("bucket"), - method: .get - ) - ) - .decoded(decoder: configuration.decoder) + try await withBackwardCompatibleErrorHandling { + try await generatedClient.bucketList().map(Bucket.init(generated:)) + } } /// Retrieves the details of an existing Storage bucket. @@ -50,13 +46,10 @@ public class StorageBucketApi: StorageApi, @unchecked Sendable { /// - Returns: The ``Bucket`` with the given identifier. /// - Throws: ``StorageError`` if the bucket does not exist or the caller is not authorized. public func getBucket(_ id: String) async throws -> Bucket { - try await execute( - HTTPRequest( - url: configuration.url.appendingPathComponent("bucket/\(id)"), - method: .get - ) - ) - .decoded(decoder: configuration.decoder) + try await withBackwardCompatibleErrorHandling { + let generated = try await generatedClient.bucketGet(bucketId: id) + return Bucket(generated: generated) + } } struct BucketParameters: Encodable { diff --git a/Sources/Storage/StorageHTTPClient.swift b/Sources/Storage/StorageHTTPClient.swift index 333bd2b0b..c846dc07d 100644 --- a/Sources/Storage/StorageHTTPClient.swift +++ b/Sources/Storage/StorageHTTPClient.swift @@ -39,20 +39,40 @@ public struct StorageHTTPSession: Sendable { public var upload: @Sendable (_ request: URLRequest, _ data: Data) async throws -> (Data, URLResponse) + let uploadFromFile: + @Sendable (URLRequest, URL, (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) + + let bytes: + @Sendable (URLRequest, (any URLSessionTaskDelegate)?) async throws -> ( + URLSession.AsyncBytes, URLResponse + ) + /// Creates a ``StorageHTTPSession`` with custom fetch and upload closures. /// /// - Parameters: /// - fetch: A closure that executes a network fetch request and returns the response data and metadata. /// - upload: A closure that uploads data for a request and returns the response data and metadata. + /// - uploadFromFile: A closure that uploads a file for a request and returns the response data and metadata. + /// - bytes: A closure that returns the response body as an `AsyncBytes` stream and the response metadata. public init( fetch: @escaping @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse), upload: @escaping @Sendable (_ request: URLRequest, _ data: Data) async throws -> ( Data, URLResponse + ), + uploadFromFile: + @escaping @Sendable (URLRequest, URL, (any URLSessionTaskDelegate)?) async throws -> ( + Data, URLResponse + ), + bytes: + @escaping @Sendable (URLRequest, (any URLSessionTaskDelegate)?) async throws -> ( + URLSession.AsyncBytes, URLResponse ) ) { self.fetch = fetch self.upload = upload + self.uploadFromFile = uploadFromFile + self.bytes = bytes } /// Creates a ``StorageHTTPSession`` backed by a `URLSession`. @@ -61,7 +81,9 @@ public struct StorageHTTPSession: Sendable { public init(session: URLSession = .shared) { self.init( fetch: { try await session.data(for: $0) }, - upload: { try await session.upload(for: $0, from: $1) } + upload: { try await session.upload(for: $0, from: $1) }, + uploadFromFile: { try await session.upload(for: $0, fromFile: $1, delegate: $2) }, + bytes: { try await session.bytes(for: $0, delegate: $1) } ) } } diff --git a/Sources/Storage/Types+Generated.swift b/Sources/Storage/Types+Generated.swift new file mode 100644 index 000000000..deb848db4 --- /dev/null +++ b/Sources/Storage/Types+Generated.swift @@ -0,0 +1,14 @@ +import Foundation + +extension Bucket { + init(generated: BucketSchema) { + self.init( + id: generated.id, name: generated.name, owner: generated.owner ?? "", + isPublic: generated.public ?? false, + createdAt: generated.createdAt.flatMap { $0.date } ?? Date(timeIntervalSince1970: 0), + updatedAt: generated.updatedAt.flatMap { $0.date } ?? Date(timeIntervalSince1970: 0), + allowedMimeTypes: generated.allowedMimeTypes, + fileSizeLimit: generated.fileSizeLimit.map(Int64.init) + ) + } +} diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index b39f8f824..daf2d081a 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -103,7 +103,12 @@ public final class SupabaseClient: Sendable { configuration: StorageClientConfiguration( url: storageURL, headers: headers, - session: StorageHTTPSession(fetch: fetchWithAuth, upload: uploadWithAuth), + session: StorageHTTPSession( + fetch: fetchWithAuth, + upload: uploadWithAuth, + uploadFromFile: uploadFromFileWithAuth, + bytes: bytesWithAuth + ), logger: options.global.logger, useNewHostname: options.storage.useNewHostname ) @@ -396,6 +401,23 @@ public final class SupabaseClient: Sendable { try await session.upload(for: adapt(request: request), from: data) } + @Sendable + private func uploadFromFileWithAuth( + _ request: URLRequest, + from fileURL: URL, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> (Data, URLResponse) { + try await session.upload(for: adapt(request: request), fromFile: fileURL, delegate: delegate) + } + + @Sendable + private func bytesWithAuth( + _ request: URLRequest, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> (URLSession.AsyncBytes, URLResponse) { + try await session.bytes(for: adapt(request: request), delegate: delegate) + } + private func adapt(request: URLRequest) async -> URLRequest { let token = try? await _getAccessToken() diff --git a/Tests/StorageTests/BucketOperationsTests.swift b/Tests/StorageTests/BucketOperationsTests.swift index 7d5a4a285..8ff8cba00 100644 --- a/Tests/StorageTests/BucketOperationsTests.swift +++ b/Tests/StorageTests/BucketOperationsTests.swift @@ -52,7 +52,7 @@ struct BucketOperationsTests { return HTTPRuntime.HTTPResponse( head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) } - let client = StorageOpenAPIClient( + let client = StorageGeneratedClient( baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) let bucket = try await client.bucketGet(bucketId: "avatars") diff --git a/Tests/StorageTests/ErrorDecodingTests.swift b/Tests/StorageTests/ErrorDecodingTests.swift index a5988c646..1b416fe26 100644 --- a/Tests/StorageTests/ErrorDecodingTests.swift +++ b/Tests/StorageTests/ErrorDecodingTests.swift @@ -24,7 +24,7 @@ struct ErrorDecodingTests { let transport = FakeTransport { _ in HTTPRuntime.HTTPResponse(head: HTTPResponseHead(status: 403, headers: [:]), body: errorBody) } - let client = StorageOpenAPIClient( + let client = StorageGeneratedClient( baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) await #expect(throws: ErrorSchema.self) { @@ -37,7 +37,7 @@ struct ErrorDecodingTests { let transport = FakeTransport { _ in HTTPRuntime.HTTPResponse(head: HTTPResponseHead(status: 404, headers: [:]), body: Data()) } - let client = StorageOpenAPIClient( + let client = StorageGeneratedClient( baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) await #expect(throws: HTTPRuntime.HTTPError.self) { diff --git a/Tests/StorageTests/ObjectUploadTests.swift b/Tests/StorageTests/ObjectUploadTests.swift index 82eddc2d8..24d190ab4 100644 --- a/Tests/StorageTests/ObjectUploadTests.swift +++ b/Tests/StorageTests/ObjectUploadTests.swift @@ -17,7 +17,7 @@ struct ObjectUploadTests { // NOTE: the real Storage OpenAPI spec's `object#createObject` operation // declares a `multipart/form-data` (or raw binary) request body, but // Task 13's generator does not yet wire request bodies for `multipart` - // content types into `StorageOpenAPIClient.objectUpload` — the generated + // content types into `StorageGeneratedClient.objectUpload` — the generated // method takes only `bucketName`/`wildcard` and never calls `setBody`, so // no file content is ever sent. This test documents that real, current // behavior rather than asserting a multipart body that doesn't exist yet. @@ -35,7 +35,7 @@ struct ObjectUploadTests { return HTTPRuntime.HTTPResponse( head: HTTPResponseHead(status: 200, headers: [:]), body: responseBody) } - let client = StorageOpenAPIClient( + let client = StorageGeneratedClient( baseURL: URL(string: "https://example.supabase.co/storage/v1")!, transport: transport) let result = try await client.objectUpload(bucketName: "avatars", wildcard: "a.txt") diff --git a/Tests/StorageTests/StorageBucketAPITests.swift b/Tests/StorageTests/StorageBucketAPITests.swift index c400e6205..9f26bef0e 100644 --- a/Tests/StorageTests/StorageBucketAPITests.swift +++ b/Tests/StorageTests/StorageBucketAPITests.swift @@ -34,7 +34,9 @@ final class StorageBucketAPITests: XCTestCase { ], session: StorageHTTPSession( fetch: { try await session.data(for: $0) }, - upload: { try await session.upload(for: $0, from: $1) } + upload: { try await session.upload(for: $0, from: $1) }, + uploadFromFile: { try await session.upload(for: $0, fromFile: $1, delegate: $2) }, + bytes: { try await session.bytes(for: $0, delegate: $1) } ), logger: nil ) diff --git a/Tests/StorageTests/StorageFileAPITests.swift b/Tests/StorageTests/StorageFileAPITests.swift index c67768eda..d4dffd416 100644 --- a/Tests/StorageTests/StorageFileAPITests.swift +++ b/Tests/StorageTests/StorageFileAPITests.swift @@ -35,7 +35,9 @@ final class StorageFileAPITests: XCTestCase { ], session: StorageHTTPSession( fetch: { try await session.data(for: $0) }, - upload: { try await session.upload(for: $0, from: $1) } + upload: { try await session.upload(for: $0, from: $1) }, + uploadFromFile: { try await session.upload(for: $0, fromFile: $1, delegate: $2) }, + bytes: { try await session.bytes(for: $0, delegate: $1) } ), logger: nil ) diff --git a/Tests/StorageTests/SupabaseStorageTests.swift b/Tests/StorageTests/SupabaseStorageTests.swift index 93f497446..f2a43e360 100644 --- a/Tests/StorageTests/SupabaseStorageTests.swift +++ b/Tests/StorageTests/SupabaseStorageTests.swift @@ -17,7 +17,9 @@ final class SupabaseStorageTests: XCTestCase { var sessionMock = StorageHTTPSession( fetch: unimplemented("StorageHTTPSession.fetch"), - upload: unimplemented("StorageHTTPSession.upload") + upload: unimplemented("StorageHTTPSession.upload"), + uploadFromFile: unimplemented("StorageHTTPSession.uploadFromFile"), + bytes: unimplemented("StorageHTTPSession.bytes") ) func testGetPublicURL() throws {