Skip to content

refactor(storage): adopt swift-openapi-generator for the HTTP layer#1093

Draft
grdsdev wants to merge 32 commits into
mainfrom
claude/sharp-driscoll-a8d814
Draft

refactor(storage): adopt swift-openapi-generator for the HTTP layer#1093
grdsdev wants to merge 32 commits into
mainfrom
claude/sharp-driscoll-a8d814

Conversation

@grdsdev

@grdsdev grdsdev commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces hand-written HTTP request/response plumbing in StorageBucketApi and the multipart-bearing methods of StorageFileApi with a generated OpenAPI client, sourced from supabase/storage#1215's spec-quality fixes. Zero public API or behavior change.

Design docs / plans:

What changed

Milestones 1-2 — bucket API + plumbing:

  • Vendored the OpenAPI spec from fix: improve generated OpenAPI spec quality for SDK generation storage#1215, patched several invalid/malformed spec bugs found along the way (tracked in docs/superpowers/storage-openapi-upstream-issues.md for filing upstream)
  • Isolated codegen tooling (tools/openapi-generator, scripts/generate-storage-openapi.sh) that keeps swift-openapi-generator itself out of the main dependency graph
  • StorageOpenAPITransport bridges the generated client onto the existing StorageHTTPSession/header-merging pipeline
  • All six StorageBucketApi methods (listBuckets, getBucket, createBucket, updateBucket, emptyBucket, deleteBucket) migrated
  • Fixed a real regression caught in review: non-2xx responses were throwing ClientError-wrapped errors instead of bare StorageError

Milestone 4 — upload/update/uploadToSignedURL (multipart):

  • Patched the spec to declare a multipart/form-data request body (previously undeclared entirely) for the three upload operations
  • Migrated upload/update/uploadToSignedURL off the hand-written MultipartFormData encoder (now deleted, ~900 lines) onto the generated client's multipart support
  • Introduced a TaskLocal-scoped per-call header mechanism for headers the generated client's Input doesn't expose a slot for
  • Fixed a Cache-Control regression caught in the final review before merge

Out of scope, follow-ups filed separately:

  • download() (binary response streaming) — its own follow-up plan
  • Streaming large file uploads instead of loading into memory (pre-existing limitation, not a regression from this work)
  • A CI check that regenerates the spec and fails on drift (called for in the design doc, not yet implemented)

Test plan

  • Full test suite passes (xcodebuild test, all Storage/Supabase suites green)
  • Every task independently reviewed (spec compliance + code quality) during implementation
  • Two whole-branch reviews (Milestones 1-2 and Milestone 4), each catching and fixing one real regression before merge
  • Multipart/JSON request snapshots verified byte-identical or consciously ratified where wire format changed (documented in commit messages)

grdsdev added 30 commits July 8, 2026 05:41
Design for replacing hand-written HTTP request/response building in
StorageBucketApi/StorageFileApi with a generated OpenAPI client, sourced
from supabase/storage#1215's spec-quality fixes, kept fully internal
behind the existing public API.
…s 1-2)

Bite-sized task plan covering spec vendoring, codegen tooling, the
StorageOpenAPITransport bridge, and migrating all six StorageBucketApi
methods to the generated client. Milestones 3-4 (JSON object endpoints,
multipart/binary) get their own follow-up plans per the design doc.
…dored spec

The docs:export output declares openapi: 3.0.3 but used OpenAPI 3.1's
type-array nullable syntax (type: ["null", "integer"]) in 4 places
(bucketSchema.file_size_limit, .allowed_mime_types, and two properties
in the object/sign response schema). That's invalid for a 3.0.3
document and swift-openapi-generator correctly rejects it. Converted
to the equivalent valid 3.0.3 form (type + nullable: true) with no
semantic change.
Living tracking doc for spec bugs found while adopting
swift-openapi-generator, to file back upstream once confirmed against
master.
The previous fix converted the 4 type-array occurrences but missed a
second OpenAPI 3.1-only pattern: 12 properties used
anyOf: [<schema>, {"type": "null"}], which is also invalid under the
document's declared 3.0.3 version. swift-openapi-generator failed with
"Cannot initialize JSONType from invalid String value null" on
objectSchema.properties.id and the /object/copy response schema.

Converted each to the equivalent 3.0.3 form: merge the non-null
branch's keywords onto the parent and set nullable: true. No semantic
change.
Adds an isolated tools/openapi-generator package (kept out of the main
graph to avoid pulling swift-openapi-generator's own dependencies into
consumer builds) plus scripts/generate-storage-openapi.sh to regenerate
Sources/Storage/Generated/{Types,Client}.swift from the vendored spec.
Wires swift-openapi-runtime into the root Package.swift as a real
Storage dependency since the generated code imports it.

Renamed the existing hand-written Sources/Storage/Types.swift to
StorageTypes.swift: SwiftPM names build products by file basename
regardless of directory, so it collided with the generator's
Generated/Types.swift (a reserved output filename that can't be
renamed via config) and failed the build with "multiple producers".

Generated Components.Schemas.bucketSchema spells the JSON `public` and
`file_size_limit` fields as `_public: Swift.Bool?` and
`file_size_limit: Swift.Int?`.
anyOf-with-null nullable syntax (12 sites) and the bucketUpdate
required/properties mismatch warning, both found running Task 2 of
the storage OpenAPI generator plan.
Task 2 renamed Sources/Storage/Types.swift to StorageTypes.swift to
resolve a SwiftPM basename collision with the generator's reserved
Types.swift output. Task 5's instructions still referenced the old
path.
Adds Bucket.init(fromGenerated:) bridging Components.Schemas.bucketSchema
to the existing public Bucket type, and switches listBuckets() to call
openAPIClient.bucketList(...) instead of hand-built HTTP request/response
code.

Also strips the Accept header that swift-openapi-generator's client always
sets in StorageOpenAPITransport, since none of the hand-written
implementations being migrated ever sent one and wire behavior must stay
byte-identical (testListBuckets' curl snapshot would otherwise gain an
extra header).
Bare ISO8601DateFormatter() (no fractional-seconds support) fails to
parse the Storage API's millisecond-precision timestamps, silently
falling back to the 1970 epoch for createdAt/updatedAt on every real
response. Reuse the existing String.date helper (Helpers module),
which already handles both fractional and non-fractional ISO8601.
…API client

file_size_limit is generated as a value1(Int)/value2(String) struct rather
than an anyOf enum; only value2 is ever used since BucketOptions.fileSizeLimit
is already a plain String. Configures the generated client's JSON encoder
with sortedKeys so request bodies stay deterministic across migrated
operations.

Also excludes Sources/Storage/Generated from scripts/format.sh, since that
directory is machine-generated and must not be hand-reformatted.
The generated OpenAPI client appends `; charset=utf-8` to Content-Type on
every JSON-body request, but the hand-written path (and every other
migrated bucket method) always sent plain `application/json`.
StorageOpenAPITransport already normalizes the generator-injected Accept
header for wire compatibility during the migration; apply the same
treatment to Content-Type instead of accepting the drift in snapshots.
…body

PUT /bucket/{bucketId}'s request body declared a top-level anyOf
(at-least-one-of public/file_size_limit/allowed_mime_types) alongside
its properties and minProperties: 1 — already fully implied by
minProperties: 1 given the schema has no other fields. Because a
top-level anyOf makes swift-openapi-generator treat the schema as
anyOf-driven, it silently dropped the co-located properties, producing
a generated body type with zero usable fields. Deleting the redundant
anyOf (keeping minProperties + properties) regenerates a normal,
populated body type with no semantic change server-side.

Found while attempting Task 10 of
docs/superpowers/plans/2026-07-08-storage-openapi-generator.md.
RealtimeIntegrationTests was missing the XCTSkipUnless(INTEGRATION_TESTS)
guard present in every sibling integration test file, so a plain
swift test ran it unconditionally. Most tests failed fast with no
server, but testPostgresAllChanges hung forever: it drives a manual
TestClock that backs withTimeout()'s subscribe-timeout logic but never
advances it for this test's subscribe call, so with no live server and
a clock that never moves on its own, the subscribe never resolves and
never times out.

Restoring the guard exposed a second bug: tearDown() force-unwrapped
client/client2, which are nil when setUp() skips before assigning them,
crashing every run. Switched to optional chaining so tearDown() is a
no-op when setUp() skipped.
…API client

Mirrors createBucket's Task 9 pattern, mapping BucketOptions onto the
now-populated Operations.bucketUpdate.Input.Body.jsonPayload (unblocked
by 7970393's spec fix).

testUpdateBucket's snapshot needed two corrections, both pre-existing
inaccuracies exposed by the generated client actually honoring the
spec:
- Request body: the old hand-written client sent id/name on update,
  fields the real PUT /bucket/{bucketId} endpoint never accepts (only
  public/file_size_limit/allowed_mime_types, per the vendored spec).
  The generated body type has no id/name fields to set, so the request
  now correctly sends only {"public":true}.
- Mock response: the endpoint's actual 200 response is
  {"message": "..."}, not a full bucket object. The old client ignored
  the response body entirely, so the mock's inaccurate fixture never
  mattered; the generated client decodes it per spec and requires the
  real shape.

Also removed BucketParameters, dead now that both createBucket and
updateBucket use the generated client.
…nses

StorageOpenAPITransport's execute closure reused StorageApi.executeRequest,
which throws StorageError/HTTPError directly on non-2xx. OpenAPIRuntime's
UniversalClient.send wraps any non-ClientError thrown by the transport into
a ClientError, so bucket API errors regressed from bare StorageError to
ClientError(underlyingError: StorageError).

Give the OpenAPI transport path its own executeRequestWithoutStatusCheck
that shares header-merging with executeRequest but never throws on non-2xx,
matching the design doc's "transport doesn't throw, facade maps errors"
architecture. StorageBucketApi's six migrated methods now switch on the
generated Output's .forbidden/.clientError/.undocumented cases and build a
StorageError from the real decoded errorSchema body (or raw bytes for
undocumented statuses), instead of a generic placeholder message.

Adds a regression test proving the thrown error is a bare StorageError
(not ClientError) with the real statusCode/message/error, plus two small
StorageOpenAPITransport tests for the Accept-header-drop and
Content-Type-charset-normalization branches.
Updated after the error-handling fix (f1e0aef) changed this
transport's contract from throwing on non-2xx to always returning the
raw response.
Milestone 4 of the storage OpenAPI generator adoption. Requires
patching the vendored spec to add a multipart requestBody (currently
undeclared) before the generated client can send anything for these
operations — flagged as a de-risking spike before any facade changes.
…PI client

Covers upload/update/uploadToSignedURL (Milestone 4, multipart write
path). Grounded in a throwaway codegen spike confirming
swift-openapi-generator handles the multipart shape needed once the
vendored spec's undeclared request body is patched. download() (binary
response) stays out of scope for its own follow-up plan.
objectUpload/objectUploadUpdate/objectUploadSigned had no declared
request body at all (Fastify's multipart handling isn't schema-
validated the same way as JSON bodies). Added a multipart/form-data
requestBody (cacheControl, optional metadata, file) to all three so
swift-openapi-generator produces a usable typed Input.body.

