Skip to content

feat(storage)!: storage v3#987

Open
grdsdev wants to merge 49 commits intov3from
refactor/storage-http-client
Open

feat(storage)!: storage v3#987
grdsdev wants to merge 49 commits intov3from
refactor/storage-http-client

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented Apr 29, 2026

This PR is a major overhaul of the Storage module — Storage v3. It replaces the old class hierarchy with a modern, value-type-forward Swift API, introduces typed domain values, adds upload progress reporting, rewrites error handling, and ships comprehensive DocC documentation.

Breaking change — this PR renames types, changes parameter types, and removes or internalises several APIs. Migration notes are in each section below.


Architecture

New StorageClient replaces StorageApi + StorageBucketApi

The old StorageApi base class and StorageBucketApi subclass are removed. All bucket and file operations now live on the new StorageClient struct.

  • StorageClient.url is a first-class initialiser parameter, no longer nested inside StorageClientConfiguration.
  • JSON encoder/decoder are internal to StorageClient with a fixed snake_case strategy and are no longer user-configurable via StorageClientConfiguration.
  • @unchecked Sendable classes are gone; the new types are proper Sendable structs where needed.
  • HTTP error translation (translateStorageError) is now internal to StorageClient. HTTPError no longer leaks out of Storage operations — callers always receive StorageError.

StorageFileApiStorageFileAPI

The file API type is renamed to follow Swift's acronym capitalisation convention. The old StorageFileApi.swift (747 lines) is replaced by StorageFileAPI.swift (1 128 lines) with full typed APIs.

MultipartFormData replaced by MultipartBuilder

The old 717-line Alamofire-derived MultipartFormData class is replaced with a 279-line value-type MultipartBuilder struct:

  • Immutable, chainable builder API (addText, addData, addFile, addFileStreamed).
  • Supports both in-memory builds (buildInMemory()) and temp-file streaming (buildToTempFile()) for large uploads.
  • No third-party code or class-based mutable state.

Types consolidated into Types.swift

BucketOptions, TransformOptions, and several response types that were scattered across individual files are now in a single Types.swift.


New Typed Value Types (Breaking)

Old New Notes
String (resize mode) ResizeMode .cover, .contain, .fill
String? (image format) ImageFormat? .origin, .avif, .webp, .jpeg, .png
String (sort order) SortOrder .asc, .desc
String? (file size limit) StorageByteCount? .bytes(), .kilobytes(), .megabytes(), .gigabytes(), ExpressibleByIntegerLiteral
Bool (download flag) DownloadBehavior? .withOriginalName, .named("filename.pdf"); nil = no download

BucketOptions changes

// Before
BucketOptions(public: true, fileSizeLimit: "10485760")

// After
BucketOptions(isPublic: true, fileSizeLimit: .megabytes(10))
  • public renamed to isPublic (avoids Swift keyword collision).
  • fileSizeLimit changed from String? to StorageByteCount?.

TransformOptions changes

// Before
TransformOptions(resize: "cover", format: "origin")

// After
TransformOptions(resize: .cover, format: .origin)

SortBy.order change

// Before
SortBy(column: "name", order: "asc")

// After
SortBy(column: "name", order: .asc)

createSignedURL / createSignedURLs changes

// Before
api.createSignedURL(path: "file.png", expiresIn: 60, download: true)

// After — download with original filename
api.createSignedURL(path: "file.png", expiresIn: .seconds(60), download: .withOriginalName)

// After — download with custom filename
api.createSignedURL(path: "file.png", expiresIn: .seconds(60), download: .named("annual-2024.pdf"))

expiresIn now takes Duration instead of a raw Int (seconds). The download parameter uses DownloadBehavior? instead of Bool — pass nil (the default) to omit the download query parameter.


Other API Changes (Breaking)

  • FileObjectV2FileInfo: the v2 response type is renamed to the simpler FileInfo.
  • FileInfo.id and FileUploadResponse.id are now UUID: Storage object IDs are gen_random_uuid() in the database, so these are now properly typed.
  • SearchOptions.prefix is now internal: it was never meant to be set directly by callers.
  • FileOptions.duplex and FileOptions.headers removed: these were implementation details that leaked into the public API.
  • uploadFile convenience removed: the main upload overloads cover all cases.

Upload Progress Reporting

Upload methods now accept an optional trailing closure for progress:

try await api.upload(path: "video.mp4", file: data) { progress in
    print("\(progress.fractionCompleted * 100)% uploaded")
}

New UploadProgress type exposes totalBytesSent, totalBytesExpectedToSend, and fractionCompleted.


Typed Error Handling

do {
    let data = try await api.download(path: "secret.png")
} catch let error as StorageError {
    if error.isNotFound {
        // handle 404
    } else if error.isUnauthorized {
        // handle 401
    }
    print(error.errorCode) // StorageErrorCode
    print(error.statusCode) // Int?
}
  • New StorageErrorCode type — RawRepresentable with static constants for all known server error strings (e.g. .notFound, .objectNotFound, .bucketNotFound). Adding new constants is never a breaking change.
  • StorageError gains: errorCode: StorageErrorCode, statusCode: Int?, underlyingResponse, underlyingData, isNotFound, isUnauthorized.
  • StorageError no longer conforms to Decodable — JSON decoding is an internal implementation detail.
  • exists(path:) is simplified to a single catch clause using isNotFound.

Documentation

All public types, properties, initialisers, and methods in the Storage module now have DocC-style doc comments with parameter lists, return/throws descriptions, and ## Example code blocks. Also fixes a pre-existing bug where the fileSizeLimit and allowedMimeTypes doc comments were swapped in BucketOptions.


Other Changes

  • Package.swift: macOS minimum version bumped from 12 → 13.
  • Mocker: updated from 1.7.0 → 1.7.1.
  • Linux: FoundationNetworking import added to StorageFileAPI for Linux builds.
  • .gitignore: ignores .claude/, .claire/, docs/superpowers/.

Test Coverage

New test file What it covers
MultipartBuilderTests.swift In-memory and temp-file builds, text/data/file parts
StorageByteCountTests.swift Factory methods, arithmetic, ExpressibleByIntegerLiteral
UploadProgressTests.swift fractionCompleted, edge cases
ValueTypesTests.swift ResizeMode, ImageFormat, SortOrder, DownloadBehavior raw values

Existing StorageErrorTests, StorageFileAPITests, StorageBucketAPITests, TransformOptionsTests, BucketOptionsTests, and integration tests are updated for the new APIs.

@grdsdev grdsdev requested a review from a team as a code owner April 29, 2026 11:36
@grdsdev grdsdev marked this pull request as draft April 29, 2026 11:38
@grdsdev grdsdev force-pushed the refactor/storage-http-client branch from 8aa55b2 to dc03947 Compare April 29, 2026 11:42
grdsdev and others added 22 commits April 29, 2026 08:44
…tion

Coding is not user-configurable; move encoder and decoder as internal
properties on StorageClient with fixed snake_case encoding strategy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
url is now a first-class parameter of StorageClient's init, stored
directly on the client rather than nested inside configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover every public type, property, initializer, and method in the Storage
module with DocC-style doc comments, parameter lists, return/throws
descriptions, and ## Example code blocks.

Also fix a pre-existing bug where the `fileSizeLimit` and `allowedMimeTypes`
property docs were swapped in `BucketOptions`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eAPI type name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…o Types.swift, rename StorageFileApi to StorageFileAPI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the full v2 API redesign for the Storage module including typed
value types (ResizeMode, ImageFormat, SortOrder, StorageByteCount),
DownloadBehavior enum, upload progress reporting, method signature
consolidation, and backend-verified type corrections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces StorageByteCount with Int64 backing, factory methods
(.bytes/.kilobytes/.megabytes/.gigabytes), and ExpressibleByIntegerLiteral
conformance. Replaces raw strings/integers at call sites in future tasks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eFormat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…imit to StorageByteCount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
grdsdev and others added 13 commits April 29, 2026 18:33
…ation and DownloadBehavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…osure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… and FileInfo

Storage object IDs are uuid columns in the database (gen_random_uuid()),
so FileUploadResponse.id and FileInfo.id are typed as UUID instead of String.
FileObject.id was already UUID?. Bucket.id remains String (user-defined names).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BucketOptions: public → isPublic, fileSizeLimit String? → StorageByteCount?
- FileSearchView: FileInfo.id UUID → uuidString, SortBy.order String → SortOrder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rCode

- Adds StorageErrorCode (RawRepresentable) with static constants for all
  known server error strings; new constants are never a breaking change
- StorageError gains errorCode: StorageErrorCode, statusCode: Int?,
  underlyingResponse, underlyingData, isNotFound, isUnauthorized
- Removes Decodable conformance; ServerErrorResponse (private) handles
  JSON decoding in translateStorageError
- translateStorageError consolidated to StorageClient (internal); always
  produces StorageError — HTTPError no longer escapes Storage operations
- exists(path:) simplified to a single catch clause using isNotFound

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread Sources/Storage/Types.swift Outdated
grdsdev and others added 5 commits April 30, 2026 12:09
…all sites

Replace `.download` with `.withOriginalName` and `.downloadAs(_:)` with
`.named(_:)` so the parameter label and case name are no longer identical
(`download: .download` → `download: .withOriginalName`).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…current suite interference

On Linux, Swift Testing runs suites concurrently. Calling Mocker.removeAll()
in init() (which fires before each test) races with other suites' just-registered
mocks, causing StorageBucketAPITests to time out. Each test registers mocks for
unique URL+method combinations so global cleanup is not needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t assertions

StorageError now includes underlyingResponse and underlyingData which contain
runtime-varying content (headers, raw bytes), making .dump snapshots non-
deterministic. Replace with XCTAssertEqual checks on statusCode and message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@grdsdev
Copy link
Copy Markdown
Contributor Author

grdsdev commented Apr 30, 2026

Code review

Found 4 issues:

  1. addFileStreamed is a dead API with a misleading contract — it is byte-for-byte identical to addFile, but its doc comment says "for streamed output." A caller who uses addFileStreamed and then calls buildInMemory() will still load the entire file into memory via Data(contentsOf:). The streaming is a property of which build* method is chosen, not which add* method. The method is also never called anywhere in the codebase.

/// Add a file field to the multipart payload (for streamed output)
func addFileStreamed(
name: String,
fileURL: URL,
fileName: String? = nil,
mimeType: String? = nil
) -> MultipartBuilder {
// For now, same as addFile - streaming happens in buildToTempFile
var builder = self
builder.parts.append(
.file(name: name, fileURL: fileURL, fileName: fileName, mimeType: mimeType))
return builder
}

  1. isNotFound includes statusCode == 400, which means exists() silently returns false for any HTTP 400 Bad Request (e.g. malformed path, missing required header, payload validation error). Callers will think a file is absent when the real problem is a bad request, making those errors very hard to debug. The 400 check appears intentional (it's documented in the property's doc comment), but the broader isNotFound being part of the public API means this semantics also bleeds into any user code that switches on error.isNotFound.

/// `true` when the error indicates that the requested object or bucket does not exist.
///
/// Covers HTTP status codes 400 and 404 as well as the explicit error codes
/// ``StorageErrorCode/objectNotFound``, ``StorageErrorCode/bucketNotFound``, and
/// ``StorageErrorCode/notFound``.
public var isNotFound: Bool {
statusCode == 400 || statusCode == 404
|| errorCode == .objectNotFound
|| errorCode == .bucketNotFound
|| errorCode == .notFound
}

  1. x-upsert header is not sent for update() (PUT), only for upload() (POST) — the guard if method == .post means a call to update(path:file:options:) with options.upsert = true never actually signals upsert intent to the server. This is inconsistent with _uploadToSignedURL, which unconditionally sends the header regardless of method.

var headers = multipartHeaders(options: options)
if method == .post {
headers[Header.xUpsert] = "\(options.upsert)"
}

  1. ServerErrorResponse.message is non-optional — if the Storage server returns an error body without a message field (e.g. {"error": "unauthorized"}), the entire Decodable decode fails and the error falls back to errorCode: .unknown, discarding the structured error field. Making message optional and falling back to error would preserve the error code in all cases.

private struct ServerErrorResponse: Decodable {
let message: String
let error: String?
/// The server sends the status code as a JSON string, e.g. `"404"`.
let statusCode: String?
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

grdsdev and others added 3 commits April 30, 2026 16:41
- Remove addFileStreamed from MultipartBuilder — it was byte-for-byte
  identical to addFile with a misleading "streamed" name; streaming is
  determined by buildToTempFile() vs buildInMemory(), not the add method
- Narrow isNotFound to HTTP 404 only; move the 400 check into exists()
  with a comment explaining why the Storage server returns 400 for HEAD
  requests on non-existent objects, preventing bad-request errors from
  being silently swallowed in other call sites
- Always send x-upsert header in _uploadOrUpdate regardless of HTTP
  method, so update() (PUT) respects FileOptions.upsert consistently
  with _uploadToSignedURL
- Make ServerErrorResponse.message optional so error responses that
  omit the message field (e.g. {"error":"unauthorized"}) still produce
  a structured StorageError instead of falling back to .unknown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… contract

After narrowing isNotFound to exclude HTTP 400, the test asserting
error.isNotFound for a bare 400/unknown error now correctly expects false.
The 400 case is covered by exists() directly, which is verified by the
existing exists_400_error test in StorageFileAPITests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@grdsdev grdsdev marked this pull request as ready for review May 4, 2026 08:51
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant