diff --git a/.githooks/pre-commit b/.githooks/pre-commit index a49efcd..b2e1b8a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,26 +1,5 @@ #!/bin/sh -# Formats staged Swift files with swift-format before each commit. +# Lints and formats before each commit, so what CI checks is what you push. # Enable once per clone: ./scripts/setup.sh (or git config core.hooksPath .githooks) set -e - -# NUL-delimited so paths with spaces survive. ACM excludes deletions. -files=$(git diff --cached --name-only --diff-filter=ACM -z -- '*.swift' | tr '\0' '\n') -[ -z "$files" ] && exit 0 - -if ! command -v swift >/dev/null 2>&1; then - echo "pre-commit: swift toolchain not found; skipping swift-format." >&2 - exit 0 -fi - -echo "$files" | while IFS= read -r f; do - [ -n "$f" ] || continue - # A file with unstaged changes is partially staged; formatting in place and - # re-adding would pull the unstaged hunks into the commit. Skip it — CI's - # format check is the backstop for anything the hook can't safely touch. - if git diff --quiet -- "$f"; then - swift format --in-place "$f" - git add -- "$f" - else - echo "pre-commit: '$f' is partially staged; skipping format (run swift format manually)." >&2 - fi -done +exec "$(git rev-parse --show-toplevel)/bin/lint" --staged diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a31fbdb..4f76370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,18 +13,22 @@ env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer jobs: - swift-format-check: - name: swift-format check - runs-on: xcode-27 + lint: + name: oida + # Linting needs no Xcode, so it runs on Linux where a runner minute is cheapest. bin/lint downloads + # oida, pinned by .oida-version and verified against .oida-checksum-, so CI and a laptop run + # the same bytes. The Swift toolchain container is what makes Linux work at all: oida's SourceKit-based + # rules and its swift-format handoff both resolve through the toolchain that ships in this image, the + # same way they resolve through an installed Xcode on a Mac. There is no baseline: any violation fails. + runs-on: ubuntu-latest + container: swift:6.3.2-noble steps: - uses: actions/checkout@v5 - - name: Check formatting - run: | - swift format --in-place --recursive Sources Tests - if ! git diff --exit-code; then - echo "::error::Code is not formatted. Run: swift format --in-place --recursive Sources Tests (and commit)." - exit 1 - fi + # curl isn't in the toolchain image; bin/lint needs it to fetch the pinned oida release. + - name: Install curl + run: apt-get update && apt-get install -y curl + - name: Lint + run: ./bin/lint --check build-and-test: name: macOS Swift Build & Test diff --git a/.oida-checksum-linux-x86_64 b/.oida-checksum-linux-x86_64 new file mode 100644 index 0000000..0cd652c --- /dev/null +++ b/.oida-checksum-linux-x86_64 @@ -0,0 +1 @@ +1df36e1a360fc0c613bce32bfd81d95ddd8d9f888c4bd3da223c305dce03ec88 diff --git a/.oida-checksum-macos-arm64 b/.oida-checksum-macos-arm64 new file mode 100644 index 0000000..4e5db0c --- /dev/null +++ b/.oida-checksum-macos-arm64 @@ -0,0 +1 @@ +b8715e4c9a16a1c4e78d09ab3904cb6966bbce41d722f774b3846352f3f0c7f9 diff --git a/.oida-version b/.oida-version new file mode 100644 index 0000000..04a373e --- /dev/null +++ b/.oida-version @@ -0,0 +1 @@ +0.16.0 diff --git a/.oida.yml b/.oida.yml new file mode 100644 index 0000000..d92ad03 --- /dev/null +++ b/.oida.yml @@ -0,0 +1,76 @@ +# The linter is oida (github.com/kolonialno/oida-swift), a SwiftLint fork carrying the rules we +# actually want. bin/lint fetches the pinned release and runs it; there is no baseline, so the tree +# is kept at zero and any violation fails. + +opt_in_rules: + # The shape of every argument list, parameter list and condition: three or more elements go one per + # line, two or fewer go on one line, and both directions hold. + - multiline_call_arguments + - multiline_parameters + - multiline_conditions + # Imports in three groups — Apple, ours, third-party — alphabetical inside each. Correctable. + - grouped_imports + - key_path_only_where_the_api_takes_one + - multiline_string_opens_on_its_own_line + # What gets written too much of: a helper with one caller that returns nothing, a comment whose every + # word is already in the code below it, a banner naming what the declarations already name. + - comment_adds_no_word + - no_mark_comments + - no_single_use_void_functions + # A cast or a try that traps on anything the runtime hands it, and an override that overrides nothing. + - unneeded_override + - force_cast + - force_try + # VOICE.md's own contract, enforced on every root document. + - document_says_what_is + - document_avoids_retired_words + - document_links_resolve + +disabled_rules: + - todo # work is tracked in PRs and LEARNINGS, not in-code markers + - unused_closure_parameter + +excluded: + # Never lint vendored or generated code, wherever the build puts it. Matched by location, not path, + # so an in-repo `-derivedDataPath` does not drag SPM checkouts into scope. + - DerivedData + - build + - "**/.build" + - "**/SourcePackages" + - "**/GeneratedSources" + - "**/Intermediates.noindex" + +# Errors, not warnings: what these find is written by whoever is not reading the code around it, and a +# warning is what a reader scrolls past. +comment_adds_no_word: + severity: error +no_mark_comments: + severity: error + +opening_brace: + # A condition or a signature that spans several lines gets its brace on its own line — that is what + # the formatter emits, and asking for anything else is asking for a shape it will undo. + ignore_multiline_statement_conditions: true + ignore_multiline_function_signatures: true + +identifier_name: + min_length: 1 + max_length: 70 + +type_name: + max_length: 45 + +multiline_call_arguments: + max_number_of_single_line_parameters: 2 + +multiline_parameters: + max_number_of_single_line_parameters: 2 + +multiline_conditions: + max_number_of_single_line_parameters: 2 + +grouped_imports: + # This package ships one product and takes no dependencies, so "ours" is the module itself and its + # test target, which is only ever @testable-imported. + our_modules: + - Networking diff --git a/.swift-format b/.swift-format index 2a64983..051ba15 100644 --- a/.swift-format +++ b/.swift-format @@ -5,6 +5,7 @@ "spaces": 4 }, "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": false, "respectsExistingLineBreaks": true, "lineBreakBeforeEachArgument": false } diff --git a/AGENTS.md b/AGENTS.md index b3b178a..ee0935b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Swift API Design Guidelines whole, so a naming question is answered by reading i - `make httpbin` / `make httpbin-stop` — run go-httpbin on `:8080` to iterate with plain `swift test`. - `swift build` — library only; CI also fails on any first-party `warning:`. - Integration tests need go-httpbin (`TestConfig.httpbinBaseURL`, default `http://127.0.0.1:8080`). Without it - they fail fast (connection refused) — intended, not a flake. The offline / `fake*` suites need no server. + they fail fast (connection refused) — intended behaviour rather than a flake. The offline / `fake*` suites run on their own. - Formatting: `.swift-format` (120 cols, 4-space). Run `./scripts/setup.sh` once per clone to enable the `.githooks/pre-commit` hook (formats staged Swift files); CI's format check is the backstop. - CI: the `xcode-27` runner image / Xcode 27 / Swift 6.4, the same toolchain as local — CI is the gate. @@ -47,23 +47,23 @@ Swift API Design Guidelines whole, so a naming question is answered by reading i Cross-file contracts an agent must hold; the *why* lives in each file's comments. - **Actor isolation.** `Networking` is an actor — isolated members need `await`, config is async setters - (no external property mutation), no subclassing. -- **Typed bodies, no `Any`.** The method picks the encoding (`body:` JSON / `form:` url-encoded / + (mutation goes through them alone), and the type is final. +- **Typed bodies, chosen by the method.** The method picks the encoding (`body:` JSON / `form:` url-encoded / `parts:`+`fields:` multipart / `data:contentType:` raw; `query:` for get/delete), via the `RequestBody` enum. `T` ∈ any `Decodable` · `Data` · `Void` · `JSONResponse`. -- **Errors say *where* it failed**, never a stringified catch-all; the core never parses the error body — +- **Errors say *where* it failed**, each a typed case; the core hands the error body on untouched — `ResponseMetadata.body` is the full bytes, the caller decodes its own envelope. - **Interceptors are an onion *below* the decode layer**, over a raw `HTTPExchange`; registered outermost-first, `next` replays. Downloads route through too; a GET cache hit flows back out through the chain (the cache is the innermost layer). -- **Cache reads are pure.** `.memory`/`.none` never touch disk, so a read can't destroy a durable +- **Cache reads are pure.** `.memory`/`.none` stay in memory, so a read leaves a durable `.memoryAndFile` copy — purging belongs to the write path and `clearCache`. Sliding TTL keyed on file mtime; sharded layout; one-shard-per-launch background sweep. The `NSCache` warm tier can be evicted by iOS at any time — disk is the durable fallback. - **Observability is one hook:** `events()` (multi-consumer `AsyncStream`). Built-in logging is separate, synchronous, gated by `logLevel`; redaction is log-path only, so `events()` carries the real headers. - **Region-isolation trap (Swift 6.2):** before building the `@Sendable` interceptor chain, read actor - state (`session`/`collector`) into locals so the closure captures none. + state (`session`/`collector`) into locals, so the closure captures values alone. ## Boundaries @@ -71,5 +71,5 @@ Cross-file contracts an agent must hold; the *why* lives in each file's comments - **Always** update `README.md` and `CHANGELOG.md` in the same PR as any public-API change — the README snippets are copy-paste docs and must compile under the actor (`await`). - **Ask first** before adding a third-party dependency — the core is intentionally dependency-free. -- **Never** put cache purging back on the read path: `.memory`/`.none` reads must stay pure, or an +- **Keep cache purging off the read path**: `.memory`/`.none` reads must stay pure, or an evicted warm tier turns a recoverable miss into permanent loss. diff --git a/LEARNINGS.md b/LEARNINGS.md index c3ad8f4..578b12e 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -21,9 +21,9 @@ LEARNINGS/2026-09-15-1520-a-warm-build-hides-a-warning.md The filename is the whole of the discipline. As many branches write this repository at once as there are worktrees open, and a file apiece is what lets them: each branch adds a path the others lack, so a merge takes every side whole and the file you wrote arrives byte for byte. Sharing one file costs a conflict per -pair of open branches, and `merge=union` in `.gitattributes` does not save you — it is read off a **working -tree**, which a server-side merge lacks, so it clears the conflicts on your machine and none of the ones -that block a merge button. +pair of open branches, and `merge=union` in `.gitattributes` applies only where a **working +tree** exists, which a server-side merge lacks — so it clears the conflicts on your machine while every +one that blocks a merge button stands. Inside: diff --git a/Package.swift b/Package.swift index c12bc43..985b34e 100644 --- a/Package.swift +++ b/Package.swift @@ -9,12 +9,9 @@ let package = Package( .iOS(.v18), .macOS(.v15), .tvOS(.v18), .watchOS(.v11) ], products: [ - .library( - name: "Networking", - targets: ["Networking"]), - ], - dependencies: [ + .library(name: "Networking", targets: ["Networking"]) ], + dependencies: [], targets: [ .target( name: "Networking", diff --git a/README.md b/README.md index 9916a4e..dbb8cf2 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - Friendly API - Singleton free -- No external dependencies +- Dependency-free - Minimal implementation - Fully unit tested - Simple request cancellation @@ -47,7 +47,7 @@ Initializing an instance of **Networking** means you have to select a [URLSessio - `.default`: The default session configuration uses a persistent disk-based cache (except when the result is downloaded to a file) and stores credentials in the user’s keychain. -- `.ephemeral`: An ephemeral session configuration object is similar to a default session configuration object except that the corresponding session object does not store caches, credential stores, or any session-related data to disk. Instead, session-related data is stored in RAM. The only time an ephemeral session writes data to disk is when you tell it to write the contents of a URL to a file. The main advantage to using ephemeral sessions is privacy. By not writing potentially sensitive data to disk, you make it less likely that the data will be intercepted and used later. For this reason, ephemeral sessions are ideal for private browsing modes in web browsers and other similar situations. +- `.ephemeral`: Keeps everything in RAM — caches, credential stores and session data all live there, and the one thing it writes to disk is a URL you explicitly ask it to save to a file. Privacy is the point: data that stays out of the file system stays out of reach later, which is what makes this the configuration behind private browsing modes. - `.background`: This configuration type is suitable for transferring data files while the app runs in the background. A session configured with this object hands control of the transfers over to the system, which handles the transfers in a separate process. In iOS, this configuration makes it possible for transfers to continue even when the app itself is suspended or terminated. @@ -65,7 +65,7 @@ let networking = Networking(baseURL: "http://example.com", configuration: .ephem You can set the `headerFields` in any networking object. -This will append (if not found) or overwrite (if found) what NSURLSession sends on each request. +This sets what `URLSession` sends on each request: a header it already carries is overwritten, and a new one is added. ```swift await networking.setHeaderFields(["User-Agent": "your new user agent"]) @@ -130,7 +130,7 @@ await networking.setInterceptors([ ]) ``` -Concurrent requests that all hit `401` at once share a **single** refresh — they wait on the one in flight instead of each firing their own (no token-refresh stampede). For pure *notification* of a `401` (no replay), you don't need an interceptor — it's already a `.completed(_, .failure)` with `error.statusCode == 401` on [`events()`](#observing-requests). +Concurrent requests that all hit `401` at once share a **single** refresh — they wait on the one in flight, so a single token refresh serves them all. For pure *notification* of a `401`, where a replay is beside the point, [`events()`](#observing-requests) already carries it as a `.completed(_, .failure)` with `error.statusCode == 401`, which is enough on its own. `AuthRefreshInterceptor` is one implementation of the general `HTTPInterceptor` seam — an async `intercept(_:next:)` hook that wraps every verb request. Calling `next` runs the rest of the chain (the innermost being the real network call); calling it again replays. @@ -163,7 +163,7 @@ Interceptors apply to **downloads** (`downloadImage`/`downloadData`) as well as ### Validating the response -A `2xx` isn't always a *correct* response — the server might return the wrong content-type or a malformed envelope. `ResponseValidatorInterceptor` runs your check on each successful response and turns a failure into a typed `NetworkingError.validation(reason:_:)` (carrying the response metadata). Non-2xx responses pass through untouched, so a real `500` still surfaces as `.http`, not a validation failure. +A `2xx` can still be the *wrong* response — the server might return the wrong content-type or a malformed envelope. `ResponseValidatorInterceptor` runs your check on each successful response and turns a failure into a typed `NetworkingError.validation(reason:_:)` (carrying the response metadata). Everything outside 2xx passes through untouched, so a real `500` still surfaces as `.http`. ```swift await networking.setInterceptors([ @@ -243,7 +243,7 @@ case .failure(let error): `get` returns Swift's [Result](https://developer.apple.com/documentation/swift/result) with two cases: `.success(let response)` and `.failure(let error)`. The success carries the decoded value, the failure a `NetworkingError`. -`get` is generic over any `Decodable`, so you can decode straight into your own model — no manual JSON digging: +`get` is generic over any `Decodable`, so you decode straight into your own model and the JSON digging stays here: ```swift struct Recipe: Decodable { let title: String } @@ -304,9 +304,9 @@ error.responseMetadata // ResponseMetadata? — status, headers, and the full bo error.isRetryable // Bool — conservative: transient transport failures + HTTP 408/429/5xx ``` -`isRetryable` is deliberately conservative: only transport timeouts/connection failures and a small set of status codes (408, 429, 500, 502, 503, 504). A 4xx (other than 408/429), a decoding failure, or an invalid request is never reported as retryable. +`isRetryable` is deliberately conservative: only transport timeouts/connection failures and a small set of status codes (408, 429, 500, 502, 503, 504). It reports a 4xx (other than 408/429), a decoding failure and an invalid request as final. -**The core makes no assumption about the error body's shape.** It hands you the complete body in `ResponseMetadata` so you can decode your API's own error envelope into a typed value — `decode(_:)` is a small convenience over `body`: +**The core treats the error body as opaque.** It hands you the complete body in `ResponseMetadata` so you can decode your API's own error envelope into a typed value — `decode(_:)` is a small convenience over `body`: ```swift struct APIError: Decodable { let errors: [String: [String]] } // e.g. a Rails/ActiveModel envelope @@ -320,7 +320,7 @@ if case .failure(.http(let httpError)) = result, ### Typed request bodies -Just as the response side is generic over any `Decodable`, the request side is generic over any `Encodable`. `post`, `put`, and `patch` take a `body:` that's JSON-encoded for you and sent with `Content-Type: application/json` — so the body you send is compile-checked, never an untyped `[String: Any]` dictionary: +Just as the response side is generic over any `Decodable`, the request side is generic over any `Encodable`. `post`, `put`, and `patch` take a `body:` that's JSON-encoded for you and sent with `Content-Type: application/json` — so the body you send is compile-checked, a typed value rather than a `[String: Any]` dictionary: ```swift struct Credentials: Encodable { @@ -342,7 +342,7 @@ let ack: Result = await networking.put("/account", body: ## Choosing how the body is encoded -Each encoding is a distinct, typed method — the method you call picks the `Content-Type`, so there's no untyped `parameters:`/`parameterType:` pair to get wrong. +Each encoding is a distinct, typed method, and the method you call picks the `Content-Type` — one decision, rather than a `parameters:`/`parameterType:` pair to keep in step. ### JSON @@ -356,7 +356,7 @@ let result: Result = await networking.post("/post ### URL-encoding -Pass `form:` to send `application/x-www-form-urlencoded`; **Networking** percent-encodes it for you ([`Percent-encoding` / `URL-encoding`](https://en.wikipedia.org/wiki/Percent-encoding#The_application.2Fx-www-form-urlencoded_type)). `form:` takes any flat `Encodable` — a `[String: String]` or your own model — and stringifies scalars for you (`Bool` → `"true"`, not `"1"`): +Pass `form:` to send `application/x-www-form-urlencoded`; **Networking** percent-encodes it for you ([`Percent-encoding` / `URL-encoding`](https://en.wikipedia.org/wiki/Percent-encoding#The_application.2Fx-www-form-urlencoded_type)). `form:` takes any flat `Encodable` — a `[String: String]` or your own model — and stringifies scalars for you, so a `Bool` becomes `"true"`: ```swift let networking = Networking(baseURL: "http://example.com") @@ -394,7 +394,7 @@ let result: Result = await networking.post("/imag **FormDataPart Content-Type**: -Each part's `Content-Type` comes from its `FormDataPartType`, which simply carries the MIME string. The default is `.octetStream` (`application/octet-stream`); `.png` and `.jpeg` are provided as conveniences. For anything else, construct one directly — `FormDataPart(type: FormDataPartType("application/pdf"), data: …, parameterName: …)`. +Each part's `Content-Type` comes from its `FormDataPartType`, which carries the MIME string. The default is `.octetStream` (`application/octet-stream`); `.png` and `.jpeg` are provided as conveniences. For anything else, construct one directly — `FormDataPart(type: FormDataPartType("application/pdf"), data: …, parameterName: …)`. ### Raw data @@ -454,7 +454,7 @@ let result: Result<[Story], NetworkingError> = await networking.get("/stories") **Faking with contents of a file**: -If your file is not located in the main bundle you have to specify using the bundle parameters, otherwise `NSBundle.mainBundle()` will be used. +A file outside the main bundle takes the bundle parameter; everything else uses `Bundle.main`. ```swift let networking = Networking(baseURL: baseURL) @@ -465,9 +465,9 @@ let result: Result = await networking.get("/entri **Faking with status code**: -If you do not provide a status code for this fake request, the default returned one will be 200 (SUCCESS), but if you do provide a status code that is not 2xx, then **Networking** returns `.failure(.http(HTTPError))` carrying that status code, with the fake's body in `metadata` for you to `decode` — see [Handling errors](#handling-errors). +A fake request defaults to 200 (SUCCESS). Give it a status code outside 2xx and **Networking** returns `.failure(.http(HTTPError))` carrying that status code, with the fake's body in `metadata` for you to `decode` — see [Handling errors](#handling-errors). -Use the no-body overload (omit `response:`) for a status-code-only fake: +Omit `response:` for a status-code-only fake: ```swift let networking = Networking(baseURL: "https://example.com") @@ -524,11 +524,11 @@ let _: Result = await networking.downloadImage("/image/p // Image from cache ``` -**Clearing and expiring the cache.** Call `await networking.clearCache()` to empty it — **both** the in-memory layer and the on-disk files. (`reset()` does the same, plus wiping credentials.) There's no per-path purge: `clearCache()` clears everything, and stale entries expire on their own (below). For a download, `downloadImage`/`downloadData` with `cachingLevel: .none` force a fresh fetch and drop that cached copy; for the verbs, `.none` simply bypasses the cache (it neither reads nor writes — and is the default, so it never disturbs an entry you cached deliberately). +**Clearing and expiring the cache.** Call `await networking.clearCache()` to empty it — **both** the in-memory layer and the on-disk files. (`reset()` does the same, plus wiping credentials.) `clearCache()` takes the whole cache at once: it clears everything, and stale entries expire on their own (below). For a download, `downloadImage`/`downloadData` with `cachingLevel: .none` force a fresh fetch and drop that cached copy; for the verbs, `.none` bypasses the cache and is the default, so an entry you cached deliberately stays where it is. -On-disk entries expire on their own, so a long-lived cache can't grow without bound. An entry whose **on-disk last use** is older than `cacheTTL` (default **7 days**; set it via `init(…, cacheTTL:)` or `setCacheTTL(_:)`) is swept. The persisted clock is the cache file's modification date, refreshed by **disk reads and writes** — caching an entry, or reading one back after it's left the in-memory layer, re-warms it; only genuinely idle entries are removed. The in-memory layer is the warm tier (served fast, left to `NSCache`'s own memory-pressure eviction) and does **not** re-stamp the disk file on a hit, so an entry kept warm *only* in memory for longer than `cacheTTL` may be re-fetched once it's evicted — in normal use it would have hit disk again first and stayed warm. +On-disk entries expire on their own, so a long-lived cache stays bounded. An entry whose **on-disk last use** is older than `cacheTTL` (default **7 days**; set it via `init(…, cacheTTL:)` or `setCacheTTL(_:)`) is swept. The persisted clock is the cache file's modification date, refreshed by **disk reads and writes** — caching an entry, or reading one back after it's left the in-memory layer, re-warms it; only genuinely idle entries are removed. The in-memory layer is the warm tier (served fast, left to `NSCache`'s own memory-pressure eviction) and leaves the disk file's date alone on a hit, so an entry kept warm *only* in memory for longer than `cacheTTL` may be re-fetched once it's evicted — in normal use it would have hit disk again first and stayed warm. -> **This is a key→blob store, not HTTP caching.** `cachingLevel` caches the downloaded bytes *unconditionally* by path (or `cacheName:`), ignoring `Cache-Control`/`ETag`/`Expires` — which is what you usually want for images and other assets whose hosts often send no cache headers at all. It deliberately does **not** implement HTTP cache semantics (freshness, conditional `304` revalidation, eviction). If you want those, configure a `URLCache` on the session and let `URLSession` handle it per the response headers — it composes with the verb requests: +> **This is a key→blob store rather than HTTP caching.** `cachingLevel` caches the downloaded bytes *unconditionally* by path (or `cacheName:`), ignoring `Cache-Control`/`ETag`/`Expires` — which is what you usually want for images and other assets whose hosts often omit cache headers entirely. HTTP cache semantics — freshness, conditional `304` revalidation, eviction — are deliberately out of scope. For those, configure a `URLCache` on the session and let `URLSession` handle it per the response headers — it composes with the verb requests: > > ```swift > let configuration = URLSessionConfiguration.default @@ -551,7 +551,7 @@ let result: Result = await networking.downloadImage("/im **Networking** doesn't print to the console. Two separate things give you visibility: a **failure log** that's on by default (next section), and an **event stream** you can hook for the full lifecycle. -`events()` returns an `AsyncStream` — one `.started` then one `.completed` for **every** request (verbs *and* downloads). Iterate it with `for await`, accumulating into plain local state (no callback-capture gymnastics): +`events()` returns an `AsyncStream` — one `.started` then one `.completed` for **every** request (verbs *and* downloads). Iterate it with `for await`, accumulating into plain local state: ```swift let stream = await networking.events() @@ -585,7 +585,7 @@ await networking.setRedactedHeaderFields(["Authorization", "X-Api-Key"]) ### Built-in logging -Out of the box — no setup — the library logs **failures** (HTTP 4xx/5xx, decoding, transport, invalid-request) to Apple's unified logging (`os.Logger`, subsystem `com.elvisnunez.networking`), tagged with the request id. This is the modern replacement for console `print`: it appears automatically in the Xcode console and Console.app, is filterable, and honors privacy annotations. +Straight out of the box the library logs **failures** (HTTP 4xx/5xx, decoding, transport, invalid-request) to Apple's unified logging (`os.Logger`, subsystem `com.elvisnunez.networking`), tagged with the request id. This is the modern replacement for console `print`: it appears automatically in the Xcode console and Console.app, is filterable, and honors privacy annotations. `setLogLevel` chooses **which requests** are logged — logged requests always get full detail (line, request + response headers, request + response bodies, truncated): @@ -597,7 +597,7 @@ await networking.setLogLevel(.all) // every request too, success or failur `.failures` is the default: failures are rare, so logging them in full is cheap and it's the case you debug — including the **request body**, the quickest way to catch a wrong-shaped payload. `.all` adds successful requests (with their response body, for "succeeded but returned the wrong thing"). The level gates *only* the built-in logging — `events()` always delivers full structured events regardless. (Downloads — `downloadImage`/`downloadData` — log the line + request headers; their response headers/body are omitted since the payload is binary.) -**Privacy — one rule: debug shows, release redacts.** Requests carry sensitive data (logins, payments, profiles, `Authorization`/`Cookie` headers). `redactsLogs` governs the built-in **logs**: in **debug** builds it shows everything (you're debugging — "is my auth header set?" must be answerable); in **release** it replaces both the body lines *and* the `setRedactedHeaderFields` header values (`Authorization`/`Cookie`/`Set-Cookie` by default) with ``. Override either way with `setRedactsLogs(_:)`. One caveat on what redaction does **not** cover: `events()` always carries the **real** headers — it's your own request data, and redaction is a logging concern, not an observation one. Need more control? `setLogLevel(.none)` or filter `events()` yourself. +**Privacy — one rule: debug shows, release redacts.** Requests carry sensitive data (logins, payments, profiles, `Authorization`/`Cookie` headers). `redactsLogs` governs the built-in **logs**: in **debug** builds it shows everything (you're debugging — "is my auth header set?" must be answerable); in **release** it replaces both the body lines *and* the `setRedactedHeaderFields` header values (`Authorization`/`Cookie`/`Set-Cookie` by default) with ``. Override either way with `setRedactsLogs(_:)`. One boundary worth knowing: `events()` always carries the **real** headers — it's your own request data, and redaction is a logging concern rather than an observation one. For more control, `setLogLevel(.none)` or filter `events()` yourself. **Reading logs from a CLI / test / headless run.** `os.Logger` isn't visible in `swift test` / `swift run` stdout. Point the library at a file and it mirrors the same diagnostics there as plain text: @@ -605,7 +605,7 @@ await networking.setLogLevel(.all) // every request too, success or failur await networking.setLogFileURL(URL(fileURLWithPath: "/tmp/networking.log")) ``` -Or set it with **no code change** via the `NETWORKING_LOG_FILE` environment variable — handy for CI or an automated agent: +Or set it from the environment with **`NETWORKING_LOG_FILE`**, leaving the code untouched — handy for CI or an automated agent: ```shell NETWORKING_LOG_FILE=/tmp/networking.log swift test @@ -624,7 +624,7 @@ dir=$(xcrun simctl get_app_container data) cat "$dir/Library/Caches/networking.log" ``` -(On a physical device there's no `simctl`; fall back to Console.app / `os.Logger`.) +(A physical device has Console.app / `os.Logger` in place of `simctl`.) ## Installing @@ -656,7 +656,7 @@ swift test make httpbin-stop ``` -Tests default to `http://127.0.0.1:8080`; set `HTTPBIN_BASE_URL` to point elsewhere. The offline (faked) suites need no server. +Tests default to `http://127.0.0.1:8080`; set `HTTPBIN_BASE_URL` to point elsewhere. The offline (faked) suites run on their own. ## Author diff --git a/Sources/Networking/CacheExpiry.swift b/Sources/Networking/CacheExpiry.swift index 8df0b38..b7fa7af 100644 --- a/Sources/Networking/CacheExpiry.swift +++ b/Sources/Networking/CacheExpiry.swift @@ -18,6 +18,8 @@ final class CacheExpiry: @unchecked Sendable { return .seconds(ttlSeconds) } + // The lock-guarded write itself; the lock is the reason this is a function. + // oida:disable:next no_single_use_void_functions func setTTL(_ ttl: Duration) { lock.lock() defer { lock.unlock() } diff --git a/Sources/Networking/CacheStore.swift b/Sources/Networking/CacheStore.swift index ed22674..3b394f3 100644 --- a/Sources/Networking/CacheStore.swift +++ b/Sources/Networking/CacheStore.swift @@ -12,16 +12,21 @@ final class CacheStore: @unchecked Sendable { let expiry: CacheExpiry let folderName: String - init(memory: NSCache, ttl: Duration, folderName: String) { + init( + memory: NSCache, + ttl: Duration, + folderName: String + ) { self.memory = memory self.expiry = CacheExpiry(ttl: ttl) self.folderName = folderName } var ttl: Duration { expiry.ttl } - func setTTL(_ ttl: Duration) { expiry.setTTL(ttl) } - // MARK: - Layout + // Forwards to an expiry the caller cannot reach, since it is private to the store. + // oida:disable:next no_single_use_void_functions + func setTTL(_ ttl: Duration) { expiry.setTTL(ttl) } /// The on-disk URL for a resolved resource key, laid out under `folderName//`. Creates the /// shard directory if needed. @@ -36,13 +41,26 @@ final class CacheStore: @unchecked Sendable { let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { throw NSError( - domain: folderName, code: 9999, - userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(finalPath)"]) + domain: folderName, + code: 9999, + userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(finalPath)"] + ) } - let folderURL = cachesURL.appendingPathComponent(URL(string: folderPath)!.absoluteString) + guard let folderComponent = URL(string: folderPath)?.absoluteString else { + throw NSError( + domain: folderName, + code: 9999, + userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(folderPath)"] + ) + } + let folderURL = cachesURL.appendingPathComponent(folderComponent) if FileManager.default.exists(at: folderURL) == false { - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true, + attributes: nil + ) } return cachesURL.appendingPathComponent(url.absoluteString) } @@ -73,12 +91,14 @@ final class CacheStore: @unchecked Sendable { return "\(prefix)-\(hash)" } - // MARK: - Read - /// A pure read: serve from the warm tier, falling back to a non-expired disk entry (which re-warms both /// tiers). Never mutates a tier except to drop an entry it finds expired. `.memory`/`.none` never touch /// disk, so a read can't destroy a durable copy written at `.memoryAndFile`. - func object(forResource resource: String, level: Networking.CachingLevel, asImage: Bool) throws -> Any? { + func object( + forResource resource: String, + level: Networking.CachingLevel, + asImage: Bool + ) throws -> Any? { let destinationURL = try destinationURL(forResource: resource) let key = destinationURL.absoluteString switch level { @@ -119,9 +139,13 @@ final class CacheStore: @unchecked Sendable { } } - // MARK: - Write - - func storeData(_ data: Data?, forResource resource: String, level: Networking.CachingLevel) throws { + // The store's write seam, reached from Networking and driven directly by six tests. + // oida:disable:next no_single_use_void_functions + func storeData( + _ data: Data?, + forResource resource: String, + level: Networking.CachingLevel + ) throws { let destinationURL = try destinationURL(forResource: resource) let key = destinationURL.absoluteString @@ -142,12 +166,19 @@ final class CacheStore: @unchecked Sendable { } @discardableResult - func storeImage(data: Data?, forResource resource: String, level: Networking.CachingLevel) throws -> Image? { + func storeImage( + data: Data?, + forResource resource: String, + level: Networking.CachingLevel + ) throws -> Image? { let destinationURL = try destinationURL(forResource: resource) let key = destinationURL.absoluteString var image: Image? - if let data = data, let nonOptionalImage = Image(data: data), data.count > 0 { + if let data = data, + let nonOptionalImage = Image(data: data), + data.count > 0 + { switch level { case .memory: memory.setObject(nonOptionalImage, forKey: key as AnyObject) @@ -165,13 +196,13 @@ final class CacheStore: @unchecked Sendable { return image } - // MARK: - Clear & sweep - // Serializes whole-folder mutations of the shared cache directory so the background sweep (which // creates the folder + writes its cursor) can't race a clear. static let mutationLock = NSLock() static let sweepCursorFileName = ".sweep-shard" + // The store's clear seam, reached from Networking and driven directly by three tests. + // oida:disable no_single_use_void_functions /// Empties **both** tiers (clearing only one would leave the other serving deleted data). Scoped to the /// networking folder; unrelated files in Caches are untouched. func clear() throws { @@ -183,17 +214,23 @@ final class CacheStore: @unchecked Sendable { _ = try FileManager.default.remove(at: folderURL) } } + // oida:enable no_single_use_void_functions private static func folderURL(named folderName: String) -> URL? { guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil } - return cachesURL.appendingPathComponent(URL(string: folderName)!.absoluteString) + guard let folderComponent = URL(string: folderName)?.absoluteString else { + return nil + } + return cachesURL.appendingPathComponent(folderComponent) } // Deletes expired files from **one** shard per call (rotated via a tiny cursor file), so each launch's // sweep is O(N / shardCount) and everything gets visited over `shardCount` launches. Age is judged by // the file's modification date. Best-effort and off the request path. + // The store's sweep seam, reached from Networking on a detached task. + // oida:disable:next no_single_use_void_functions func sweepExpired() { Self.mutationLock.lock() defer { Self.mutationLock.unlock() } @@ -209,8 +246,10 @@ final class CacheStore: @unchecked Sendable { let shardURL = domainURL.appendingPathComponent(String(cursor % Self.shardCount, radix: 16)) if let files = try? FileManager.default.contentsOfDirectory( - at: shardURL, includingPropertiesForKeys: [.contentModificationDateKey], options: []) - { + at: shardURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [] + ) { for file in files { guard let modified = try? file.resourceValues(forKeys: [.contentModificationDateKey]) @@ -225,8 +264,10 @@ final class CacheStore: @unchecked Sendable { // Pre-sharding versions wrote files directly under the domain root; clear those strays (the cursor // file and the shard subdirectories stay). if let rootEntries = try? FileManager.default.contentsOfDirectory( - at: domainURL, includingPropertiesForKeys: [.isRegularFileKey], options: []) - { + at: domainURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [] + ) { for entry in rootEntries where entry.lastPathComponent != Self.sweepCursorFileName { if (try? entry.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true { try? FileManager.default.removeItem(at: entry) @@ -235,6 +276,10 @@ final class CacheStore: @unchecked Sendable { } try? FileManager.default.createDirectory(at: domainURL, withIntermediateDirectories: true) - try? String(cursor &+ 1).write(to: cursorURL, atomically: true, encoding: .utf8) + try? String(cursor &+ 1).write( + to: cursorURL, + atomically: true, + encoding: .utf8 + ) } } diff --git a/Sources/Networking/DownloadResponse.swift b/Sources/Networking/DownloadResponse.swift index 790fc68..d97b0d2 100644 --- a/Sources/Networking/DownloadResponse.swift +++ b/Sources/Networking/DownloadResponse.swift @@ -10,38 +10,63 @@ import Foundation /// envelope that also carries the status code and headers. The factory is how `downloadImage` /// builds the requested type — not meant to be called directly. public protocol ImageDownloadable { - static func makeDownloadResult(image: Image, statusCode: Int, headers: [String: AnyCodable]) -> Self + static func makeDownloadResult( + image: Image, + statusCode: Int, + headers: [String: AnyCodable] + ) -> Self } /// The payload types a `downloadData` call can produce: the bare `Data`, or a `DataResponse` /// envelope that also carries the status code and headers. public protocol DataDownloadable { - static func makeDownloadResult(data: Data, statusCode: Int, headers: [String: AnyCodable]) -> Self + static func makeDownloadResult( + data: Data, + statusCode: Int, + headers: [String: AnyCodable] + ) -> Self } extension Image: ImageDownloadable { - public static func makeDownloadResult(image: Image, statusCode: Int, headers: [String: AnyCodable]) -> Self { + public static func makeDownloadResult( + image: Image, + statusCode: Int, + headers: [String: AnyCodable] + ) -> Self { + // Self is the concrete type the caller asked for, and Image is the only case that reaches here. + // oida:disable:next force_cast return image as! Self } } extension Data: DataDownloadable { - public static func makeDownloadResult(data: Data, statusCode: Int, headers: [String: AnyCodable]) -> Self { + public static func makeDownloadResult( + data: Data, + statusCode: Int, + headers: [String: AnyCodable] + ) -> Self { return data } } -/// A downloaded image plus the response metadata, for callers that need the status/headers. // @unchecked: immutable struct; the only non-Sendable-provable field is the platform `Image`, // which is effectively read-only once downloaded. +/// A downloaded image plus the response metadata, for callers that need the status/headers. public struct ImageResponse: ImageDownloadable, @unchecked Sendable { public let statusCode: Int public let headers: [String: AnyCodable] public let image: Image - public static func makeDownloadResult(image: Image, statusCode: Int, headers: [String: AnyCodable]) -> ImageResponse - { - ImageResponse(statusCode: statusCode, headers: headers, image: image) + public static func makeDownloadResult( + image: Image, + statusCode: Int, + headers: [String: AnyCodable] + ) -> ImageResponse { + ImageResponse( + statusCode: statusCode, + headers: headers, + image: image + ) } } @@ -51,7 +76,15 @@ public struct DataResponse: DataDownloadable, Sendable { public let headers: [String: AnyCodable] public let data: Data - public static func makeDownloadResult(data: Data, statusCode: Int, headers: [String: AnyCodable]) -> DataResponse { - DataResponse(statusCode: statusCode, headers: headers, data: data) + public static func makeDownloadResult( + data: Data, + statusCode: Int, + headers: [String: AnyCodable] + ) -> DataResponse { + DataResponse( + statusCode: statusCode, + headers: headers, + data: data + ) } } diff --git a/Sources/Networking/FakeRequest.swift b/Sources/Networking/FakeRequest.swift index a5b2924..0b33d39 100644 --- a/Sources/Networking/FakeRequest.swift +++ b/Sources/Networking/FakeRequest.swift @@ -15,19 +15,9 @@ struct FakeRequest { let statusCode: Int let delay: Double - init( - payload: Payload, responseType: Networking.ResponseType, headerFields: [String: String]?, statusCode: Int, - delay: Double - ) { - self.payload = payload - self.responseType = responseType - self.headerFields = headerFields - self.statusCode = statusCode - self.delay = delay - } - static func find( - ofType type: Networking.RequestType, forPath path: String, + ofType type: Networking.RequestType, + forPath path: String, in collection: [Networking.RequestType: [String: FakeRequest]] ) throws -> FakeRequest? { guard let requests = collection[type] else { return nil } @@ -35,59 +25,71 @@ struct FakeRequest { if let result = requests[path] { return result - } else { - var evaluatedPath = path - evaluatedPath.removeFirstLetterIfDash() - evaluatedPath.removeLastLetterIfDash() - let lookupPathParts = evaluatedPath.components(separatedBy: "/") - - for (originalFakedPath, fakeRequest) in requests { - guard originalFakedPath.contains("{") else { continue } - - var fakedPath = originalFakedPath - fakedPath.removeFirstLetterIfDash() - fakedPath.removeLastLetterIfDash() - let fakePathParts = fakedPath.components(separatedBy: "/") - - guard lookupPathParts.count == fakePathParts.count else { continue } - guard lookupPathParts.first == fakePathParts.first else { continue } - guard lookupPathParts.count != 1 && fakePathParts.count != 1 else { continue } - - var replacedValues = [String: String]() - for (index, fakePathPart) in fakePathParts.enumerated() { - if fakePathPart.contains("{") { - replacedValues[fakePathPart] = lookupPathParts[index] - } - } - - var replacedPath = originalFakedPath - for (key, value) in replacedValues { - replacedPath = replacedPath.replacingOccurrences(of: key, with: value) - } - guard replacedPath == path else { continue } - // Substitute the captured path values into the JSON body string (e.g. "{userID}" -> "10"). - guard case .data(let data) = fakeRequest.payload, - var responseString = String(data: data, encoding: .utf8) - else { continue } - - for (key, value) in replacedValues { - responseString = responseString.replacingOccurrences(of: key, with: value) - } - - guard let stringData = responseString.data(using: .utf8) else { continue } - return FakeRequest( - payload: .data(stringData), responseType: fakeRequest.responseType, - headerFields: fakeRequest.headerFields, statusCode: fakeRequest.statusCode, delay: fakeRequest.delay - ) - } + } + return templated(forPath: path, in: requests) + } + + // A faked path may carry placeholders — "/users/{userID}" answers "/users/10" — so a lookup that + // missed exactly walks the templated ones, and the values it captured are substituted into the body + // as well as the path. + private static func templated(forPath path: String, in requests: [String: FakeRequest]) -> FakeRequest? { + var evaluatedPath = path + evaluatedPath.removeFirstLetterIfDash() + evaluatedPath.removeLastLetterIfDash() + let lookupPathParts = evaluatedPath.components(separatedBy: "/") + + for (originalFakedPath, fakeRequest) in requests { + guard let replacedValues = captures(from: originalFakedPath, matching: lookupPathParts), + originalFakedPath.replacing(replacedValues) == path + else { continue } + + guard case .data(let data) = fakeRequest.payload, let responseString = String(data: data, encoding: .utf8) + else { continue } + + let substituted = responseString.replacing(replacedValues) + guard let stringData = substituted.data(using: .utf8) else { continue } + return FakeRequest( + payload: .data(stringData), + responseType: fakeRequest.responseType, + headerFields: fakeRequest.headerFields, + statusCode: fakeRequest.statusCode, + delay: fakeRequest.delay + ) } return nil } + // The values a templated path captures from a lookup — "/users/{userID}" against "/users/10" gives + // ["{userID}": "10"] — or nil where the two do not describe the same path at all. + private static func captures(from fakedPath: String, matching lookupPathParts: [String]) -> [String: String]? { + guard fakedPath.contains("{") else { return nil } + + var trimmed = fakedPath + trimmed.removeFirstLetterIfDash() + trimmed.removeLastLetterIfDash() + let fakePathParts = trimmed.components(separatedBy: "/") + + guard lookupPathParts.count == fakePathParts.count, + lookupPathParts.first == fakePathParts.first, + lookupPathParts.count != 1 + else { return nil } + + var captured = [String: String]() + for (index, fakePathPart) in fakePathParts.enumerated() where fakePathPart.contains("{") { + captured[fakePathPart] = lookupPathParts[index] + } + return captured + } + } extension String { + // Placeholder -> captured value, applied to whichever string carries the placeholders. + func replacing(_ values: [String: String]) -> String { + values.reduce(self) { $0.replacingOccurrences(of: $1.key, with: $1.value) } + } + mutating func removeFirstLetterIfDash() { let initialCharacter = String(self[.. HTTPExchange - ) async throws -> HTTPExchange + ) async throws + -> HTTPExchange } /// On an unauthorized response, refresh the credential and replay the request once. @@ -42,7 +43,9 @@ public struct AuthRefreshInterceptor: HTTPInterceptor { public func intercept( _ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange - ) async throws -> HTTPExchange { + ) async throws + -> HTTPExchange + { let exchange = try await next(request) guard triggeringStatusCodes.contains(exchange.response.statusCode) else { return exchange } guard let refreshedValue = try await coordinator.refreshOnce() else { return exchange } @@ -83,7 +86,9 @@ public struct RetryInterceptor: HTTPInterceptor { public func intercept( _ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange - ) async throws -> HTTPExchange { + ) async throws + -> HTTPExchange + { let methodAllowsRetry = retryableMethods.contains((request.httpMethod ?? "GET").uppercased()) var attempt = 1 while true { @@ -147,7 +152,9 @@ public struct ResponseValidatorInterceptor: HTTPInterceptor { public func intercept( _ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange - ) async throws -> HTTPExchange { + ) async throws + -> HTTPExchange + { let exchange = try await next(request) guard (200..<300).contains(exchange.response.statusCode) else { return exchange } switch validate(exchange) { diff --git a/Sources/Networking/Helpers.swift b/Sources/Networking/Helpers.swift index a6294ba..d2bbad6 100644 --- a/Sources/Networking/Helpers.swift +++ b/Sources/Networking/Helpers.swift @@ -31,8 +31,10 @@ extension Dictionary where Key: ExpressibleByStringLiteral { return current + ["\(keyValuePair.key)=\(encodedValue)"] } else { throw NSError( - domain: Networking.domain, code: 0, - userInfo: [NSLocalizedDescriptionKey: "Couldn't encode \(keyValuePair.value)"]) + domain: Networking.domain, + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Couldn't encode \(keyValuePair.value)"] + ) } } @@ -73,8 +75,13 @@ extension FileManager { extension URLRequest { init( - url: URL, requestType: Networking.RequestType, contentType: String?, responseType: Networking.ResponseType, - authorizationHeaderValue: String?, token: String?, authorizationHeaderKey: String, + url: URL, + requestType: Networking.RequestType, + contentType: String?, + responseType: Networking.ResponseType, + authorizationHeaderValue: String?, + token: String?, + authorizationHeaderKey: String, headerFields: [String: String]? ) { self = URLRequest(url: url) @@ -103,15 +110,28 @@ extension URLRequest { } extension HTTPURLResponse { - convenience init(url: URL, headerFields: [String: String]? = nil, statusCode: Int) { - self.init(url: url, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)! + convenience init( + url: URL, + headerFields: [String: String]? = nil, + statusCode: Int + ) { + // composedURL has already parsed this URL and thrown on anything that would not, so the only + // input Foundation refuses cannot reach here. + self.init( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: headerFields + )! } } extension NSError { convenience init(statusCode: Int) { self.init( - domain: Networking.domain, code: statusCode, - userInfo: [NSLocalizedDescriptionKey: HTTPURLResponse.localizedString(forStatusCode: statusCode)]) + domain: Networking.domain, + code: statusCode, + userInfo: [NSLocalizedDescriptionKey: HTTPURLResponse.localizedString(forStatusCode: statusCode)] + ) } } diff --git a/Sources/Networking/Image.swift b/Sources/Networking/Image.swift index c747a40..d260d7b 100644 --- a/Sources/Networking/Image.swift +++ b/Sources/Networking/Image.swift @@ -9,20 +9,29 @@ extension Image { static func find(named name: String, inBundle bundle: Bundle) -> Image { #if os(macOS) - return bundle.image(forResource: name)! + let found = bundle.image(forResource: name) #elseif os(watchOS) - return UIImage(named: name)! + let found = UIImage(named: name) #else - return UIImage(named: name, in: bundle, compatibleWith: nil)! + let found = UIImage( + named: name, + in: bundle, + compatibleWith: nil + ) #endif + guard let found else { + // The asset ships inside the bundle, so its absence is a packaging fault, not a caller's. + fatalError("\(bundle.bundleURL.lastPathComponent) carries no image named \(name)") + } + return found } #if os(macOS) func data(_ type: NSBitmapImageRep.FileType) -> Data? { - let imageData = tiffRepresentation! - let bitmapImageRep = NSBitmapImageRep(data: imageData)! - let data = bitmapImageRep.representation(using: type, properties: [NSBitmapImageRep.PropertyKey: Any]()) - return data + guard let imageData = tiffRepresentation, let bitmapImageRep = NSBitmapImageRep(data: imageData) else { + return nil + } + return bitmapImageRep.representation(using: type, properties: [NSBitmapImageRep.PropertyKey: Any]()) } #endif diff --git a/Sources/Networking/Networking+HTTPRequests.swift b/Sources/Networking/Networking+HTTPRequests.swift index 8e1fec6..27afd22 100644 --- a/Sources/Networking/Networking+HTTPRequests.swift +++ b/Sources/Networking/Networking+HTTPRequests.swift @@ -2,22 +2,36 @@ import Foundation extension Networking { - // MARK: GET - - public func get(_ path: String, query: [URLQueryItem]? = nil, cachingLevel: CachingLevel = .none) + public func get( + _ path: String, + query: [URLQueryItem]? = nil, + cachingLevel: CachingLevel = .none + ) async -> Result { - return await handle(.get, path: path, query: query ?? [], cachingLevel: cachingLevel) - } - - public func get(_ path: String, query: Q, cachingLevel: CachingLevel = .none) async + return await handle( + .get, + path: path, + query: query ?? [], + cachingLevel: cachingLevel + ) + } + + public func get( + _ path: String, + query: Q, + cachingLevel: CachingLevel = .none + ) async -> Result { - return await queryEncodeAndHandle(.get, path: path, query: query, cachingLevel: cachingLevel) + return await queryEncodeAndHandle( + .get, + path: path, + query: query, + cachingLevel: cachingLevel + ) } - // MARK: POST - public func post(_ path: String) async -> Result { return await handle(.post, path: path) } @@ -29,51 +43,94 @@ extension Networking { /// JSON body, ISO-8601 dates. public func post(_ path: String, body: B) async -> Result { - return await encodeAndHandle(.post, path: path, body: body) + return await encodeAndHandle( + .post, + path: path, + body: body + ) } public func post(_ path: String, body: B) async -> Result { - let result: Result = await encodeAndHandle(.post, path: path, body: body) + let result: Result = await encodeAndHandle( + .post, + path: path, + body: body + ) return result.map { _ in () } } /// `application/x-www-form-urlencoded` body, built from a flat `Encodable`. public func post(_ path: String, form: F) async -> Result { - return await formEncodeAndHandle(.post, path: path, form: form) + return await formEncodeAndHandle( + .post, + path: path, + form: form + ) } public func post(_ path: String, form: F) async -> Result { - let result: Result = await formEncodeAndHandle(.post, path: path, form: form) + let result: Result = await formEncodeAndHandle( + .post, + path: path, + form: form + ) return result.map { _ in () } } - public func post(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async + public func post( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result { - return await handle(.post, path: path, body: .multipart(fields: fields, parts: parts)) - } - - public func post(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async -> Result< + return await handle( + .post, + path: path, + body: .multipart(fields: fields, parts: parts) + ) + } + + public func post( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result< Void, NetworkingError > { let result: Result = await handle( - .post, path: path, body: .multipart(fields: fields, parts: parts)) + .post, + path: path, + body: .multipart(fields: fields, parts: parts) + ) return result.map { _ in () } } - public func post(_ path: String, data: Data, contentType: String) async -> Result - { - return await handle(.post, path: path, body: .raw(data, contentType: contentType)) - } - - public func post(_ path: String, data: Data, contentType: String) async -> Result { + public func post( + _ path: String, + data: Data, + contentType: String + ) async -> Result { + return await handle( + .post, + path: path, + body: .raw(data, contentType: contentType) + ) + } + + public func post( + _ path: String, + data: Data, + contentType: String + ) async -> Result { let result: Result = await handle( - .post, path: path, body: .raw(data, contentType: contentType)) + .post, + path: path, + body: .raw(data, contentType: contentType) + ) return result.map { _ in () } } - // MARK: PUT - public func put(_ path: String) async -> Result { return await handle(.put, path: path) } @@ -84,49 +141,93 @@ extension Networking { } public func put(_ path: String, body: B) async -> Result { - return await encodeAndHandle(.put, path: path, body: body) + return await encodeAndHandle( + .put, + path: path, + body: body + ) } public func put(_ path: String, body: B) async -> Result { - let result: Result = await encodeAndHandle(.put, path: path, body: body) + let result: Result = await encodeAndHandle( + .put, + path: path, + body: body + ) return result.map { _ in () } } public func put(_ path: String, form: F) async -> Result { - return await formEncodeAndHandle(.put, path: path, form: form) + return await formEncodeAndHandle( + .put, + path: path, + form: form + ) } public func put(_ path: String, form: F) async -> Result { - let result: Result = await formEncodeAndHandle(.put, path: path, form: form) + let result: Result = await formEncodeAndHandle( + .put, + path: path, + form: form + ) return result.map { _ in () } } - public func put(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async + public func put( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result { - return await handle(.put, path: path, body: .multipart(fields: fields, parts: parts)) - } - - public func put(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async -> Result< + return await handle( + .put, + path: path, + body: .multipart(fields: fields, parts: parts) + ) + } + + public func put( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result< Void, NetworkingError > { let result: Result = await handle( - .put, path: path, body: .multipart(fields: fields, parts: parts)) + .put, + path: path, + body: .multipart(fields: fields, parts: parts) + ) return result.map { _ in () } } - public func put(_ path: String, data: Data, contentType: String) async -> Result { - return await handle(.put, path: path, body: .raw(data, contentType: contentType)) - } - - public func put(_ path: String, data: Data, contentType: String) async -> Result { + public func put( + _ path: String, + data: Data, + contentType: String + ) async -> Result { + return await handle( + .put, + path: path, + body: .raw(data, contentType: contentType) + ) + } + + public func put( + _ path: String, + data: Data, + contentType: String + ) async -> Result { let result: Result = await handle( - .put, path: path, body: .raw(data, contentType: contentType)) + .put, + path: path, + body: .raw(data, contentType: contentType) + ) return result.map { _ in () } } - // MARK: PATCH - public func patch(_ path: String) async -> Result { return await handle(.patch, path: path) } @@ -137,66 +238,126 @@ extension Networking { } public func patch(_ path: String, body: B) async -> Result { - return await encodeAndHandle(.patch, path: path, body: body) + return await encodeAndHandle( + .patch, + path: path, + body: body + ) } public func patch(_ path: String, body: B) async -> Result { - let result: Result = await encodeAndHandle(.patch, path: path, body: body) + let result: Result = await encodeAndHandle( + .patch, + path: path, + body: body + ) return result.map { _ in () } } public func patch(_ path: String, form: F) async -> Result { - return await formEncodeAndHandle(.patch, path: path, form: form) + return await formEncodeAndHandle( + .patch, + path: path, + form: form + ) } public func patch(_ path: String, form: F) async -> Result { - let result: Result = await formEncodeAndHandle(.patch, path: path, form: form) + let result: Result = await formEncodeAndHandle( + .patch, + path: path, + form: form + ) return result.map { _ in () } } - public func patch(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async + public func patch( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result { - return await handle(.patch, path: path, body: .multipart(fields: fields, parts: parts)) - } - - public func patch(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async -> Result< + return await handle( + .patch, + path: path, + body: .multipart(fields: fields, parts: parts) + ) + } + + public func patch( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result< Void, NetworkingError > { let result: Result = await handle( - .patch, path: path, body: .multipart(fields: fields, parts: parts)) + .patch, + path: path, + body: .multipart(fields: fields, parts: parts) + ) return result.map { _ in () } } - public func patch(_ path: String, data: Data, contentType: String) async -> Result - { - return await handle(.patch, path: path, body: .raw(data, contentType: contentType)) - } - - public func patch(_ path: String, data: Data, contentType: String) async -> Result { + public func patch( + _ path: String, + data: Data, + contentType: String + ) async -> Result { + return await handle( + .patch, + path: path, + body: .raw(data, contentType: contentType) + ) + } + + public func patch( + _ path: String, + data: Data, + contentType: String + ) async -> Result { let result: Result = await handle( - .patch, path: path, body: .raw(data, contentType: contentType)) + .patch, + path: path, + body: .raw(data, contentType: contentType) + ) return result.map { _ in () } } - // MARK: DELETE - public func delete(_ path: String, query: [URLQueryItem]? = nil) async -> Result { - return await handle(.delete, path: path, query: query ?? []) + return await handle( + .delete, + path: path, + query: query ?? [] + ) } public func delete(_ path: String, query: [URLQueryItem]? = nil) async -> Result { - let result: Result = await handle(.delete, path: path, query: query ?? []) + let result: Result = await handle( + .delete, + path: path, + query: query ?? [] + ) return result.map { _ in () } } public func delete(_ path: String, query: Q) async -> Result { - return await queryEncodeAndHandle(.delete, path: path, query: query, cachingLevel: .none) + return await queryEncodeAndHandle( + .delete, + path: path, + query: query, + cachingLevel: .none + ) } public func delete(_ path: String, query: Q) async -> Result { let result: Result = await queryEncodeAndHandle( - .delete, path: path, query: query, cachingLevel: .none) + .delete, + path: path, + query: query, + cachingLevel: .none + ) return result.map { _ in () } } } @@ -209,7 +370,11 @@ extension Networking { return encoder }() - func encodeAndHandle(_ requestType: RequestType, path: String, body: B) async -> Result< + func encodeAndHandle( + _ requestType: RequestType, + path: String, + body: B + ) async -> Result< T, NetworkingError > { let data: Data @@ -217,10 +382,16 @@ extension Networking { data = try Self.requestBodyEncoder.encode(body) } catch { return emitPreflightFailure( - requestType, path: path, - error: .invalidRequest(.bodyEncodingFailed(message: error.localizedDescription))) + requestType, + path: path, + error: .invalidRequest(.bodyEncodingFailed(message: error.localizedDescription)) + ) } - return await handle(requestType, path: path, body: .json(data)) + return await handle( + requestType, + path: path, + body: .json(data) + ) } // A fake whose response can't encode is a test setup bug — fail loudly here rather than silently @@ -235,7 +406,11 @@ extension Networking { } extension Networking { - func formEncodeAndHandle(_ requestType: RequestType, path: String, form: some Encodable) async + func formEncodeAndHandle( + _ requestType: RequestType, + path: String, + form: some Encodable + ) async -> Result { let fields: [String: String] @@ -243,191 +418,381 @@ extension Networking { fields = try formFields(from: form) } catch { return emitPreflightFailure( - requestType, path: path, - error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription))) + requestType, + path: path, + error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription)) + ) } - return await handle(requestType, path: path, body: .formURLEncoded(fields)) + return await handle( + requestType, + path: path, + body: .formURLEncoded(fields) + ) } func queryEncodeAndHandle( - _ requestType: RequestType, path: String, query: some Encodable, cachingLevel: CachingLevel + _ requestType: RequestType, + path: String, + query: some Encodable, + cachingLevel: CachingLevel ) async -> Result { let items: [URLQueryItem] do { items = try formFields(from: query).map { URLQueryItem(name: $0.key, value: $0.value) } } catch { return emitPreflightFailure( - requestType, path: path, - error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription))) + requestType, + path: path, + error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription)) + ) } - return await handle(requestType, path: path, query: items, cachingLevel: cachingLevel) + return await handle( + requestType, + path: path, + query: items, + cachingLevel: cachingLevel + ) } } -// MARK: - Faking requests - extension Networking { public func fakeGET( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( - requestType: .get, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .get, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } - public func fakeGET(_ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0) - { + public func fakeGET( + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 + ) { registerFake( - requestType: .get, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .get, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakeGET( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .get, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .get, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } public func fakePOST( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( - requestType: .post, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .post, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePOST( - _ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0 + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .post, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .post, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePOST( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .post, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .post, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } public func fakePUT( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( - requestType: .put, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .put, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } - public func fakePUT(_ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0) - { + public func fakePUT( + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 + ) { registerFake( - requestType: .put, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .put, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePUT( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .put, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .put, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } public func fakePATCH( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( - requestType: .patch, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .patch, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePATCH( - _ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0 + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .patch, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .patch, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePATCH( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .patch, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .patch, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } public func fakeDELETE( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( - requestType: .delete, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .delete, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakeDELETE( - _ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0 + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .delete, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .delete, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakeDELETE( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .delete, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .delete, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } } -// MARK: - Downloads - extension Networking { - public nonisolated func imageFromCache(_ path: String, cacheName: String? = nil) throws -> Image? { + nonisolated public func imageFromCache(_ path: String, cacheName: String? = nil) throws -> Image? { let object = try objectFromCache( - for: path, cacheName: cacheName, cachingLevel: .memoryAndFile, responseType: .image) + for: path, + cacheName: cacheName, + cachingLevel: .memoryAndFile, + responseType: .image + ) return object as? Image } public func downloadImage( - _ path: String, cacheName: String? = nil, cachingLevel: CachingLevel = .memoryAndFile + _ path: String, + cacheName: String? = nil, + cachingLevel: CachingLevel = .memoryAndFile ) async -> Result { return await handleImageRequest( - .get, path: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: .image) + .get, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel, + responseType: .image + ) } /// Completes the in-flight download for `path` with `URLError.cancelled`. public func cancelImageDownload(_ path: String) async throws { let url = try composedURL(with: path) - await cancelRequest(.data, requestType: .get, url: url) + await cancelRequest( + .data, + requestType: .get, + url: url + ) } public func fakeImageDownload( - _ path: String, image: Image, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0 + _ path: String, + image: Image, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( - requestType: .get, path: path, headerFields: headerFields, payload: .image(image), responseType: .image, - statusCode: statusCode, delay: delay) + requestType: .get, + path: path, + headerFields: headerFields, + payload: .image(image), + responseType: .image, + statusCode: statusCode, + delay: delay + ) } public func downloadData( - _ path: String, cacheName: String? = nil, cachingLevel: CachingLevel = .memoryAndFile + _ path: String, + cacheName: String? = nil, + cachingLevel: CachingLevel = .memoryAndFile ) async -> Result { return await handleDataRequest( - .get, path: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: .data) + .get, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel, + responseType: .data + ) } - public nonisolated func dataFromCache(_ path: String, cacheName: String? = nil) throws -> Data? { + nonisolated public func dataFromCache(_ path: String, cacheName: String? = nil) throws -> Data? { let object = try objectFromCache( - for: path, cacheName: cacheName, cachingLevel: .memoryAndFile, responseType: .data) + for: path, + cacheName: cacheName, + cachingLevel: .memoryAndFile, + responseType: .data + ) return object as? Data } diff --git a/Sources/Networking/Networking+New.swift b/Sources/Networking/Networking+New.swift index e19b736..1ab9f99 100644 --- a/Sources/Networking/Networking+New.swift +++ b/Sources/Networking/Networking+New.swift @@ -22,7 +22,10 @@ extension Networking { } func handle( - _ requestType: RequestType, path: String, body: RequestBody = .none, query: [URLQueryItem] = [], + _ requestType: RequestType, + path: String, + body: RequestBody = .none, + query: [URLQueryItem] = [], cachingLevel: CachingLevel = .none ) async -> Result { let requestID = UUID() @@ -37,20 +40,41 @@ extension Networking { var responseMetadata: ResponseMetadata? do { - if let fakeRequest = try FakeRequest.find(ofType: requestType, forPath: path, in: fakeRequests) { + if let fakeRequest = try FakeRequest.find( + ofType: requestType, + forPath: path, + in: fakeRequests + ) { let fakeContext = RequestContext( - id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) + id: requestID, + requestType: requestType, + url: try? composedURL(with: path), + headers: headerFields + ) context = fakeContext emit(.started(fakeContext)) let (fakeResult, fakeStatus, fakeBytes): (Result, Int, Int) = - try await handleFakeRequest(fakeRequest, path: path, requestType: requestType) + try await handleFakeRequest( + fakeRequest, + path: path, + requestType: requestType + ) result = fakeResult statusCode = fakeStatus byteCount = fakeBytes } else { - let request = try createRequest(path: path, requestType: requestType, body: body, query: query) + let request = try createRequest( + path: path, + requestType: requestType, + body: body, + query: query + ) let requestContext = RequestContext( - id: requestID, requestType: requestType, url: request.url, headers: request.allHTTPHeaderFields) + id: requestID, + requestType: requestType, + url: request.url, + headers: request.allHTTPHeaderFields + ) context = requestContext emit(.started(requestContext)) @@ -59,16 +83,28 @@ extension Networking { let cacheKey = cacheKey(for: request, fallbackPath: path) if cachingLevel != .none, let cachedData = try objectFromCache( - for: path, cacheName: cacheKey, cachingLevel: cachingLevel, responseType: .json) as? Data, + for: path, + cacheName: cacheKey, + cachingLevel: cachingLevel, + responseType: .json + ) as? Data, let cached = try? JSONDecoder().decode(CachedResponse.self, from: cachedData), let url = request.url, let cachedResponse = HTTPURLResponse( - url: url, statusCode: cached.statusCode, httpVersion: nil, headerFields: cached.headers) + url: url, + statusCode: cached.statusCode, + httpVersion: nil, + headerFields: cached.headers + ) { // Run the cache hit back out through the interceptor chain so validators apply to it too. let exchange = try await perform( request, cached: HTTPExchange(data: cached.body, response: cachedResponse)) - result = handleResponse(responseData: exchange.data, response: exchange.response, path: path) + result = handleResponse( + responseData: exchange.data, + response: exchange.response, + path: path + ) statusCode = exchange.response.statusCode byteCount = exchange.data.count responseMetadata = ResponseMetadata(response: exchange.response, body: exchange.data) @@ -78,8 +114,12 @@ extension Networking { let responseData = exchange.data let response: URLResponse = exchange.response let networkResult: Result = handleResponse( - responseData: responseData, response: response, path: path) - if cachingLevel != .none, case .success = networkResult, + responseData: responseData, + response: response, + path: path + ) + if cachingLevel != .none, + case .success = networkResult, let httpResponse = response as? HTTPURLResponse { let headers = Dictionary( @@ -87,10 +127,17 @@ extension Networking { (key as? String).map { ($0, "\(value)") } }) let envelope = CachedResponse( - statusCode: httpResponse.statusCode, headers: headers, body: responseData) + statusCode: httpResponse.statusCode, + headers: headers, + body: responseData + ) if let encoded = try? JSONEncoder().encode(envelope) { try? cacheOrPurgeData( - data: encoded, path: path, cacheName: cacheKey, cachingLevel: cachingLevel) + data: encoded, + path: path, + cacheName: cacheKey, + cachingLevel: cachingLevel + ) } } result = networkResult @@ -107,7 +154,11 @@ extension Networking { // .started/.completed so observers see every request. if context == nil { let errorContext = RequestContext( - id: requestID, requestType: requestType, url: nil, headers: headerFields) + id: requestID, + requestType: requestType, + url: nil, + headers: headerFields + ) context = errorContext emit(.started(errorContext)) } @@ -117,14 +168,25 @@ extension Networking { let duration = clock.now - startInstant guard let context else { return result } return complete( - result, context: context, statusCode: statusCode, byteCount: byteCount, metrics: metrics, - duration: duration, requestBody: body, responseMetadata: responseMetadata) + result, + context: context, + statusCode: statusCode, + byteCount: byteCount, + metrics: metrics, + duration: duration, + requestBody: body, + responseMetadata: responseMetadata + ) } // Runs the interceptor chain. On a cache hit, `cached` is the base result the chain folds around (no // network call), so validators still see it. session/collector are read into locals first so the // @Sendable chain captures no actor-isolated state (Swift 6 region isolation). - func perform(_ request: URLRequest, collector: MetricsCollector? = nil, cached: HTTPExchange? = nil) async throws + func perform( + _ request: URLRequest, + collector: MetricsCollector? = nil, + cached: HTTPExchange? = nil + ) async throws -> HTTPExchange { let session = self.session @@ -150,8 +212,13 @@ extension Networking { // The single completion path, shared by verbs, pre-flight failures, and downloads. `requestBody` and // `responseMetadata` aren't on the `.completed` event, so they're threaded here for logging only. func complete( - _ result: Result, context: RequestContext, statusCode: Int?, byteCount: Int, - metrics: TransactionMetrics?, duration: Duration, requestBody: RequestBody? = nil, + _ result: Result, + context: RequestContext, + statusCode: Int?, + byteCount: Int, + metrics: TransactionMetrics?, + duration: Duration, + requestBody: RequestBody? = nil, responseMetadata: ResponseMetadata? = nil ) -> Result { let outcome: Outcome @@ -162,15 +229,35 @@ extension Networking { outcome = .failure(error) } logCompletion( - context: context, result: result, statusCode: statusCode, byteCount: byteCount, duration: duration, - requestBody: requestBody, responseMetadata: responseMetadata) - emit(.completed(context, outcome: outcome, duration: duration, metrics: metrics)) + context: context, + result: result, + statusCode: statusCode, + byteCount: byteCount, + duration: duration, + requestBody: requestBody, + responseMetadata: responseMetadata + ) + emit( + .completed( + context, + outcome: outcome, + duration: duration, + metrics: metrics + )) return result } + // Fifty lines of logging behind a name, and three early exits the caller must never inherit — + // returning from complete would skip emit and the result. + // oida:disable:next no_single_use_void_functions private func logCompletion( - context: RequestContext, result: Result, statusCode: Int?, byteCount: Int, - duration: Duration, requestBody: RequestBody?, responseMetadata: ResponseMetadata? + context: RequestContext, + result: Result, + statusCode: Int?, + byteCount: Int, + duration: Duration, + requestBody: RequestBody?, + responseMetadata: ResponseMetadata? ) { guard logLevel != .none else { return } @@ -229,14 +316,29 @@ extension Networking { // Emits the `.started`/`.completed` pair for a failure that happens before the network call (e.g. // body encoding), so observers don't miss it. - func emitPreflightFailure(_ requestType: RequestType, path: String, error: NetworkingError) -> Result< + func emitPreflightFailure( + _ requestType: RequestType, + path: String, + error: NetworkingError + ) -> Result< T, NetworkingError > { let requestID = UUID() let context = RequestContext( - id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) + id: requestID, + requestType: requestType, + url: try? composedURL(with: path), + headers: headerFields + ) emit(.started(context)) - return complete(.failure(error), context: context, statusCode: nil, byteCount: 0, metrics: nil, duration: .zero) + return complete( + .failure(error), + context: context, + statusCode: nil, + byteCount: 0, + metrics: nil, + duration: .zero + ) } // Persists status code and headers alongside the body so a cache hit reproduces real metadata. @@ -247,9 +349,7 @@ extension Networking { } private func cacheKey(for request: URLRequest, fallbackPath: String) -> String { - guard let url = request.url, - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - else { + guard let url = request.url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return fallbackPath } // Sort the *encoded* query components so parameter order doesn't change the key (a cache @@ -260,10 +360,19 @@ extension Networking { return components.string ?? url.absoluteString } - private func handleFakeRequest(_ fakeRequest: FakeRequest, path: String, requestType: RequestType) + private func handleFakeRequest( + _ fakeRequest: FakeRequest, + path: String, + requestType: RequestType + ) async throws -> (Result, statusCode: Int, byteCount: Int) { - let (response, _) = try handleFakeRequest(fakeRequest, path: path, cacheName: nil, cachingLevel: .none) + let (response, _) = try handleFakeRequest( + fakeRequest, + path: path, + cacheName: nil, + cachingLevel: .none + ) if fakeRequest.delay > 0 { try? await Task.sleep(nanoseconds: UInt64(fakeRequest.delay * 1_000_000_000)) @@ -277,16 +386,28 @@ extension Networking { responseData = Data() } let result: Result = handleResponse( - responseData: responseData, response: response, path: path) + responseData: responseData, + response: response, + path: path + ) return (result, response.statusCode, responseData.count) } - private func createRequest(path: String, requestType: RequestType, body: RequestBody, query: [URLQueryItem]) throws + private func createRequest( + path: String, + requestType: RequestType, + body: RequestBody, + query: [URLQueryItem] + ) throws -> URLRequest { // Split a query embedded in the path so it survives URL building instead of being // percent-encoded into the path (encodeUTF8 uses .urlPathAllowed, which escapes "?"). - let pathParts = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + let pathParts = path.split( + separator: "?", + maxSplits: 1, + omittingEmptySubsequences: false + ) let rawPath = String(pathParts[0]) let pathQuery = pathParts.count > 1 ? String(pathParts[1]) : nil @@ -339,20 +460,24 @@ extension Networking { body += "--\(boundary)\r\n" body += "Content-Disposition: form-data; name=\"\(key)\"" body += "\r\n\r\n\(value)\r\n" - bodyData.append(body.data(using: .utf8)!) + bodyData.append(Data(body.utf8)) } for var part in parts { part.boundary = boundary bodyData.append(part.formData as Data) } - bodyData.append("--\(boundary)--\r\n".data(using: .utf8)!) + bodyData.append(Data("--\(boundary)--\r\n".utf8)) return bodyData case .raw(let data, _): return data } } - private func handleResponse(responseData: Data, response: URLResponse, path: String) -> Result< + private func handleResponse( + responseData: Data, + response: URLResponse, + path: String + ) -> Result< T, NetworkingError > { guard let httpResponse = response as? HTTPURLResponse else { @@ -363,7 +488,11 @@ extension Networking { switch StatusCodeType(statusCode: statusCode) { case .informational, .successful: - return handleSuccessfulResponse(responseData: responseData, path: path, httpResponse: httpResponse) + return handleSuccessfulResponse( + responseData: responseData, + path: path, + httpResponse: httpResponse + ) case .cancelled: return .failure(.cancelled) case .redirection, .clientError, .serverError, .unknown: @@ -373,10 +502,16 @@ extension Networking { } } - private func handleSuccessfulResponse(responseData: Data, path: String, httpResponse: HTTPURLResponse) + private func handleSuccessfulResponse( + responseData: Data, + path: String, + httpResponse: HTTPURLResponse + ) -> Result { if T.self == Data.self { + // Guarded by the test on the line above: T is Data here. + // oida:disable:next force_cast return .success(responseData as! T) } else if T.self == JSONResponse.self { let headers = Dictionary( @@ -387,7 +522,13 @@ extension Networking { // An empty body (e.g. 204 No Content) is a success — decoding empty data would fail, so use an empty body. let body = responseData.isEmpty ? [:] : try JSONDecoder().decode([String: AnyCodable].self, from: responseData) - let networkingJSON = JSONResponse(statusCode: httpResponse.statusCode, headers: headers, body: body) + let networkingJSON = JSONResponse( + statusCode: httpResponse.statusCode, + headers: headers, + body: body + ) + // Guarded by the `T.self == JSONResponse.self` test this branch is under. + // oida:disable:next force_cast return .success(networkingJSON as! T) } catch let error as DecodingError { return .failure(.decoding(error, ResponseMetadata(response: httpResponse, body: responseData))) diff --git a/Sources/Networking/Networking+Private.swift b/Sources/Networking/Networking+Private.swift index b2994cc..af427b8 100644 --- a/Sources/Networking/Networking+Private.swift +++ b/Sources/Networking/Networking+Private.swift @@ -2,15 +2,25 @@ import Foundation extension Networking { nonisolated func objectFromCache( - for path: String, cacheName: String?, cachingLevel: CachingLevel, responseType: ResponseType + for path: String, + cacheName: String?, + cachingLevel: CachingLevel, + responseType: ResponseType ) throws -> Any? { try cacheStore.object( - forResource: cacheResource(for: path, cacheName: cacheName), level: cachingLevel, - asImage: responseType == .image) + forResource: cacheResource(for: path, cacheName: cacheName), + level: cachingLevel, + asImage: responseType == .image + ) } func registerFake( - requestType: RequestType, path: String, fileName: String, bundle: Bundle, statusCode: Int, delay: Double + requestType: RequestType, + path: String, + fileName: String, + bundle: Bundle, + statusCode: Int, + delay: Double ) { let url = URL(string: fileName) guard let resource = url?.deletingPathExtension().absoluteString, @@ -20,28 +30,51 @@ extension Networking { fatalError("We couldn't find \(fileName), are you sure is there?") } registerFake( - requestType: requestType, path: path, headerFields: nil, payload: .data(data), responseType: .json, - statusCode: statusCode, delay: delay) + requestType: requestType, + path: path, + headerFields: nil, + payload: .data(data), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } func registerFake( - requestType: RequestType, path: String, headerFields: [String: String]?, payload: FakeRequest.Payload, - responseType: ResponseType, statusCode: Int, delay: Double + requestType: RequestType, + path: String, + headerFields: [String: String]?, + payload: FakeRequest.Payload, + responseType: ResponseType, + statusCode: Int, + delay: Double ) { var requests = fakeRequests[requestType] ?? [String: FakeRequest]() requests[path] = FakeRequest( - payload: payload, responseType: responseType, headerFields: headerFields, statusCode: statusCode, - delay: delay) + payload: payload, + responseType: responseType, + headerFields: headerFields, + statusCode: statusCode, + delay: delay + ) fakeRequests[requestType] = requests } - func handleFakeRequest(_ fakeRequest: FakeRequest, path: String, cacheName: String?, cachingLevel: CachingLevel) + func handleFakeRequest( + _ fakeRequest: FakeRequest, + path: String, + cacheName: String?, + cachingLevel: CachingLevel + ) throws -> (HTTPURLResponse, NSError?) { var error: NSError? let url = try composedURL(with: path) let response = HTTPURLResponse( - url: url, headerFields: fakeRequest.headerFields, statusCode: fakeRequest.statusCode) + url: url, + headerFields: fakeRequest.headerFields, + statusCode: fakeRequest.statusCode + ) if StatusCodeType(statusCode: fakeRequest.statusCode) != .successful { error = NSError(statusCode: fakeRequest.statusCode) @@ -51,22 +84,39 @@ extension Networking { let data: Data? = { if case .data(let data) = fakeRequest.payload { return data } else { return nil } }() switch fakeRequest.responseType { case .image: - try cacheOrPurgeImage(data: nil, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + try cacheOrPurgeImage( + data: nil, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) case .data, .json: - try cacheOrPurgeData(data: data, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + try cacheOrPurgeData( + data: data, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) } return (response, error) } func handleDataRequest( - _ requestType: RequestType, path: String, cacheName: String?, cachingLevel: CachingLevel, + _ requestType: RequestType, + path: String, + cacheName: String?, + cachingLevel: CachingLevel, responseType: ResponseType ) async -> Result { let requestID = UUID() let clock = ContinuousClock() let startInstant = clock.now let context = RequestContext( - id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) + id: requestID, + requestType: requestType, + url: try? composedURL(with: path), + headers: headerFields + ) emit(.started(context)) let result: Result @@ -75,7 +125,11 @@ extension Networking { do { if let fakeRequests = fakeRequests[requestType], let fakeRequest = fakeRequests[path] { let (fakeResponse, _) = try handleFakeRequest( - fakeRequest, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + fakeRequest, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) if fakeRequest.delay > 0 { try? await Task.sleep(nanoseconds: UInt64(fakeRequest.delay * 1_000_000_000)) } @@ -86,22 +140,39 @@ extension Networking { byteCount = fakeData.count result = .success( T.makeDownloadResult( - data: fakeData, statusCode: fakeResponse.statusCode, - headers: headerFields(from: fakeResponse))) + data: fakeData, + statusCode: fakeResponse.statusCode, + headers: headerFields(from: fakeResponse) + )) } else { result = .failure(.invalidResponse) } } else if let cached = try objectFromCache( - for: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: responseType) as? Data - { + for: path, + cacheName: cacheName, + cachingLevel: cachingLevel, + responseType: responseType + ) as? Data { let response = HTTPURLResponse(url: try composedURL(with: path), statusCode: 200) statusCode = 200 result = .success( - T.makeDownloadResult(data: cached, statusCode: 200, headers: headerFields(from: response))) + T.makeDownloadResult( + data: cached, + statusCode: 200, + headers: headerFields(from: response) + )) } else { let (downloaded, networkResponse) = try await requestData( - requestType, path: path, responseType: responseType) - try cacheOrPurgeData(data: downloaded, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + requestType, + path: path, + responseType: responseType + ) + try cacheOrPurgeData( + data: downloaded, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) statusCode = networkResponse.statusCode if StatusCodeType(statusCode: networkResponse.statusCode) != .successful { result = .failure(downloadError(forStatusCode: networkResponse.statusCode)) @@ -109,27 +180,41 @@ extension Networking { byteCount = downloaded.count result = .success( T.makeDownloadResult( - data: downloaded, statusCode: networkResponse.statusCode, - headers: headerFields(from: networkResponse))) + data: downloaded, + statusCode: networkResponse.statusCode, + headers: headerFields(from: networkResponse) + )) } } } catch { result = .failure(downloadError(error)) } return complete( - result, context: context, statusCode: statusCode, byteCount: byteCount, metrics: nil, - duration: clock.now - startInstant) + result, + context: context, + statusCode: statusCode, + byteCount: byteCount, + metrics: nil, + duration: clock.now - startInstant + ) } func handleImageRequest( - _ requestType: RequestType, path: String, cacheName: String?, cachingLevel: CachingLevel, + _ requestType: RequestType, + path: String, + cacheName: String?, + cachingLevel: CachingLevel, responseType: ResponseType ) async -> Result { let requestID = UUID() let clock = ContinuousClock() let startInstant = clock.now let context = RequestContext( - id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) + id: requestID, + requestType: requestType, + url: try? composedURL(with: path), + headers: headerFields + ) emit(.started(context)) let result: Result @@ -138,7 +223,11 @@ extension Networking { do { if let fakeRequests = fakeRequests[requestType], let fakeRequest = fakeRequests[path] { let (fakeResponse, _) = try handleFakeRequest( - fakeRequest, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + fakeRequest, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) if fakeRequest.delay > 0 { try? await Task.sleep(nanoseconds: UInt64(fakeRequest.delay * 1_000_000_000)) } @@ -148,31 +237,49 @@ extension Networking { } else if case .image(let fakeImage) = fakeRequest.payload { result = .success( T.makeDownloadResult( - image: fakeImage, statusCode: fakeResponse.statusCode, - headers: headerFields(from: fakeResponse))) + image: fakeImage, + statusCode: fakeResponse.statusCode, + headers: headerFields(from: fakeResponse) + )) } else { result = .failure(.invalidResponse) } } else if let cached = try objectFromCache( - for: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: responseType) as? Image - { + for: path, + cacheName: cacheName, + cachingLevel: cachingLevel, + responseType: responseType + ) as? Image { let response = HTTPURLResponse(url: try composedURL(with: path), statusCode: 200) statusCode = 200 result = .success( - T.makeDownloadResult(image: cached, statusCode: 200, headers: headerFields(from: response))) + T.makeDownloadResult( + image: cached, + statusCode: 200, + headers: headerFields(from: response) + )) } else { - let (data, networkResponse) = try await requestData(requestType, path: path, responseType: responseType) + let (data, networkResponse) = try await requestData( + requestType, + path: path, + responseType: responseType + ) statusCode = networkResponse.statusCode if StatusCodeType(statusCode: networkResponse.statusCode) != .successful { result = .failure(downloadError(forStatusCode: networkResponse.statusCode)) } else if let downloaded = try cacheOrPurgeImage( - data: data, path: path, cacheName: cacheName, cachingLevel: cachingLevel) - { + data: data, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) { byteCount = data.count result = .success( T.makeDownloadResult( - image: downloaded, statusCode: networkResponse.statusCode, - headers: headerFields(from: networkResponse))) + image: downloaded, + statusCode: networkResponse.statusCode, + headers: headerFields(from: networkResponse) + )) } else { result = .failure(.invalidResponse) } @@ -181,8 +288,13 @@ extension Networking { result = .failure(downloadError(error)) } return complete( - result, context: context, statusCode: statusCode, byteCount: byteCount, metrics: nil, - duration: clock.now - startInstant) + result, + context: context, + statusCode: statusCode, + byteCount: byteCount, + metrics: nil, + duration: clock.now - startInstant + ) } private func headerFields(from response: HTTPURLResponse) -> [String: AnyCodable] { @@ -194,7 +306,11 @@ extension Networking { private func downloadError(forStatusCode statusCode: Int) -> NetworkingError { // Downloads don't retain the response body/headers at this point, so the metadata is status-only. - let metadata = ResponseMetadata(statusCode: statusCode, headers: [:], body: Data()) + let metadata = ResponseMetadata( + statusCode: statusCode, + headers: [:], + body: Data() + ) return .http(HTTPError(statusCode: statusCode, metadata: metadata)) } @@ -208,13 +324,23 @@ extension Networking { return .transport(URLError(.unknown)) } - func requestData(_ requestType: RequestType, path: String, responseType: ResponseType) async throws -> ( + func requestData( + _ requestType: RequestType, + path: String, + responseType: ResponseType + ) async throws -> ( Data, HTTPURLResponse ) { let request = URLRequest( - url: try composedURL(with: path), requestType: requestType, contentType: nil, responseType: responseType, - authorizationHeaderValue: authorizationHeaderValue, token: token, - authorizationHeaderKey: authorizationHeaderKey, headerFields: headerFields) + url: try composedURL(with: path), + requestType: requestType, + contentType: nil, + responseType: responseType, + authorizationHeaderValue: authorizationHeaderValue, + token: token, + authorizationHeaderKey: authorizationHeaderKey, + headerFields: headerFields + ) // Route through the interceptor chain so retry/auth-refresh apply to downloads too. Caching is the // caller's job (handleDataRequest/handleImageRequest write under the real cacheName) — writing here @@ -223,7 +349,13 @@ extension Networking { return (exchange.data, exchange.response) } - func cancelRequest(_ sessionTaskType: SessionTaskType, requestType: RequestType, url: URL) async { + // The cancel path is Networking's own, reached from the verbs in another file. + // oida:disable:next no_single_use_void_functions + func cancelRequest( + _ sessionTaskType: SessionTaskType, + requestType: RequestType, + url: URL + ) async { let (dataTasks, uploadTasks, downloadTasks) = await session.tasks var sessionTasks = [URLSessionTask]() switch sessionTaskType { @@ -245,16 +377,32 @@ extension Networking { } } - nonisolated func cacheOrPurgeData(data: Data?, path: String, cacheName: String?, cachingLevel: CachingLevel) throws - { - try cacheStore.storeData(data, forResource: cacheResource(for: path, cacheName: cacheName), level: cachingLevel) + nonisolated func cacheOrPurgeData( + data: Data?, + path: String, + cacheName: String?, + cachingLevel: CachingLevel + ) throws { + try cacheStore.storeData( + data, + forResource: cacheResource(for: path, cacheName: cacheName), + level: cachingLevel + ) } @discardableResult - nonisolated func cacheOrPurgeImage(data: Data?, path: String, cacheName: String?, cachingLevel: CachingLevel) throws + nonisolated func cacheOrPurgeImage( + data: Data?, + path: String, + cacheName: String?, + cachingLevel: CachingLevel + ) throws -> Image? { try cacheStore.storeImage( - data: data, forResource: cacheResource(for: path, cacheName: cacheName), level: cachingLevel) + data: data, + forResource: cacheResource(for: path, cacheName: cacheName), + level: cachingLevel + ) } } diff --git a/Sources/Networking/Networking.swift b/Sources/Networking/Networking.swift index 3b6abb8..c2646cb 100644 --- a/Sources/Networking/Networking.swift +++ b/Sources/Networking/Networking.swift @@ -83,6 +83,9 @@ public actor Networking { return stream } + // The hop that makes an actor-isolated write legal from a nonisolated termination handler. + // Inlining it fails to compile: 'actor-isolated property ... can not be mutated'. + // oida:disable:next no_single_use_void_functions private func removeContinuation(_ id: UUID) { streamContinuations[id] = nil } @@ -130,10 +133,7 @@ public actor Networking { func record(_ message: String, level: OSLogType) { guard logLevel != .none else { return } logger.log(level: level, "\(message, privacy: .public)") - appendToLogFile(message) - } - private func appendToLogFile(_ message: String) { guard let logFileURL else { return } let entry = "\(Date().ISO8601Format()) \(message)\n" guard let data = entry.data(using: .utf8) else { return } @@ -162,7 +162,11 @@ public actor Networking { } } - nonisolated let boundary = String(format: "com.elvisnunez.networking.%08x%08x", arc4random(), arc4random()) + nonisolated let boundary = String( + format: "com.elvisnunez.networking.%08x%08x", + UInt32.random(in: .min ... .max), + UInt32.random(in: .min ... .max) + ) lazy var session: URLSession = { URLSession(configuration: self.configuration) @@ -188,15 +192,21 @@ public actor Networking { } public init( - baseURL: String = "", configuration: URLSessionConfiguration = .default, - cache: NSCache? = nil, logger: Logger? = nil, + baseURL: String = "", + configuration: URLSessionConfiguration = .default, + cache: NSCache? = nil, + logger: Logger? = nil, cacheTTL: Duration = .seconds(7 * 24 * 60 * 60) ) { self.baseURL = baseURL self.configuration = configuration let memoryCache = cache ?? NSCache() self.cache = memoryCache - self.cacheStore = CacheStore(memory: memoryCache, ttl: cacheTTL, folderName: Networking.domain) + self.cacheStore = CacheStore( + memory: memoryCache, + ttl: cacheTTL, + folderName: Networking.domain + ) self.logger = logger ?? Networking.defaultLogger self.logFileURL = ProcessInfo.processInfo.environment["NETWORKING_LOG_FILE"].flatMap( Networking.resolveLogFileURL) diff --git a/Sources/Networking/NetworkingError.swift b/Sources/Networking/NetworkingError.swift index ddf1db7..119b127 100644 --- a/Sources/Networking/NetworkingError.swift +++ b/Sources/Networking/NetworkingError.swift @@ -99,7 +99,11 @@ public struct ResponseMetadata: Sendable { public let headers: [String: String] public let body: Data - public init(statusCode: Int, headers: [String: String], body: Data) { + public init( + statusCode: Int, + headers: [String: String], + body: Data + ) { self.statusCode = statusCode self.headers = headers self.body = body @@ -110,7 +114,11 @@ public struct ResponseMetadata: Sendable { uniqueKeysWithValues: response.allHeaderFields.compactMap { key, value in (key as? String).map { ($0, "\(value)") } }) - self.init(statusCode: response.statusCode, headers: headers, body: body) + self.init( + statusCode: response.statusCode, + headers: headers, + body: body + ) } public func decode(_ type: T.Type, using decoder: JSONDecoder = JSONDecoder()) throws -> T { diff --git a/Sources/Networking/NetworkingEvent.swift b/Sources/Networking/NetworkingEvent.swift index e53f619..776fc77 100644 --- a/Sources/Networking/NetworkingEvent.swift +++ b/Sources/Networking/NetworkingEvent.swift @@ -25,8 +25,18 @@ extension RequestContext { // Owns the two normalizations every call site repeated: the HTTP method string and a nil header // set. The URL stays a caller concern — it's resolved differently per site (best-effort // `composedURL` from a path, a built request's URL, or nil on a pre-flight failure). - init(id: UUID, requestType: Networking.RequestType, url: URL?, headers: [String: String]?) { - self.init(id: id, method: requestType.rawValue, url: url, headers: headers ?? [:]) + init( + id: UUID, + requestType: Networking.RequestType, + url: URL?, + headers: [String: String]? + ) { + self.init( + id: id, + method: requestType.rawValue, + url: url, + headers: headers ?? [:] + ) } } @@ -89,7 +99,11 @@ final class MetricsCollector: NSObject, URLSessionTaskDelegate, @unchecked Senda return collected } - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) { lock.lock() defer { lock.unlock() } collected = metrics diff --git a/Tests/NetworkingTests/AuthRefreshInterceptorIntegrationTests.swift b/Tests/NetworkingTests/AuthRefreshInterceptorIntegrationTests.swift index 1d630ec..390d7c2 100644 --- a/Tests/NetworkingTests/AuthRefreshInterceptorIntegrationTests.swift +++ b/Tests/NetworkingTests/AuthRefreshInterceptorIntegrationTests.swift @@ -20,7 +20,11 @@ final class AuthRefreshInterceptorIntegrationTests: XCTestCase { switch result { case .success(let response): - XCTAssertEqual(response.statusCode, 200, "the replay with a fresh credential should succeed") + XCTAssertEqual( + response.statusCode, + 200, + "the replay with a fresh credential should succeed" + ) case .failure(let error): XCTFail("expected the refreshed replay to succeed, got \(error)") } diff --git a/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift b/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift index 8d4149c..1a196a0 100644 --- a/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift +++ b/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift @@ -11,6 +11,7 @@ final class AuthRefreshSingleFlightIntegrationTests: XCTestCase { private actor RefreshCounter { private(set) var count = 0 + func increment() { count += 1 } } @@ -34,6 +35,10 @@ final class AuthRefreshSingleFlightIntegrationTests: XCTestCase { } let count = await counter.count - XCTAssertEqual(count, 1, "concurrent 401s should share a single refresh, but it ran \(count) times") + XCTAssertEqual( + count, + 1, + "concurrent 401s should share a single refresh, but it ran \(count) times" + ) } } diff --git a/Tests/NetworkingTests/CacheStoreTests.swift b/Tests/NetworkingTests/CacheStoreTests.swift index a021ca4..576049b 100644 --- a/Tests/NetworkingTests/CacheStoreTests.swift +++ b/Tests/NetworkingTests/CacheStoreTests.swift @@ -12,13 +12,21 @@ final class CacheStoreTests: XCTestCase { store: CacheStore, memory: NSCache ) { let memory = NSCache() - let store = CacheStore(memory: memory, ttl: ttl, folderName: folderName) + let store = CacheStore( + memory: memory, + ttl: ttl, + folderName: folderName + ) try store.clear() return (store, memory) } override func tearDown() { - try? CacheStore(memory: NSCache(), ttl: .seconds(1), folderName: folderName).clear() + try? CacheStore( + memory: NSCache(), + ttl: .seconds(1), + folderName: folderName + ).clear() super.tearDown() } @@ -29,8 +37,17 @@ final class CacheStoreTests: XCTestCase { let resource = "http://example.com/" + String(repeating: "a", count: 300) let payload = Data("payload".utf8) - try store.storeData(payload, forResource: resource, level: .memoryAndFile) - let cached = try store.object(forResource: resource, level: .memoryAndFile, asImage: false) as? Data + try store.storeData( + payload, + forResource: resource, + level: .memoryAndFile + ) + let cached = + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + ) as? Data XCTAssertEqual(cached, payload) } @@ -38,7 +55,11 @@ final class CacheStoreTests: XCTestCase { func testColdDiskEntryExpiresOnRead() throws { let (store, memory) = try makeStore(ttl: .seconds(60)) let resource = "http://example.com/cold-entry" - try store.storeData(Data("stale".utf8), forResource: resource, level: .memoryAndFile) + try store.storeData( + Data("stale".utf8), + forResource: resource, + level: .memoryAndFile + ) let url = try store.destinationURL(forResource: resource) memory.removeObject(forKey: url.absoluteString as AnyObject) // force the disk path @@ -46,7 +67,11 @@ final class CacheStoreTests: XCTestCase { [.modificationDate: Date(timeIntervalSinceNow: -120)], ofItemAtPath: url.path) XCTAssertNil( - try store.object(forResource: resource, level: .memoryAndFile, asImage: false), + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + ), "an entry idle beyond the TTL is expired on read" ) } @@ -55,16 +80,29 @@ final class CacheStoreTests: XCTestCase { func testDiskHitReWarmsFileDate() throws { let (store, memory) = try makeStore(ttl: .seconds(60)) let resource = "http://example.com/warm-entry" - try store.storeData(Data("fresh".utf8), forResource: resource, level: .memoryAndFile) + try store.storeData( + Data("fresh".utf8), + forResource: resource, + level: .memoryAndFile + ) let url = try store.destinationURL(forResource: resource) memory.removeObject(forKey: url.absoluteString as AnyObject) // force the disk path try FileManager.default.setAttributes( [.modificationDate: Date(timeIntervalSinceNow: -30)], ofItemAtPath: url.path) - XCTAssertNotNil(try store.object(forResource: resource, level: .memoryAndFile, asImage: false)) - let mtime = try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate! - XCTAssertLessThan(Date().timeIntervalSince(mtime), 5, "the disk hit should have re-warmed the mtime to ~now") + XCTAssertNotNil( + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + )) + let mtime = try XCTUnwrap(url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + XCTAssertLessThan( + Date().timeIntervalSince(mtime), + 5, + "the disk hit should have re-warmed the mtime to ~now" + ) } // (No memory-hit-rewarm test: NSCache can evict at any time, so a memory hit can't be forced @@ -75,15 +113,27 @@ final class CacheStoreTests: XCTestCase { func testMemoryReadNeverDeletesTheDiskTier() throws { let (store, memory) = try makeStore() let resource = "http://example.com/durable-memory" - try store.storeData(Data("durable".utf8), forResource: resource, level: .memoryAndFile) + try store.storeData( + Data("durable".utf8), + forResource: resource, + level: .memoryAndFile + ) let url = try store.destinationURL(forResource: resource) memory.removeAllObjects() // simulate iOS evicting the warm tier - _ = try store.object(forResource: resource, level: .memory, asImage: false) + _ = try store.object( + forResource: resource, + level: .memory, + asImage: false + ) XCTAssertTrue(FileManager.default.exists(at: url), "a .memory read deleted the durable disk copy") XCTAssertEqual( - try store.object(forResource: resource, level: .memoryAndFile, asImage: false) as? Data, + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + ) as? Data, Data("durable".utf8), "the disk copy should still be readable after the .memory read" ) @@ -93,10 +143,19 @@ final class CacheStoreTests: XCTestCase { func testNoneReadNeverDeletesTheDiskTier() throws { let (store, _) = try makeStore() let resource = "http://example.com/durable-none" - try store.storeData(Data("durable".utf8), forResource: resource, level: .memoryAndFile) + try store.storeData( + Data("durable".utf8), + forResource: resource, + level: .memoryAndFile + ) let url = try store.destinationURL(forResource: resource) - XCTAssertNil(try store.object(forResource: resource, level: .none, asImage: false)) + XCTAssertNil( + try store.object( + forResource: resource, + level: .none, + asImage: false + )) XCTAssertTrue(FileManager.default.exists(at: url), "a .none read deleted the durable disk copy") } @@ -105,11 +164,25 @@ final class CacheStoreTests: XCTestCase { func testClearEmptiesBothTiers() throws { let (store, _) = try makeStore() let resource = "http://example.com/cached" - try store.storeData(Data("hi".utf8), forResource: resource, level: .memoryAndFile) - XCTAssertNotNil(try store.object(forResource: resource, level: .memoryAndFile, asImage: false)) + try store.storeData( + Data("hi".utf8), + forResource: resource, + level: .memoryAndFile + ) + XCTAssertNotNil( + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + )) try store.clear() - XCTAssertNil(try store.object(forResource: resource, level: .memoryAndFile, asImage: false)) + XCTAssertNil( + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + )) } } diff --git a/Tests/NetworkingTests/CacheWriteAndDataBodyRegressionTests.swift b/Tests/NetworkingTests/CacheWriteAndDataBodyRegressionTests.swift index 07b8ba0..e9baf14 100644 --- a/Tests/NetworkingTests/CacheWriteAndDataBodyRegressionTests.swift +++ b/Tests/NetworkingTests/CacheWriteAndDataBodyRegressionTests.swift @@ -14,8 +14,16 @@ final class CacheWriteAndDataBodyRegressionTests: XCTestCase { let cacheName = "orphan-regression-key" try await networking.clearCache() - try Helper.removeFileIfNeeded(networking, path: path, cacheName: nil) - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: nil + ) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let result: Result = await networking.downloadData(path, cacheName: cacheName) guard case .success = result else { return XCTFail("expected a successful download, got \(result)") } @@ -23,8 +31,7 @@ final class CacheWriteAndDataBodyRegressionTests: XCTestCase { let orphanURL = try networking.destinationURL(for: path, cacheName: nil) XCTAssertFalse( FileManager.default.exists(at: orphanURL), - "downloadData wrote a stray cache file under the path key that no read path uses" - ) + "downloadData wrote a stray cache file under the path key that no read path uses") } // The `.memory`/`.none` pure-read contracts (a read must not destroy a durable `.memoryAndFile` copy) @@ -38,6 +45,10 @@ final class CacheWriteAndDataBodyRegressionTests: XCTestCase { guard case .success(let data) = result else { return XCTFail("expected success, got \(result)") } let decoded = try? JSONDecoder().decode([String: String].self, from: data) - XCTAssertEqual(decoded?["message"], "hello", "a verb with T == Data must return the response body") + XCTAssertEqual( + decoded?["message"], + "hello", + "a verb with T == Data must return the response body" + ) } } diff --git a/Tests/NetworkingTests/CancellationIntegrationTests.swift b/Tests/NetworkingTests/CancellationIntegrationTests.swift index bf8f954..1cce804 100644 --- a/Tests/NetworkingTests/CancellationIntegrationTests.swift +++ b/Tests/NetworkingTests/CancellationIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class CancellationIntegrationTests: XCTestCase { +final class CancellationIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL // Cancelling the Task running a request surfaces `.cancelled`. go-httpbin's `/delay/{n}` keeps the diff --git a/Tests/NetworkingTests/DELETEIntegrationTests.swift b/Tests/NetworkingTests/DELETEIntegrationTests.swift index 7e3dccc..742210f 100644 --- a/Tests/NetworkingTests/DELETEIntegrationTests.swift +++ b/Tests/NetworkingTests/DELETEIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class DELETEIntegrationTests: XCTestCase { +final class DELETEIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testDELETE() async throws { @@ -35,7 +35,7 @@ class DELETEIntegrationTests: XCTestCase { let result: Result = await networking.delete("/invalidpath") switch result { case .success: - XCTFail() + XCTFail("a DELETE to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") diff --git a/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift b/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift index e10435d..4fd9789 100644 --- a/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift +++ b/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class Dictionary_FormURLEncodedTests: XCTestCase { +final class DictionaryFormURLEncodedTests: XCTestCase { func testFormatting() throws { let parameters = ["username": "elvis", "password": "secret"] diff --git a/Tests/NetworkingTests/DownloadEventsIntegrationTests.swift b/Tests/NetworkingTests/DownloadEventsIntegrationTests.swift index fca3f52..0611ed4 100644 --- a/Tests/NetworkingTests/DownloadEventsIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadEventsIntegrationTests.swift @@ -16,7 +16,11 @@ final class DownloadEventsIntegrationTests: XCTestCase { let _: Result = await networking.downloadImage("/image/png") let events = await stream.collect(2) - XCTAssertEqual(events.count, 2, "downloadImage should emit .started and .completed; got \(events)") + XCTAssertEqual( + events.count, + 2, + "downloadImage should emit .started and .completed; got \(events)" + ) guard case .started(let startContext) = events.first, case .completed(let endContext, let outcome, _, _) = events.last else { @@ -31,7 +35,11 @@ final class DownloadEventsIntegrationTests: XCTestCase { func testDownloadImageEmitsFailureForBadStatus() async { let networking = Networking(baseURL: baseURL) let pigImage = Image.find(named: "pig.png", inBundle: .module) - await networking.fakeImageDownload("/image/png", image: pigImage, statusCode: 404) + await networking.fakeImageDownload( + "/image/png", + image: pigImage, + statusCode: 404 + ) let stream = await networking.events() let _: Result = await networking.downloadImage("/image/png") diff --git a/Tests/NetworkingTests/DownloadIntegrationTests.swift b/Tests/NetworkingTests/DownloadIntegrationTests.swift index 4383d0e..c73a584 100644 --- a/Tests/NetworkingTests/DownloadIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadIntegrationTests.swift @@ -6,7 +6,7 @@ import XCTest // @MainActor so the local `NSCache` (non-Sendable) used across the `downloadImage` await stays on // one actor and doesn't trip strict-concurrency "sending" checks. @MainActor -class DownloadIntegrationTests: XCTestCase { +final class DownloadIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testImageDownload() async throws { @@ -61,7 +61,11 @@ class DownloadIntegrationTests: XCTestCase { let path = "/image/png" let cacheName = "png/png" - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let _: Result = await networking.downloadImage(path, cacheName: cacheName) let destinationURL = try networking.destinationURL(for: path, cacheName: cacheName) @@ -82,7 +86,7 @@ class DownloadIntegrationTests: XCTestCase { let destinationURL = try networking.destinationURL(for: path) let absoluteString = destinationURL.absoluteString guard let image = networking.cache.object(forKey: absoluteString as AnyObject) as? Image else { - XCTFail() + XCTFail("the downloaded image should be in the memory cache") return } let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -97,7 +101,11 @@ class DownloadIntegrationTests: XCTestCase { let path = "/image/png" let cacheName = "png/png" - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let result: Result = await networking.downloadImage(path, cacheName: cacheName) switch result { @@ -105,7 +113,7 @@ class DownloadIntegrationTests: XCTestCase { let destinationURL = try networking.destinationURL(for: path, cacheName: cacheName) let absoluteString = destinationURL.absoluteString guard let image = networking.cache.object(forKey: absoluteString as AnyObject) as? Image else { - XCTFail() + XCTFail("the downloaded image should be in the memory cache under its custom name") return } let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -134,7 +142,11 @@ class DownloadIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL) let path = "/image/png" let cacheName = "hello" - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let _: Result = await networking.downloadImage(path, cacheName: cacheName) let image = try networking.imageFromCache(path, cacheName: cacheName) let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -152,7 +164,7 @@ class DownloadIntegrationTests: XCTestCase { let absoluteString = destinationURL.absoluteString networking.cache.removeObject(forKey: absoluteString as AnyObject) guard let image = try networking.imageFromCache(path) else { - XCTFail() + XCTFail("the image should come back from the file cache") return } let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -166,7 +178,11 @@ class DownloadIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL) let path = "/image/png" let cacheName = "hello" - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let result: Result = await networking.downloadImage(path, cacheName: cacheName) switch result { case .success: @@ -174,7 +190,7 @@ class DownloadIntegrationTests: XCTestCase { let absoluteString = destinationURL.absoluteString networking.cache.removeObject(forKey: absoluteString as AnyObject) guard let image = try networking.imageFromCache(path, cacheName: cacheName) else { - XCTFail() + XCTFail("the image should come back from the file cache under its custom name") return } let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -241,7 +257,7 @@ class DownloadIntegrationTests: XCTestCase { if let cacheData = try networking.dataFromCache(path) { XCTAssert(data == cacheData) } else { - XCTFail() + XCTFail("the data should come back from the cache") } case .failure(let error): XCTFail(error.localizedDescription) diff --git a/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift b/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift index 12d4867..347ce4e 100644 --- a/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift @@ -10,12 +10,17 @@ final class DownloadInterceptorIntegrationTests: XCTestCase { private actor Counter { private(set) var count = 0 + func tick() { count += 1 } } private struct CountingPassthroughInterceptor: HTTPInterceptor { let counter: Counter - func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + + func intercept( + _ request: URLRequest, + next: @Sendable (URLRequest) async throws -> HTTPExchange + ) async throws -> HTTPExchange { await counter.tick() @@ -32,6 +37,10 @@ final class DownloadInterceptorIntegrationTests: XCTestCase { if case .failure(let error) = result { XCTFail("expected the download to succeed, got \(error)") } let count = await counter.count - XCTAssertGreaterThanOrEqual(count, 1, "a download must pass through the interceptor chain") + XCTAssertGreaterThanOrEqual( + count, + 1, + "a download must pass through the interceptor chain" + ) } } diff --git a/Tests/NetworkingTests/EncodableFormQueryIntegrationTests.swift b/Tests/NetworkingTests/EncodableFormQueryIntegrationTests.swift index 6985dea..725018a 100644 --- a/Tests/NetworkingTests/EncodableFormQueryIntegrationTests.swift +++ b/Tests/NetworkingTests/EncodableFormQueryIntegrationTests.swift @@ -15,7 +15,12 @@ final class EncodableFormQueryIntegrationTests: XCTestCase { func testPOSTFormFromEncodableModel() async throws { let networking = Networking(baseURL: baseURL) let result: Result = await networking.post( - "/post", form: SearchParams(term: "swift & co", page: 2, exact: true)) + "/post", + form: SearchParams( + term: "swift & co", + page: 2, + exact: true + )) switch result { case .success(let response): let form = httpbinEchoedMap(response, "form") @@ -33,7 +38,12 @@ final class EncodableFormQueryIntegrationTests: XCTestCase { func testGETQueryFromEncodableModel() async throws { let networking = Networking(baseURL: baseURL) let result: Result = await networking.get( - "/get", query: SearchParams(term: "swift", page: 2, exact: false)) + "/get", + query: SearchParams( + term: "swift", + page: 2, + exact: false + )) switch result { case .success(let response): let args = httpbinEchoedMap(response, "args") @@ -48,7 +58,12 @@ final class EncodableFormQueryIntegrationTests: XCTestCase { func testDELETEQueryFromEncodableModel() async throws { let networking = Networking(baseURL: baseURL) let result: Result = await networking.delete( - "/delete", query: SearchParams(term: "swift", page: 5, exact: true)) + "/delete", + query: SearchParams( + term: "swift", + page: 5, + exact: true + )) switch result { case .success(let response): let args = httpbinEchoedMap(response, "args") diff --git a/Tests/NetworkingTests/FakeRequestTests.swift b/Tests/NetworkingTests/FakeRequestTests.swift index a83c6bc..83a5087 100644 --- a/Tests/NetworkingTests/FakeRequestTests.swift +++ b/Tests/NetworkingTests/FakeRequestTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class FakeRequestTests: XCTestCase { +final class FakeRequestTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL // Decodes a fake request's JSON payload back into a dictionary so `find`'s template @@ -42,11 +42,27 @@ class FakeRequestTests: XCTestCase { } func testFind() throws { - let request = FakeRequest(payload: .none, responseType: .json, headerFields: nil, statusCode: 200, delay: 0) + let request = FakeRequest( + payload: .none, + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [Networking.RequestType.get: ["/companies": request]] - XCTAssertNil(try FakeRequest.find(ofType: .get, forPath: "/users", in: existingRequests)) - XCTAssertNil(try FakeRequest.find(ofType: .get, forPath: "/users", in: [:])) + XCTAssertNil( + try FakeRequest.find( + ofType: .get, + forPath: "/users", + in: existingRequests + )) + XCTAssertNil( + try FakeRequest.find( + ofType: .get, + forPath: "/users", + in: [:] + )) } func testOneLevelFind() throws { @@ -54,10 +70,18 @@ class FakeRequestTests: XCTestCase { "name": "Name {userID}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [Networking.RequestType.get: ["/users/{userID}": request]] - let result = try FakeRequest.find(ofType: .get, forPath: "/users/10", in: existingRequests) + let result = try FakeRequest.find( + ofType: .get, + forPath: "/users/10", + in: existingRequests + ) let expected = [ "name": "Name 10" @@ -71,8 +95,12 @@ class FakeRequestTests: XCTestCase { "name": "Name {userID}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [ Networking.RequestType.get: [ "/users/ados": request, @@ -80,10 +108,14 @@ class FakeRequestTests: XCTestCase { "/users/cedos": request, "/users/tedos": request, "/users/melos": request, - "/users/{userID}": request, + "/users/{userID}": request ] ] - let result = try FakeRequest.find(ofType: .get, forPath: "/users/10", in: existingRequests) + let result = try FakeRequest.find( + ofType: .get, + forPath: "/users/10", + in: existingRequests + ) let expected = [ "name": "Name 10" @@ -95,17 +127,25 @@ class FakeRequestTests: XCTestCase { func testTwoLevelFind() throws { let json = [ "user": "User {userID}", - "company": "Company {companyID}", + "company": "Company {companyID}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [Networking.RequestType.get: ["/users/{userID}/companies/{companyID}": request]] - let result = try FakeRequest.find(ofType: .get, forPath: "/users/10/companies/20", in: existingRequests) + let result = try FakeRequest.find( + ofType: .get, + forPath: "/users/10/companies/20", + in: existingRequests + ) let expected = [ "user": "User 10", - "company": "Company 20", + "company": "Company 20" ] XCTAssertEqual(decodedDictionary(result), expected as NSDictionary) @@ -115,11 +155,15 @@ class FakeRequestTests: XCTestCase { let json = [ "user": "User {userID}", "company": "Company {companyID}", - "product": "Product {productID}", + "product": "Product {productID}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [ Networking.RequestType.get: [ "/users/{userID}/companies/{companyID}/products/a": request, @@ -129,16 +173,19 @@ class FakeRequestTests: XCTestCase { "/users/{userID}/companies/{companyID}/products/{productID}": request, "/users/{userID}/companies/{companyID}/products/e": request, "/users/{userID}/companies/{companyID}/products/f": request, - "/users/{userID}/companies/{companyID}/products/g": request, + "/users/{userID}/companies/{companyID}/products/g": request ] ] let result = try FakeRequest.find( - ofType: .get, forPath: "/users/10/companies/20/products/30", in: existingRequests) + ofType: .get, + forPath: "/users/10/companies/20/products/30", + in: existingRequests + ) let expected = [ "user": "User 10", "company": "Company 20", - "product": "Product 30", + "product": "Product 30" ] XCTAssertEqual(decodedDictionary(result), expected as NSDictionary) @@ -155,12 +202,16 @@ class FakeRequestTests: XCTestCase { "resource7": "Resource {resourceID7}", "resource8": "Resource {resourceID8}", "resource9": "Resource {resourceID9}", - "resource10": "Resource {resourceID10}", + "resource10": "Resource {resourceID10}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [ Networking.RequestType.get: [ "resource1/{resourceID1}/resource2/{resourceID2}/resource3/{resourceID3}/resource4/{resourceID4}/resource5/{resourceID5}/resource6/{resourceID6}/resource7/{resourceID7}/resource8/{resourceID8}/resource9/{resourceID9}/resource10/{resourceID10}": @@ -182,7 +233,7 @@ class FakeRequestTests: XCTestCase { "resource7": "Resource 7", "resource8": "Resource 8", "resource9": "Resource 9", - "resource10": "Resource 10", + "resource10": "Resource 10" ] XCTAssertEqual(decodedDictionary(result), expected as NSDictionary) } @@ -209,12 +260,20 @@ extension FakeRequestTests { let delay: Double = 2.0 let startTime1 = Date() - await networking.fakeGET("/stories", response: ["name": "Elvis"], delay: delay) + await networking.fakeGET( + "/stories", + response: ["name": "Elvis"], + delay: delay + ) let firstResult: Result = await networking.get("/stories") let endTime1 = Date() let elapsedTime1 = endTime1.timeIntervalSince(startTime1) - XCTAssertGreaterThanOrEqual(elapsedTime1, delay, "The delay was not correctly applied") + XCTAssertGreaterThanOrEqual( + elapsedTime1, + delay, + "The delay was not correctly applied" + ) switch firstResult { case .success(let response): @@ -232,7 +291,7 @@ extension FakeRequestTests { let result: Result = await networking.get("/stories") switch result { case .success: - XCTFail() + XCTFail("a faked GET carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -246,12 +305,16 @@ extension FakeRequestTests { func testFakeGETWithInvalidPathAndJSONError() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakeGET("/stories", response: ["error": "Shit went down"], statusCode: 401) + await networking.fakeGET( + "/stories", + response: ["error": "Shit went down"], + statusCode: 401 + ) let result: Result = await networking.get("/stories") switch result { case .success: - XCTFail() + XCTFail("a faked GET on an unregistered path must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -265,7 +328,11 @@ extension FakeRequestTests { func testErrorBodyRetainedInFullNotTruncated() async throws { let networking = Networking(baseURL: baseURL) let longMessage = String(repeating: "x", count: 600) - await networking.fakeGET("/big-error", response: ["error": longMessage], statusCode: 400) + await networking.fakeGET( + "/big-error", + response: ["error": longMessage], + statusCode: 400 + ) let result: Result = await networking.get("/big-error") guard case .failure(.http(let httpError)) = result else { @@ -274,13 +341,20 @@ extension FakeRequestTests { XCTAssertGreaterThan(httpError.metadata.body.count, 512) XCTAssertEqual(try httpError.metadata.decode(ServerError.self).error, longMessage) XCTAssertEqual( - httpError.metadata.bodySnippet?.hasSuffix("… (truncated)"), true, "the snippet stays a log excerpt") + httpError.metadata.bodySnippet?.hasSuffix("… (truncated)"), + true, + "the snippet stays a log excerpt" + ) } func testFakeGETUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakeGET("/entries", fileName: "entries.json", bundle: .module) + await networking.fakeGET( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.get("/entries") switch result { @@ -298,12 +372,36 @@ extension FakeRequestTests { "name": "Name {userID}" ] - await networking.fakeGET("/users/ados", response: json, statusCode: 200) - await networking.fakeGET("/users/bedos", response: json, statusCode: 200) - await networking.fakeGET("/users/cedos", response: json, statusCode: 200) - await networking.fakeGET("/users/tedos", response: json, statusCode: 200) - await networking.fakeGET("/users/melos", response: json, statusCode: 200) - await networking.fakeGET("/users/{userID}", response: json, statusCode: 200) + await networking.fakeGET( + "/users/ados", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/bedos", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/cedos", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/tedos", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/melos", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/{userID}", + response: json, + statusCode: 200 + ) let result: Result = await networking.get("/users/10") switch result { @@ -325,7 +423,11 @@ extension FakeRequestTests { func testFakeGETUsingHeader() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakeGET("/story", response: ["ok": true], headerFields: ["uid": "12345678"]) + await networking.fakeGET( + "/story", + response: ["ok": true], + headerFields: ["uid": "12345678"] + ) let result: Result = await networking.get("/story") switch result { @@ -362,7 +464,7 @@ extension FakeRequestTests { let result: Result = await networking.post("/story") switch result { case .success: - XCTFail() + XCTFail("a faked POST carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -374,7 +476,11 @@ extension FakeRequestTests { func testFakePOSTUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakePOST("/entries", fileName: "entries.json", bundle: .module) + await networking.fakePOST( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.post("/entries") switch result { @@ -448,7 +554,7 @@ extension FakeRequestTests { let result: Result = await networking.put("/story") switch result { case .success: - XCTFail() + XCTFail("a faked PUT carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -460,7 +566,11 @@ extension FakeRequestTests { func testFakePUTUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakePUT("/entries", fileName: "entries.json", bundle: .module) + await networking.fakePUT( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.put("/entries") switch result { @@ -511,7 +621,7 @@ extension FakeRequestTests { let result: Result = await networking.patch("/story") switch result { case .success: - XCTFail() + XCTFail("a faked PATCH carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -523,7 +633,11 @@ extension FakeRequestTests { func testFakePATCHUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakePATCH("/entries", fileName: "entries.json", bundle: .module) + await networking.fakePATCH( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.patch("/entries") switch result { @@ -597,7 +711,7 @@ extension FakeRequestTests { let result: Result = await networking.delete("/story") switch result { case .success: - XCTFail() + XCTFail("a faked DELETE carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -609,7 +723,11 @@ extension FakeRequestTests { func testFakeDELETEUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakeDELETE("/entries", fileName: "entries.json", bundle: .module) + await networking.fakeDELETE( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.delete("/entries") switch result { @@ -656,12 +774,20 @@ extension FakeRequestTests { let delay: Double = 2.0 let startTime = Date() - await networking.fakeImageDownload("/image/png", image: pigImage, delay: delay) + await networking.fakeImageDownload( + "/image/png", + image: pigImage, + delay: delay + ) let result: Result = await networking.downloadImage("/image/png") let endTime = Date() let elapsedTime = endTime.timeIntervalSince(startTime) - XCTAssertGreaterThanOrEqual(elapsedTime, delay, "The delay was not correctly applied") + XCTAssertGreaterThanOrEqual( + elapsedTime, + delay, + "The delay was not correctly applied" + ) switch result { case .success(let image): @@ -674,11 +800,15 @@ extension FakeRequestTests { func testFakeImageDownloadWithInvalidStatusCode() async throws { let networking = Networking(baseURL: baseURL) let pigImage = Image.find(named: "pig.png", inBundle: .module) - await networking.fakeImageDownload("/image/png", image: pigImage, statusCode: 401) + await networking.fakeImageDownload( + "/image/png", + image: pigImage, + statusCode: 401 + ) let result: Result = await networking.downloadImage("/image/png") switch result { case .success: - XCTFail() + XCTFail("a faked image download carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -691,7 +821,11 @@ extension FakeRequestTests { func testFakeImageDownloadUsingHeader() async throws { let networking = Networking(baseURL: baseURL) let pigImage = Image.find(named: "pig.png", inBundle: .module) - await networking.fakeImageDownload("/image/png", image: pigImage, headerFields: ["uid": "12345678"]) + await networking.fakeImageDownload( + "/image/png", + image: pigImage, + headerFields: ["uid": "12345678"] + ) let result: Result = await networking.downloadImage("/image/png") switch result { case .success(let response): @@ -714,12 +848,12 @@ extension FakeRequestTests { "client": "aClient", "access-token": "anAccessToken", "uid": "aUID", - "Authorization": "authorization", + "Authorization": "authorization" ]) let parameters = [ "phone_number": "phoneNumber", - "confirmation_code": "confirmationCode", + "confirmation_code": "confirmationCode" ] let result: Result = await networking.post( diff --git a/Tests/NetworkingTests/GETIntegrationTests.swift b/Tests/NetworkingTests/GETIntegrationTests.swift index 2ded889..bbc518c 100644 --- a/Tests/NetworkingTests/GETIntegrationTests.swift +++ b/Tests/NetworkingTests/GETIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class GETIntegrationTests: XCTestCase { +final class GETIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testGET() async throws { @@ -47,7 +47,7 @@ class GETIntegrationTests: XCTestCase { let result: Result = await networking.get("/invalidpath") switch result { case .success: - XCTFail() + XCTFail("a GET to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -166,9 +166,15 @@ class GETIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL, cache: cache) let first: Result = await networking.get( - "/get", query: [URLQueryItem(name: "value", value: "1")], cachingLevel: .memory) + "/get", + query: [URLQueryItem(name: "value", value: "1")], + cachingLevel: .memory + ) let second: Result = await networking.get( - "/get", query: [URLQueryItem(name: "value", value: "2")], cachingLevel: .memory) + "/get", + query: [URLQueryItem(name: "value", value: "2")], + cachingLevel: .memory + ) guard case .success(let firstResponse) = first, case .success(let secondResponse) = second else { return XCTFail("expected both requests to succeed") @@ -185,10 +191,15 @@ class GETIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL, cache: cache) let _: Result = await networking.get( - "/get", query: [URLQueryItem(name: "a", value: "1&b=2")], cachingLevel: .memory) + "/get", + query: [URLQueryItem(name: "a", value: "1&b=2")], + cachingLevel: .memory + ) let second: Result = await networking.get( - "/get", query: [URLQueryItem(name: "a", value: "1"), URLQueryItem(name: "b", value: "2")], - cachingLevel: .memory) + "/get", + query: [URLQueryItem(name: "a", value: "1"), URLQueryItem(name: "b", value: "2")], + cachingLevel: .memory + ) guard case .success(let secondResponse) = second else { return XCTFail("expected the second request to succeed") diff --git a/Tests/NetworkingTests/GETTests.swift b/Tests/NetworkingTests/GETTests.swift index e77770e..cc26629 100644 --- a/Tests/NetworkingTests/GETTests.swift +++ b/Tests/NetworkingTests/GETTests.swift @@ -8,12 +8,16 @@ struct Friend: Decodable { let title: String } -class GETTests: XCTestCase { +final class GETTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testGETCachedFromMemory() async throws { let cache = NSCache() - let networking = Networking(baseURL: baseURL, configuration: .default, cache: cache) + let networking = Networking( + baseURL: baseURL, + configuration: .default, + cache: cache + ) await networking.fakeGET("/get", response: ["key": "value1"]) let firstResult: Result = await networking.get("/get", cachingLevel: .memory) switch firstResult { @@ -36,7 +40,11 @@ class GETTests: XCTestCase { func testGETCachedFromFile() async throws { let cache = NSCache() - let networking = Networking(baseURL: baseURL, configuration: .default, cache: cache) + let networking = Networking( + baseURL: baseURL, + configuration: .default, + cache: cache + ) await networking.fakeGET("/get", response: ["key": "value1"]) let firstResult: Result = await networking.get( "/get", cachingLevel: .memoryAndFile) @@ -60,7 +68,11 @@ class GETTests: XCTestCase { func testGETCachedNone() async throws { let cache = NSCache() - let networking = Networking(baseURL: baseURL, configuration: .default, cache: cache) + let networking = Networking( + baseURL: baseURL, + configuration: .default, + cache: cache + ) await networking.fakeGET("/get", response: ["key": "value1"]) let firstResult: Result = await networking.get("/get", cachingLevel: .none) switch firstResult { diff --git a/Tests/NetworkingTests/NetworkingIntegrationTests.swift b/Tests/NetworkingTests/NetworkingIntegrationTests.swift index be80246..bc518a3 100644 --- a/Tests/NetworkingTests/NetworkingIntegrationTests.swift +++ b/Tests/NetworkingTests/NetworkingIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class NetworkingIntegrationTests: XCTestCase { +final class NetworkingIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testSetAuthorizationHeaderWithUsernameAndPassword() async throws { @@ -47,8 +47,9 @@ class NetworkingIntegrationTests: XCTestCase { } func testClearCacheOnlyRemovesTheNetworkingFolder() async throws { - let cachesRoot = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! - let networkingFolder = cachesRoot.appendingPathComponent(URL(string: Networking.domain)!.absoluteString) + let cachesRoot = try XCTUnwrap(FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first) + let domainComponent = try XCTUnwrap(URL(string: Networking.domain)?.absoluteString) + let networkingFolder = cachesRoot.appendingPathComponent(domainComponent) let networking = Networking(baseURL: baseURL) let _: Result = await networking.downloadImage("/image/png") diff --git a/Tests/NetworkingTests/NetworkingTests.swift b/Tests/NetworkingTests/NetworkingTests.swift index 875ffb2..a39060c 100644 --- a/Tests/NetworkingTests/NetworkingTests.swift +++ b/Tests/NetworkingTests/NetworkingTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class NetworkingTests: XCTestCase { +final class NetworkingTests: XCTestCase { let baseURL = "http://example.com" func setAuthorizationHeaderCustomValue() async throws { diff --git a/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift b/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift index f86200d..a6c22b4 100644 --- a/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift +++ b/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift @@ -4,7 +4,7 @@ import XCTest @testable import Networking -class NewNetworkingIntegrationTests: XCTestCase { +final class NewNetworkingIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testNewGET() async throws { @@ -13,7 +13,7 @@ class NewNetworkingIntegrationTests: XCTestCase { let result: Result = await networking.get("/get") switch result { - case .success(_): + case .success: print("worked") case .failure(let failure): print(failure.localizedDescription) @@ -30,13 +30,13 @@ class NewNetworkingIntegrationTests: XCTestCase { URLQueryItem(name: "pickup_latitude", value: "\(pickupCoordinate.latitude)"), URLQueryItem(name: "pickup_longitude", value: "\(pickupCoordinate.longitude)"), URLQueryItem(name: "delivery_latitude", value: "\(deliveryCoordinate.latitude)"), - URLQueryItem(name: "delivery_longitude", value: "\(deliveryCoordinate.longitude)"), + URLQueryItem(name: "delivery_longitude", value: "\(deliveryCoordinate.longitude)") ] let result: Result = await networking.get("/get", query: query) switch result { - case .success(_): + case .success: print("Test passed") case .failure(let error): print("error \(error)") @@ -49,7 +49,7 @@ class NewNetworkingIntegrationTests: XCTestCase { let result: Result = await networking.post("/get", body: ["String": "String"]) switch result { - case .success(_): + case .success: print("worked") case .failure(let failure): print(failure.localizedDescription) diff --git a/Tests/NetworkingTests/NewNetworkingTests.swift b/Tests/NetworkingTests/NewNetworkingTests.swift index db6af71..12cd47d 100644 --- a/Tests/NetworkingTests/NewNetworkingTests.swift +++ b/Tests/NetworkingTests/NewNetworkingTests.swift @@ -4,7 +4,7 @@ import XCTest @testable import Networking -class NewNetworkingTests: XCTestCase { +final class NewNetworkingTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL struct ValidationErrors: Decodable { let errors: [String: [String]] } @@ -17,7 +17,11 @@ class NewNetworkingTests: XCTestCase { "phone_number": ["has already been taken"] ] ] - await networking.fakePOST("/auth", response: response, statusCode: 422) + await networking.fakePOST( + "/auth", + response: response, + statusCode: 422 + ) let result: Result = await networking.post("/auth") switch result { diff --git a/Tests/NetworkingTests/ObservabilityIntegrationTests.swift b/Tests/NetworkingTests/ObservabilityIntegrationTests.swift index 87b97fb..29c9c5a 100644 --- a/Tests/NetworkingTests/ObservabilityIntegrationTests.swift +++ b/Tests/NetworkingTests/ObservabilityIntegrationTests.swift @@ -13,13 +13,21 @@ final class ObservabilityIntegrationTests: XCTestCase { let _: Result = await networking.get("/get") let events = await stream.collect(2) - XCTAssertEqual(events.count, 2, "expected a .started and a .completed event") + XCTAssertEqual( + events.count, + 2, + "expected a .started and a .completed event" + ) guard case .started(let startContext) = events.first, case .completed(let endContext, let outcome, let duration, let metrics) = events.last else { return XCTFail("expected .started then .completed, got \(events)") } - XCTAssertEqual(startContext.id, endContext.id, "the two events should share a request id") + XCTAssertEqual( + startContext.id, + endContext.id, + "the two events should share a request id" + ) XCTAssertEqual(startContext.method, "GET") XCTAssertGreaterThan(duration, .zero) guard case .success(let statusCode, let byteCount) = outcome else { @@ -60,7 +68,11 @@ final class ObservabilityIntegrationTests: XCTestCase { guard case .started(let context) = events.first else { return XCTFail("expected a .started event, got \(events)") } - XCTAssertEqual(context.headers["Authorization"], "Bearer supersecret", "events() carries the real header value") + XCTAssertEqual( + context.headers["Authorization"], + "Bearer supersecret", + "events() carries the real header value" + ) XCTAssertEqual(context.headers["Cookie"], "session=abc") XCTAssertEqual(context.headers["X-Trace"], "trace-123") } @@ -75,13 +87,21 @@ final class ObservabilityIntegrationTests: XCTestCase { let _: Result = await networking.post("/post", body: Unencodable()) let events = await stream.collect(2) - XCTAssertEqual(events.count, 2, "an encode failure must still emit .started and .completed; got \(events)") + XCTAssertEqual( + events.count, + 2, + "an encode failure must still emit .started and .completed; got \(events)" + ) guard case .started(let startContext) = events.first, case .completed(let endContext, let outcome, _, _) = events.last else { return XCTFail("expected .started then .completed, got \(events)") } - XCTAssertEqual(startContext.id, endContext.id, "both events should share a request id") + XCTAssertEqual( + startContext.id, + endContext.id, + "both events should share a request id" + ) guard case .failure(let error) = outcome, case .invalidRequest = error else { return XCTFail("expected an .invalidRequest failure outcome, got \(outcome)") } diff --git a/Tests/NetworkingTests/PATCHIntegrationTests.swift b/Tests/NetworkingTests/PATCHIntegrationTests.swift index 0545654..5f84839 100644 --- a/Tests/NetworkingTests/PATCHIntegrationTests.swift +++ b/Tests/NetworkingTests/PATCHIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class PATCHIntegrationTests: XCTestCase { +final class PATCHIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testPATCH() async throws { @@ -41,7 +41,7 @@ class PATCHIntegrationTests: XCTestCase { "/posdddddt", body: ["username": "jameson", "password": "secret"]) switch result { case .success: - XCTFail() + XCTFail("a PATCH to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") diff --git a/Tests/NetworkingTests/POSTIntegrationTests.swift b/Tests/NetworkingTests/POSTIntegrationTests.swift index 4a3b8c4..8053f2a 100644 --- a/Tests/NetworkingTests/POSTIntegrationTests.swift +++ b/Tests/NetworkingTests/POSTIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class POSTIntegrationTests: XCTestCase { +final class POSTIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testPOSTWithoutBody() async throws { @@ -28,7 +28,13 @@ class POSTIntegrationTests: XCTestCase { } // Distinct values so an Int/Double/Bool encoding mix-up would actually fail the assertions. let result: Result = await networking.post( - "/post", body: Payload(string: "valueA", int: 20, double: 20.5, bool: false)) + "/post", + body: Payload( + string: "valueA", + int: 20, + double: 20.5, + bool: false + )) switch result { case .success(let response): XCTAssertEqual(response.json.string, "valueA") @@ -70,7 +76,7 @@ class POSTIntegrationTests: XCTestCase { "int": "20", "double": "20.0", "bool": "true", - "date": "2016-11-02T13:55:28+01:00", + "date": "2016-11-02T13:55:28+01:00" ] let result: Result = await networking.post("/post", form: parameters) switch result { @@ -91,16 +97,27 @@ class POSTIntegrationTests: XCTestCase { let item1 = "FIRSTDATA" let item2 = "SECONDDATA" - let part1 = FormDataPart(data: item1.data(using: .utf8)!, parameterName: item1, filename: "\(item1).png") - let part2 = FormDataPart(data: item2.data(using: .utf8)!, parameterName: item2, filename: "\(item2).png") + let part1 = FormDataPart( + data: Data(item1.utf8), + parameterName: item1, + filename: "\(item1).png" + ) + let part2 = FormDataPart( + data: Data(item2.utf8), + parameterName: item2, + filename: "\(item2).png" + ) let fields = [ "string": "valueA", "int": "20", "double": "20.0", - "bool": "true", + "bool": "true" ] let result: Result = await networking.post( - "/post", parts: [part1, part2], fields: fields) + "/post", + parts: [part1, part2], + fields: fields + ) switch result { case .success(let response): XCTAssertEqual(response.body.string(for: "url"), "\(TestConfig.httpbinBaseURL)/post") @@ -126,7 +143,11 @@ class POSTIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL) let item1 = "FIRSTDATA" - let part1 = FormDataPart(data: item1.data(using: .utf8)!, parameterName: item1, filename: "\(item1).png") + let part1 = FormDataPart( + data: Data(item1.utf8), + parameterName: item1, + filename: "\(item1).png" + ) let result: Result = await networking.post("/post", parts: [part1]) switch result { case .success(let response): @@ -147,10 +168,17 @@ class POSTIntegrationTests: XCTestCase { let imageURL = try XCTUnwrap(Bundle.module.url(forResource: "pig", withExtension: "png")) let imageData = try Data(contentsOf: imageURL) - let imagePart = FormDataPart(data: imageData, parameterName: "file", filename: "pig.png") + let imagePart = FormDataPart( + data: imageData, + parameterName: "file", + filename: "pig.png" + ) let result: Result = await networking.post( - "/post", parts: [imagePart], fields: ["public_id": "pig"]) + "/post", + parts: [imagePart], + fields: ["public_id": "pig"] + ) switch result { case .success(let response): XCTAssertEqual(response.body.string(for: "url"), "\(TestConfig.httpbinBaseURL)/post") @@ -189,7 +217,7 @@ class POSTIntegrationTests: XCTestCase { "/posdddddt", body: ["username": "jameson", "password": "secret"]) switch result { case .success: - XCTFail() + XCTFail("a POST to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -207,5 +235,6 @@ private struct PostJSONEcho: Decodable { let double: Double let bool: Bool } + let json: Payload } diff --git a/Tests/NetworkingTests/PUTIntegrationTests.swift b/Tests/NetworkingTests/PUTIntegrationTests.swift index ba4d0d1..8110f7a 100644 --- a/Tests/NetworkingTests/PUTIntegrationTests.swift +++ b/Tests/NetworkingTests/PUTIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class PUTIntegrationTests: XCTestCase { +final class PUTIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testPUT() async throws { @@ -41,7 +41,7 @@ class PUTIntegrationTests: XCTestCase { "/posdddddt", body: ["username": "jameson", "password": "secret"]) switch result { case .success: - XCTFail() + XCTFail("a PUT to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") diff --git a/Tests/NetworkingTests/ResponseIntegrationTests.swift b/Tests/NetworkingTests/ResponseIntegrationTests.swift index 8e813d5..45feb1d 100644 --- a/Tests/NetworkingTests/ResponseIntegrationTests.swift +++ b/Tests/NetworkingTests/ResponseIntegrationTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import Networking -class ResponseIntegrationTests: XCTestCase { +final class ResponseIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testReflectsRequestHeaderInBody() async throws { diff --git a/Tests/NetworkingTests/ResponseValidatorIntegrationTests.swift b/Tests/NetworkingTests/ResponseValidatorIntegrationTests.swift index e40e377..275707c 100644 --- a/Tests/NetworkingTests/ResponseValidatorIntegrationTests.swift +++ b/Tests/NetworkingTests/ResponseValidatorIntegrationTests.swift @@ -29,7 +29,11 @@ final class ResponseValidatorIntegrationTests: XCTestCase { return XCTFail("expected a .validation failure, got \(result)") } XCTAssertTrue(reason.contains("application/json"), "the reason should explain the expectation; got \(reason)") - XCTAssertEqual(metadata.statusCode, 200, "validation carries the offending response's metadata") + XCTAssertEqual( + metadata.statusCode, + 200, + "validation carries the offending response's metadata" + ) } // A validator scrutinizes successes, not errors: a non-2xx response must still surface as its .http diff --git a/Tests/NetworkingTests/RetryInterceptorTests.swift b/Tests/NetworkingTests/RetryInterceptorTests.swift index ff7f1fa..8fa405d 100644 --- a/Tests/NetworkingTests/RetryInterceptorTests.swift +++ b/Tests/NetworkingTests/RetryInterceptorTests.swift @@ -11,6 +11,7 @@ final class RetryInterceptorTests: XCTestCase { actor CallCounter { private(set) var count = 0 + func tick() -> Int { count += 1 return count @@ -26,14 +27,21 @@ final class RetryInterceptorTests: XCTestCase { let counter: CallCounter let outcomeForAttempt: @Sendable (Int) -> ScriptedOutcome - func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + func intercept( + _ request: URLRequest, + next: @Sendable (URLRequest) async throws -> HTTPExchange + ) async throws -> HTTPExchange { let attempt = await counter.tick() switch outcomeForAttempt(attempt) { case .status(let code, let headers): let url = request.url ?? URL(string: "https://example.com")! - let response = HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: headers)! + let response = HTTPURLResponse( + url: url, + headerFields: headers, + statusCode: code + ) return HTTPExchange(data: Data(), response: response) case .throwTransport(let code): throw URLError(code) @@ -50,7 +58,11 @@ final class RetryInterceptorTests: XCTestCase { func testRetriesTransientStatusThenSucceeds() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 5, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 5, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { $0 <= 2 ? .status(503) : .status(200) } ) @@ -58,13 +70,21 @@ final class RetryInterceptorTests: XCTestCase { if case .failure(let error) = result { XCTFail("expected success after retries, got \(error)") } let attempts = await counter.count - XCTAssertEqual(attempts, 3, "two 503s should be retried, succeeding on the third attempt") + XCTAssertEqual( + attempts, + 3, + "two 503s should be retried, succeeding on the third attempt" + ) } func testGivesUpAfterMaxAttempts() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { _ in .status(503) } ) @@ -75,13 +95,21 @@ final class RetryInterceptorTests: XCTestCase { } XCTAssertEqual(error.statusCode, 503) let attempts = await counter.count - XCTAssertEqual(attempts, 3, "a persistently failing request should stop at maxAttempts") + XCTAssertEqual( + attempts, + 3, + "a persistently failing request should stop at maxAttempts" + ) } func testHonorsRetryAfterHeader() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .seconds(5)), + RetryInterceptor( + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .seconds(5) + ), ScriptedInterceptor(counter: counter) { $0 == 1 ? .status(429, headers: ["Retry-After": "1"]) : .status(200) } @@ -94,7 +122,10 @@ final class RetryInterceptorTests: XCTestCase { if case .failure(let error) = result { XCTFail("expected success after honoring Retry-After, got \(error)") } XCTAssertGreaterThanOrEqual( - elapsed, .milliseconds(900), "Retry-After: 1 should pace the retry ~1s, not the 1ms base backoff") + elapsed, + .milliseconds(900), + "Retry-After: 1 should pace the retry ~1s, not the 1ms base backoff" + ) } // Retrying a non-idempotent request after a timeout/5xx can duplicate a side effect (a second charge, @@ -103,7 +134,11 @@ final class RetryInterceptorTests: XCTestCase { func testDoesNotRetryNonIdempotentMethodByDefault() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { _ in .status(503) } ) @@ -114,15 +149,22 @@ final class RetryInterceptorTests: XCTestCase { } XCTAssertEqual(error.statusCode, 503) let attempts = await counter.count - XCTAssertEqual(attempts, 1, "POST is not idempotent and must not be retried by default") + XCTAssertEqual( + attempts, + 1, + "POST is not idempotent and must not be retried by default" + ) } func testRetriesNonIdempotentMethodWhenExplicitlyConfigured() async { let counter = CallCounter() let networking = await networking( RetryInterceptor( - maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2), - retryableMethods: ["POST"]), + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2), + retryableMethods: ["POST"] + ), ScriptedInterceptor(counter: counter) { _ in .status(503) } ) @@ -130,13 +172,21 @@ final class RetryInterceptorTests: XCTestCase { if case .success = result { XCTFail("expected the persistent 503 to fail") } let attempts = await counter.count - XCTAssertEqual(attempts, 3, "an explicit retryableMethods opt-in should retry POST") + XCTAssertEqual( + attempts, + 3, + "an explicit retryableMethods opt-in should retry POST" + ) } func testDoesNotRetryNonRetryableStatus() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { _ in .status(404) } ) @@ -147,13 +197,21 @@ final class RetryInterceptorTests: XCTestCase { } XCTAssertEqual(error.statusCode, 404) let attempts = await counter.count - XCTAssertEqual(attempts, 1, "a 404 is not retryable and must not be retried") + XCTAssertEqual( + attempts, + 1, + "a 404 is not retryable and must not be retried" + ) } func testRetriesTransientTransportError() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 4, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 4, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { $0 <= 2 ? .throwTransport(.timedOut) : .status(200) } ) @@ -163,6 +221,10 @@ final class RetryInterceptorTests: XCTestCase { XCTFail("expected success after transient transport retries, got \(error)") } let attempts = await counter.count - XCTAssertEqual(attempts, 3, "two timeouts should be retried, succeeding on the third attempt") + XCTAssertEqual( + attempts, + 3, + "two timeouts should be retried, succeeding on the third attempt" + ) } } diff --git a/Tests/NetworkingTests/String+UTF8Tests.swift b/Tests/NetworkingTests/String+UTF8Tests.swift index e306f38..3ab4fc7 100644 --- a/Tests/NetworkingTests/String+UTF8Tests.swift +++ b/Tests/NetworkingTests/String+UTF8Tests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class String_UTF8Tests: XCTestCase { +final class StringUTF8Tests: XCTestCase { func testEncodeUTF8WithNorwegianCharacters() { let encodedString = "døgnvillburgere.jpg".encodeUTF8() diff --git a/Tests/NetworkingTests/TestHelpers.swift b/Tests/NetworkingTests/TestHelpers.swift index 6507ab0..0017e67 100644 --- a/Tests/NetworkingTests/TestHelpers.swift +++ b/Tests/NetworkingTests/TestHelpers.swift @@ -53,7 +53,11 @@ func httpbinEchoedMap(_ response: JSONResponse, _ key: String) -> [String: Strin struct Helper { - static func removeFileIfNeeded(_ networking: Networking, path: String, cacheName: String? = nil) throws { + static func removeFileIfNeeded( + _ networking: Networking, + path: String, + cacheName: String? = nil + ) throws { let destinationURL = try networking.destinationURL(for: path, cacheName: cacheName) if FileManager.default.exists(at: destinationURL) { try FileManager.default.remove(at: destinationURL) diff --git a/Tests/NetworkingTests/TypedRequestBodyIntegrationTests.swift b/Tests/NetworkingTests/TypedRequestBodyIntegrationTests.swift index a09851b..bcef909 100644 --- a/Tests/NetworkingTests/TypedRequestBodyIntegrationTests.swift +++ b/Tests/NetworkingTests/TypedRequestBodyIntegrationTests.swift @@ -21,7 +21,11 @@ final class TypedRequestBodyIntegrationTests: XCTestCase { func testPOSTWithTypedBodyRoundTrips() async throws { let networking = Networking(baseURL: baseURL) - let body = Credentials(username: "jameson", age: 20, active: true) + let body = Credentials( + username: "jameson", + age: 20, + active: true + ) let result: Result = await networking.post("/post", body: body) switch result { case .success(let echo): @@ -34,7 +38,11 @@ final class TypedRequestBodyIntegrationTests: XCTestCase { func testPOSTWithTypedBodyVoidReturn() async throws { let networking = Networking(baseURL: baseURL) - let body = Credentials(username: "jameson", age: 20, active: true) + let body = Credentials( + username: "jameson", + age: 20, + active: true + ) let result: Result = await networking.post("/post", body: body) if case .failure(let error) = result { XCTFail(error.localizedDescription) @@ -43,7 +51,11 @@ final class TypedRequestBodyIntegrationTests: XCTestCase { func testPUTWithTypedBodyRoundTrips() async throws { let networking = Networking(baseURL: baseURL) - let body = Credentials(username: "ada", age: 36, active: false) + let body = Credentials( + username: "ada", + age: 36, + active: false + ) let result: Result = await networking.put("/put", body: body) switch result { case .success(let echo): @@ -55,7 +67,11 @@ final class TypedRequestBodyIntegrationTests: XCTestCase { func testPATCHWithTypedBodyRoundTrips() async throws { let networking = Networking(baseURL: baseURL) - let body = Credentials(username: "grace", age: 45, active: true) + let body = Credentials( + username: "grace", + age: 45, + active: true + ) let result: Result = await networking.patch("/patch", body: body) switch result { case .success(let echo): diff --git a/bin/lint b/bin/lint new file mode 100755 index 0000000..fb8ca29 --- /dev/null +++ b/bin/lint @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Lints with oida, our own linter, whose rules are in .oida.yml. +# +# bin/lint report what it finds +# bin/lint --fix fix what can be fixed, and format +# bin/lint --check fail on any violation or unformatted file (what CI runs) +# bin/lint --staged the same check, over staged files only (what the commit hook runs) +# +# One tool does both jobs: the linter decides which lists split and which join, then hands the files to +# swift-format — Xcode's on a Mac, the toolchain's on Linux CI, either way the binary Format File runs — +# which owns every indent. +# +# There is no baseline. A standing violation would be silent under one, and this repository adopted oida +# to find what it says rather than to record that it once said something, so the tree is kept at zero and +# any violation fails. +set -euo pipefail +cd "$(dirname "$0")/.." + +# oida ships two builds, and this is the only place that chooses between them. Anything else fails +# loudly rather than guessing. +case "$(uname -s)" in + Darwin) + platform="macos-arm64" + default_cache_root="$HOME/Library/Caches" + ;; + Linux) + platform="linux-x86_64" + default_cache_root="$HOME/.cache" + ;; + *) + echo "oida has no build for $(uname -s). It ships macOS arm64 and Linux x86_64 only." >&2 + exit 1 + ;; +esac + +# The linter is ours (github.com/kolonialno/oida-swift). It arrives as a download rather than a committed +# binary, because every landing of a 7 MB blob stays in the history forever. +# +# Pinned and checksummed rather than "latest": .oida-version names the release and .oida-checksum-$platform +# names its bytes, so a clone cannot lint with a build this repository did not ask for, and a tampered +# download cannot pass for one. The version is asserted after unzipping too, which is what catches a +# half-finished download rather than running whatever is in the cache. +version=$(cat .oida-version) +expected=$(cat ".oida-checksum-$platform") +cache="${XDG_CACHE_HOME:-$default_cache_root}/oida/$version" +oida="$cache/oida" + +if [ ! -x "$oida" ]; then + archive=$(mktemp -d)/oida-$platform.zip + url="https://github.com/kolonialno/oida-swift/releases/download/$version/oida-$platform.zip" + echo "Fetching the linter, $version — once per version." >&2 + if ! curl -fsSL "$url" -o "$archive"; then + echo "Could not download $url" >&2 + echo "Check that .oida-version names a published release." >&2 + exit 1 + fi + found=$(shasum -a 256 "$archive" | cut -d' ' -f1) + if [ "$found" != "$expected" ]; then + echo "The download does not match .oida-checksum-$platform." >&2 + echo " expected $expected" >&2 + echo " found $found" >&2 + echo "Refusing to run it. Either the release was rebuilt or the download is not what it claims." >&2 + exit 1 + fi + mkdir -p "$cache" + unzip -qo "$archive" -d "$cache" + chmod +x "$oida" + rm -rf "$(dirname "$archive")" +fi + +# The pin is asserted rather than trusted: a cached binary from an interrupted download, or one someone +# put there by hand, would otherwise lint by rules this repository never chose. +have=$("$oida" version) +if [ "$version" != "$have" ]; then + echo "The linter at $oida reports $have but .oida-version pins $version." >&2 + echo "Remove it and let this script fetch the pinned one:" >&2 + echo " rm -rf \"$cache\"" >&2 + exit 1 +fi + +case "${1:-}" in + --staged) + staged=() + while IFS= read -r -d '' file; do staged+=("$file"); done < <(./bin/staged-swift-files) + + if [ ${#staged[@]} -eq 0 ]; then + echo "No staged Swift files." + exit 0 + fi + + # The whole repository, not just the staged files. A rule that counts callers across the run + # reads a partial run as a function with fewer callers than it has and invents violations, so + # the staged list decides only whether to run at all. + if ! "$oida" lint --strict --format --quiet; then + echo "Fix the violations above, or justify one with a scoped oida:disable." >&2 + exit 1 + fi + echo "Lint clean, with ${#staged[@]} staged Swift file(s)." + ;; + --check) + "$oida" lint --strict --format --quiet + echo "Lint and formatting are clean." + ;; + --fix) + "$oida" --fix --format --quiet >/dev/null + echo "Fixed and formatted." + ;; + *) + "$oida" lint --strict + ;; +esac diff --git a/bin/staged-swift-files b/bin/staged-swift-files new file mode 100755 index 0000000..572c445 --- /dev/null +++ b/bin/staged-swift-files @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# +# Prints the staged Swift files worth checking, null-delimited, from the repo root. +# +# bin/lint decides from this list whether a commit is worth linting at all — it lints the whole +# repository when it runs, since a rule that counts callers across the run cannot read a partial one. +# So a file this list wrongly keeps costs a couple of seconds, and one it wrongly skips costs a commit +# that was never checked. The list mirrors `excluded:` in .oida.yml. +# +# Null-delimited, so a Swift file under a folder with a space in its name stays one path. +set -euo pipefail +cd "$(dirname "$0")/.." + +# Paths arrive relative to the repo root. +excluded='/\.build/|^\.build/|^build/|/DerivedData/|/SourcePackages/|/GeneratedSources/|/Intermediates\.noindex/' + +# --diff-filter drops deletions and renames-away: there is nothing left to read. +while IFS= read -r -d '' file; do + printf '%s' "$file" | grep -qE "$excluded" || printf '%s\0' "$file" +done < <(git diff --cached --name-only --diff-filter=ACMR -z -- '*.swift') diff --git a/scripts/setup.sh b/scripts/setup.sh index 47491e8..5b70088 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -4,4 +4,4 @@ set -e repo_root=$(git rev-parse --show-toplevel) git -C "$repo_root" config core.hooksPath .githooks -echo "setup: core.hooksPath -> .githooks (swift-format pre-commit hook enabled)" +echo "setup: core.hooksPath -> .githooks (oida pre-commit hook enabled)"