Confirmed the generated client hardcodes Content-Type:
application/octet-stream for the typed .file case with no override —
the file part must be built via the .undocumented(MultipartRawPart)
escape hatch to send the real MIME type, per the implementation plan.
Migrates StorageFileApi._uploadOrUpdate to call the generated
objectUpload/objectUploadUpdate operations instead of the hand-written
MultipartFormData/execute() path. Adds a StorageApi task-local
(extraHeadersForCurrentRequest) so per-call headers (x-upsert, Duplex,
Cache-Control, options.headers) can flow through the generated Client,
which only exposes `accept` on its per-operation Input.headers. Also
wires the OpenAPI client's multipart boundary generator to the existing
DEBUG-only testingBoundary hook so snapshot tests stay deterministic.
Migrates StorageFileApi._uploadToSignedURL to call the generated
objectUploadSigned operation instead of the hand-written
MultipartFormData/execute() path, reusing the shared file-part builder
and StorageApi.extraHeadersForCurrentRequest task-local established
for upload/update. The generated success response only has a Key
field (no Id), matching SignedURLUploadResponse's existing shape, and
this operation has no forbidden case in its Output (only ok,
clientError, undocumented).
…OpenAPI client

MultipartFormData's only callers (upload/update and uploadToSignedURL) were
migrated to OpenAPIRuntime.MultipartRawPart in prior tasks. Also removes two
now-dead code paths that referenced it: FileUpload.encode(to:withPath:options:)
and the Helpers.HTTPRequest(formData:) initializer, neither of which had any
remaining call sites.
…rtFormData

The deprecated FormData type's availability attribute pointed
consumers to MultipartFormData via renamed:, but that type was deleted
in cea2c1b (superseded by the generated OpenAPI client's multipart
support). renamed: is a string literal, not compiler-validated, so the
build stayed clean, but the message pointed at a symbol that no longer
exists in the package.
grdsdev added 2 commits July 8, 2026 12:34
_uploadToSignedURL dropped the Cache-Control header during the OpenAPI
migration, unlike _uploadOrUpdate which correctly preserved it via
_uploadExtraHeaders. Pre-migration, both paths shared the same header
logic. Also adds missing coverage: a POST upload() test (previously
only update()/uploadToSignedURL() had snapshot tests) and a concurrency
test proving the TaskLocal used to thread per-call headers through the
OpenAPI transport doesn't leak between concurrent calls.
The @available(renamed:) attribute pointed to a nonexistent
updateToSignedURL(_:token:data:options:); the real method is
uploadToSignedURL.
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 28956968023

Coverage decreased (-36.9%) to 46.475%

Details

  • Coverage decreased (-36.9%) from the base build.
  • Patch coverage: 8525 uncovered changes across 6 files (1346 of 9871 lines covered, 13.64%).
  • 20 coverage regressions across 2 files.

Uncovered Changes

File Changed Covered %
Sources/Storage/Generated/Client.swift 5026 709 14.11%
Sources/Storage/Generated/Types.swift 4476 339 7.57%
Sources/Storage/StorageBucketApi.swift 112 77 68.75%
Sources/Storage/StorageError.swift 28 7 25.0%
Sources/Storage/StorageFileApi.swift 128 115 89.84%
Sources/Storage/OpenAPI/StorageOpenAPITransport.swift 57 55 96.49%
Total (8 files) 9871 1346 13.64%

Coverage Regressions

20 previously-covered lines in 2 files lost coverage.

File Lines Losing Coverage Coverage
Sources/RealtimeV2/WebSocket/URLSessionWebSocket.swift 17 57.63%
Sources/Storage/StorageFileApi.swift 3 94.96%

Coverage Stats

Coverage Status
Relevant Lines: 18782
Covered Lines: 8729
Line Coverage: 46.48%
Coverage Strength: 18.48 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants