Skip to content

Repository files navigation

Declarative Requests: Open Spec Done Right

Build Swift Versions Platforms

Describe your backend in Swift — checked by the compiler, verified by your tests, composable by construction — instead of a YAML spec that drifts.

A SwiftUI-style result builder for composing URLRequest — fully composable: blocks compose into endpoint specs, specs compose with auth, auth composes with environment, the same primitive at every level. Each block maps onto one piece of a raw HTTP request — read the builder top to bottom and you read the request top to bottom.

POST /v1/login HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer eyJhbGci...
Content-Type: application/json

{"email":"alice@example.com","password":"hunter2"}
let request = try RequestBlock {
    Method.POST
    Endpoint("v1/login")
    Header.accept.setValue("application/json")
    RequestBody.json(LoginRequest(email: email, password: password))
    Authorization.bearer(token)
    BaseURL("https://api.example.com")
}.request()

Header.accept.setValue(...) is one HTTP header line. RequestBody.json(...) is the body section. The block order roughly mirrors the wire order.

Open Spec

Describe your backend as an enum: one case per endpoint — a backend is a finite set of endpoints, so the spec is a closed type. Materializing a request is three composable steps, one per axis:

  1. The operation — definition: what the endpoint is.
  2. The environment — .base(_:). The base combines with path and query, and those live exactly in the endpoint definition — so it chains right after the endpoint. Header-only layers may follow it.
  3. The authorized step — the spec's Security enum declares HOW: authorization(token:), token non-optional (the scheme is a spec fact, absence is not its business) and needsAuthorization(_:) per endpoint. The client decides WHEN: it gates on the spec's facts and its own session state, and a protected endpoint with no token fails the build with the client's error — a half-authorized request can never reach the wire. Materializing (.request()) is the only terminal step.
// the whole backend in one closed type: endpoints + security, like the
// sections of an OpenAPI document
enum BackendSpec {
    // each operation IS a block
    enum Operation: RequestBuildable {
        case getUser(id: String)
        case refreshToken(token: String)

        var body: some RequestBuildable {
            switch self {
            case let .getUser(id):
                Method.GET
                Endpoint("v1/users/\(id)")
            case let .refreshToken(token):
                Method.POST
                Endpoint("v1/auth/refresh")
                RequestBody.json(["token": token])
            }
        }
    }

    // the security section: one scheme = one factory
    enum Security {
        static func needsAuthorization(_ operation: Operation) -> Bool {
            switch operation {
            case .getUser: true
            case .refreshToken: false
            }
        }

        static func authorization(token: String) -> some RequestBuildable {
            Authorization.bearer(token)
        }
    }
}

Every layer is a block, so the client composes them in one builder — definition, environment, session, in canonical order — and wires it exactly once:

// the client: session + environment in one place; gating is its rule
struct MissingToken: Error {}

struct APIClient {
    var baseURL: URL
    var token: String?

    func request(_ operation: BackendSpec.Operation) throws -> URLRequest {
        try RequestBlock {
            operation
            BaseURL(baseURL)
            if BackendSpec.Security.needsAuthorization(operation) {
                if let token {
                    BackendSpec.Security.authorization(token: token)
                } else {
                    RequestFailure(MissingToken())
                }
            }
        }.request()
    }
}

let client = APIClient(baseURL: URL(string: "https://api.example.com/api")!)
let user = try client.request(.getUser(id: "42"))

MissingToken is the client's error, not the library's and not the spec's — the spec says how to authorize, the client says whether a missing token is a failure.

This is the "Open Spec Done Right" from the top of this README — the spec is code, so nothing drifts.

Block reference

One type per request property — you never touch the raw URLRequest fields directly, each has exactly one typed block over it:

Raw URLRequest field Block
httpMethod Method.GET … .custom(_)
url BaseURL / Endpoint / Query
allHTTPHeaderFields Header.<field>.setValue/addValue, MIMEType.<case>.accept/.contentType, Cookie, Authorization
httpBody / httpBodyStream RequestBody.* encoders; raw bytes and streams via RequestMutation
timeoutInterval Timeout(5)
cachePolicy RequestMutation(\.cachePolicy, .returnCacheDataElseLoad)
networkServiceType RequestMutation(\.networkServiceType, .background)
httpShouldHandleCookies RequestMutation(\.httpShouldHandleCookies, false)
allowsCellularAccess etc. RequestMutation(\.allowsCellularAccess, false) etc.

For a field without a block, RequestMutation(\.keyPath, value) is the one-line escape hatch, and a raw RequestBlock { state in … } closure is the last resort.

Pick the factory or initializer that matches the data you have.

URL & path

Block What it does Example
BaseURL(_:) Combines into what's built: scheme/host/port, and its path is always a prefix. Apply it last (or chain .base(_:) after the block). BaseURL("https://api.example.com/api")
Endpoint(_:) Sets the path; applying BaseURL prefixes it with the base's path. Endpoint("users/\(id)/posts")
Query(_ name:, _ value:) Append a single query item (accumulates). Value is any LosslessStringConvertible? (String, Int, Double, Bool…); nil renders a bare key. Query("page", 2)
Query(_ encodable:) Flatten an Encodable model into query items. Query(filterModel)

Method, headers, cookies, auth

Block What it does Example
Method.GET / .POST / … / .custom("LINK") Sets the HTTP method. Method.POST
Header.<field>.setValue(_:) Sets a header field, replacing any previous value. Header.accept.setValue("application/json")
Header.<field>.addValue(_:) Appends a value without removing existing ones. Header.accept.addValue("text/html")
Header.custom(_:).setValue(_:) Sets a header by raw string name. Header.custom("X-Trace-Id").setValue("abc123")
Cookie(_ name:, _ value:) Adds one cookie to the Cookie header (accumulates). Cookie("session", token)
Authorization.<scheme>(_:) Authorization: <scheme> <credentials> (RFC 9110 §11.6.2) — scheme is .bearer / .digest / .negotiate; credentials go on the wire verbatim. Authorization.bearer(token)
Authorization.custom(_:_:) Any other scheme, in wire order. Authorization.custom("token", key)
Authorization.basic(username:password:) The one scheme whose credentials the package encodes — RFC 7617, Base64 of user:password. Pre-encoded ones go through .custom("Basic"). Authorization.basic(username: u, password: p)
MIMEType.<case>.contentType Sets Content-Type (replaces). MIMEType.json.contentType
MIMEType.<case>.accept Accumulates Accept header values. MIMEType.custom("application/vnd.x").accept

They go directly in the request — setValue replaces, addValue accumulates:

let request = try RequestBlock {
    Method.GET
    Endpoint("users")

    MIMEType.json.accept
    Header.userAgent.setValue("MyApp/1.0")
    Header.custom("X-Trace-Id").setValue("abc123")
    if isStaging {
        Header.custom("X-Env").setValue("staging")
    }
    Authorization.bearer(token)
    BaseURL("https://api.example.com")
}.request()

BaseURL goes last — everything before it accumulates the relative part of the URL, and the base resolves it — declare BaseURL last or chain .base(_:). This split is deliberate — the canonical order is three layers, each varying on a different axis:

  1. Definition — method, path, query, headers, body: the backend's spec, identical everywhere.
  2. Authorization — who is asking. Varies per session, and only endpoints that need it carry the Authorization block — public endpoints simply don't have it.
  3. Base URL — where it runs. Varies per environment; BaseURL comes last.

Endpoints are definition; authorization and the base URL are configuration on two different axes.

Body — one type, many factories

RequestBody is the body block. The factory you pick decides how the bytes are produced and what (if any) Content-Type is set:

Factory What you supply Sets Content-Type
RequestBody.string(_ s:type:) String (UTF-8) + a MIMEType yes (defaults .plainText)
RequestBody.json(_ value:) Encodable value application/json
RequestBody.urlEncoded(_ name:, _ value:) one form field, value contract same as Query (accumulates across blocks/loops) application/x-www-form-urlencoded
RequestBody.urlEncoded(_ encodable:) Encodable (incl. [String:String]) application/x-www-form-urlencoded
RequestBody.multipart { parts } MultipartParts multipart/form-data; boundary=…

Raw bytes: RequestMutation(\.httpBody, data); streams: RequestMutation(\.httpBodyStream, InputStream(data: data)) (RequestMutation's autoclosure value re-creates the single-use stream per build). Pair with MIMEType.<case>.contentType if needed.

Each RequestBody.* block replaces the body — except urlEncoded, which merges its items into an existing form body, so form fields can accumulate across blocks and loops.

Encodable-driven blocks (json, urlEncoded, Query) use the builder's JSONEncoder. Swap it with .useEncoder(_:) on any block group:

let request = try RequestBlock {
    RequestBody.json(model)   // encoded with snake_case keys
    BaseURL("https://api.example.com")
}.useEncoder(snakeCaseEncoder).request()

Networking knobs

Block What it does
Timeout(_ seconds:) request.timeoutInterval

Multipart upload

let request = try RequestBlock {
    Method.POST
    Endpoint("upload")
    RequestBody.multipart {
        MultipartPart.field(name: "user", value: "alice")
        MultipartPart.data(name: "avatar", filename: "a.png", data: pngBytes, type: .png)
        for url in fileURLs {
            MultipartPart.file(name: "files", fileURL: url, type: .octetStream)
        }
    }
    BaseURL("https://api.example.com")
}.request()

The encoder follows RFC 7578: form-field and filename parameters are quoted, \ and " characters are escaped, CR/LF in names is stripped (no header injection), and a boundary containing whitespace or special characters is quoted in the Content-Type header. For very large uploads, write the assembled body to a temp file and hand it to URLSession.uploadTask(fromFile:) — the OS streams it and replays it on redirects.

Building from a base URL

If you already have a URL value, chain it as the base:

let request = try RequestBlock {
    Method.GET
    Endpoint("v1/users/\(userId)")
    Header.accept.setValue("application/json")
}
.base(api)
.request()

Otherwise declare the URL inside the builder with BaseURL:

let request = try RequestBlock {
    Method.POST
    Endpoint("login")
    RequestBody.json(credentials)
    BaseURL("https://api.example.com")
}.request()

Architecture sketch

flowchart LR
    RequestBuilder -- transforms --> RequestState

    subgraph RequestState
        request["request: URLRequest (single source of truth)"]
        proj["path / queryItems / cookies<br/>(computed projections into request)<br/>setBaseURL combines, no readback"]
        proj --> request
    end

    request --> FinalRequest["final request"]

    subgraph RequestBuilder
        function1 --> function2
        function2 --> dots["..."]
        dots --> functionN
    end
Loading

Block map

Every block and its variants at a glance:

flowchart LR
    RB["@RequestBuilder { }"]

    %% URL & Endpoint
    RB --> URL_GROUP["URL & Endpoint"]
    URL_GROUP --> BaseURL["BaseURL(_ url / _ string)"]
    URL_GROUP --> Endpoint["Endpoint(_ path)"]
    URL_GROUP --> Query
    Query --> Q1["Query(_ name, _ value)"]
    Query --> Q2["Query(_ encodable)"]

    %% Method
    RB --> MethodGroup["Method"]
    MethodGroup --> MSTD[".GET  .POST  .PUT<br/>.DELETE  .PATCH  .HEAD<br/>.OPTIONS  .QUERY"]
    MethodGroup --> MCUSTOM[".custom(_ string)"]

    %% Headers
    RB --> HeaderGroup["Headers"]
    HeaderGroup --> Header["Header (enum)"]
    Header --> H1["Header.&lt;field&gt;.setValue(value)"]
    Header --> H2["Header.&lt;field&gt;.addValue(value)"]
    Header --> H3["Header.custom(name).setValue(value)"]
    Header --> HFields["contentType  accept  authorization<br/>userAgent  origin  cookie  referer<br/>host  acceptLanguage  acceptEncoding"]
    HeaderGroup --> Cookie["Cookie(_ name, _ value)"]
    HeaderGroup --> MIME["MIMEType (enum)"]
    MIME --> MIME1["MIMEType.json.contentType"]
    MIME --> MIME2["MIMEType.json.accept"]
    MIME --> MIMEC["common types:<br/>.json  .xml  .png  .mp4  .pdf ...<br/>.custom(_ string)"]

    %% Auth
    RB --> AuthGroup["Authorization"]
    AuthGroup --> A1["Authorization.&lt;scheme&gt;(credentials)"]
    AuthGroup --> A2["Authorization.basic(username:password:)"]
    AuthGroup --> A3["schemes:<br/>.bearer  .digest  .negotiate<br/>.custom(_ scheme, _ credentials)"]

    %% Body
    RB --> BodyGroup["RequestBody"]
    BodyGroup --> B2[".string(_ string, type:)"]
    BodyGroup --> B3[".json(_ encodable)"]
    BodyGroup --> B4[".urlEncoded(_ name, _ value)"]
    BodyGroup --> B5[".urlEncoded(_ encodable)"]
    BodyGroup --> B7[".multipart { parts }"]
    B7 --> MP["MultipartPart"]
    MP --> MP1[".field(name:value:)"]
    MP --> MP2[".data(name:filename:data:type:)"]
    MP --> MP3[".file(name:filename:fileURL:type:)"]

    %% Networking knobs
    RB --> NetGroup["Networking Knobs"]
    NetGroup --> Timeout["Timeout(_ seconds)"]
    NetGroup --> RM["RequestMutation(\.cachePolicy, ...)<br/>RequestMutation(\.networkServiceType, ...)<br/>RequestMutation(\.httpShouldHandleCookies, ...)"]
Loading

Key concepts

  • RequestBuildable — the protocol every block conforms to.
  • RequestBuilder — the @resultBuilder that stitches blocks together.
  • RequestBlock — the leaf block; holds a closure that mutates RequestState.
  • RequestState — the in-progress URLRequest plus the JSONEncoder that body / header / query blocks use.
  • .request() — the only terminal: applies the composed transform to a fresh RequestState and returns the finished URLRequest.

Releases

Packages

Used by

Contributors

Languages