Commit a52dee116d

a52dee116d4066d1b59bd90b4ebc4def4e1597d6

parent: 7001577e67

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 04:14 UTC

feat: stabilize and document the Local API v1 response contract (v5 step 3)

Second v5.0.0 roadmap item: make the Local API's public JSON contract explicit,
documented, and regression-locked, so external consumers (Shortcuts, scripts,
integrations) have a stable surface with a defined compatibility promise.

- LocalAPIContract: new single source of truth for the wire-format version
  ("v1") and the canonical JSON encoder (ISO-8601 dates, sorted keys). Both the
  success and error paths in LocalAPIService now route through it, so the format
  can't drift between them, and the ad-hoc per-call-site encoders are gone.

- The response envelope and every payload struct are promoted from `private` to
  internal so the contract is a first-class, testable part of the module. The
  transport/handler internals (request parser, HTTP response, secret store)
  stay private.

- Docs/local-api.md documents the base URL/auth, the envelope, the encoding
  conventions (notably: absent optionals are omitted, not null), every endpoint
  and its payload fields, the error codes, and the semantic-version-style
  compatibility policy (additive changes keep v1; renames/removals/type changes
  bump the version). Linked from the README.

- LocalAPIContractTests: 16 structure/"golden" tests pinning the envelope shape,
  each payload's field names, the enum encodings, and the ISO-8601 date format.
  They assert structure, not values, so ordinary behavior changes don't churn
  them but a renamed or dropped field fails CI. Full unit suite: 52 passing.

Layout: unified · split

Docs/local-api.md added +113
@@ -0,0 +1,113 @@
1# DomainDig Local API — `v1`
2
3The Local API exposes DomainDig's canonical report data to on-device automation
4(Shortcuts, scripts, integrations). It is **off by default** and, when enabled,
5binds only to loopback.
6
7- **Base URL:** `http://127.0.0.1:<port>` (default port `47821`, configurable in
8 Settings → Local API)
9- **Binding:** loopback only (`acceptLocalOnly`); never reachable off-device
10- **Content type:** every response is `application/json`
11- **Version:** `v1` (reported in every response envelope)
12
13This document is the stable contract. The response shape is pinned by
14`DomainDigTests/LocalAPIContractTests.swift`; `LocalAPIContract` (in
15`LocalAPIContract.swift`) is the single source of truth for the version string
16and the JSON encoder.
17
18## Authentication
19
20Every request requires the token shown in Settings → Local API, supplied either
21way:
22
23```
24Authorization: Bearer <token>
25```
26```
27X-API-Token: <token>
28```
29
30A missing or wrong token returns `401 unauthorized`. Settings → Local API has a
31**Copy cURL Command** button that emits a ready-to-run authenticated request.
32
33## Response envelope
34
35Every response — success or error — is wrapped in the same envelope:
36
37```json
38{
39 "success": true,
40 "version": "v1",
41 "data": { "...": "payload, present on success" }
42}
43```
44```json
45{
46 "success": false,
47 "version": "v1",
48 "error": { "code": "not_found", "message": "The requested Local API route does not exist." }
49}
50```
51
52- On success, `data` holds the endpoint payload and `error` is **omitted**.
53- On failure, `error` holds a machine `code` plus a human `message`, and `data`
54 is **omitted**.
55
56### Encoding conventions
57
58- **Dates** are ISO-8601 UTC strings, e.g. `"2023-11-14T22:13:20Z"`.
59- **Absent optional fields are omitted, not `null`.** Consumers must treat a
60 missing key as "not present."
61- Object keys are emitted in sorted order (deterministic output; not
62 contractually meaningful — do not depend on key order).
63
64## Endpoints
65
66| Method | Path | Payload (`data`) fields |
67|--------|------|-------------------------|
68| GET | `/portfolio` | `summary` → `{ totalDomains, healthyCount, warningCount, criticalCount, changedLast24h, expiringSoonCount, unreachableCount }` |
69| GET | `/domains` | `domains: [TrackedDomain]` |
70| GET | `/domains/{domain}` | `domain`, `trackedDomain?` (`TrackedDomain`), `latestReport?` (`DomainReport`) |
71| GET | `/domains/{domain}/history` | `domain`, `history: [HistoryEntry]` |
72| GET | `/events` | `events: [{ timestamp, domain, summary, status, severity }]` |
73| GET | `/monitoring` | `isEnabled`, `scope` (`"allTracked"` \| `"selectedOnly"`), `alertsEnabled`, `monitoredDomains: [{ domain, monitoringEnabled, lastMonitoredAt?, lastAlertAt?, certificateWarningLevel }]` |
74| POST | `/inspect` | body `{ "domain": "example.com" }` → `report` (`DomainReport`) |
75| POST | `/inspect/{domain}` | `report` (`DomainReport`) |
76| POST | `/monitoring/{domain}/enable` | `domain`, `monitoringEnabled` |
77| POST | `/monitoring/{domain}/disable` | `domain`, `monitoringEnabled` |
78
79`certificateWarningLevel` encodes as `"none"`, `"warning"`, or `"critical"`.
80
81`DomainReport` is the app's canonical report model (the same shape the JSON
82export produces); see `DomainReportBuilder.swift` for its fields. It is a large
83object and is treated as an additive contract: new fields may appear without a
84version bump.
85
86## Error codes
87
88| HTTP | `code` | When |
89|------|--------|------|
90| 400 | `bad_request` | The HTTP request line/path could not be parsed |
91| 400 | `invalid_body` | `POST /inspect` body was not `{ "domain": "…" }` |
92| 400 | `invalid_domain` | A path/body domain was empty or invalid |
93| 401 | `unauthorized` | Missing or incorrect token |
94| 404 | `not_found` | No such route |
95| 404 | `domain_not_found` | No local data / tracked domain for the given name |
96| 500 | `encoding_failed` | The response could not be encoded |
97| 500 | `internal_error` | The request handler failed unexpectedly |
98
99## Compatibility policy
100
101The `version` field follows a semantic-version-style promise:
102
103- **Backward-compatible changes keep `version` at `v1`.** Adding a new endpoint,
104 or adding a new field to an existing payload, is non-breaking. **Consumers
105 must ignore unknown fields.**
106- **Breaking changes bump `version`.** Renaming or removing a field, changing a
107 field's type, or changing the meaning/units of an existing field requires a new
108 version, an update to this document, and an update to
109 `LocalAPIContractTests.swift`.
110
111There are currently no deprecated fields or endpoints. When a field is
112deprecated, it will be listed here with the version in which it becomes eligible
113for removal, and will remain present for at least one subsequent version.
DomainDig.xcodeproj/project.pbxproj +8
@@ -7,6 +7,7 @@
7 objects = { 7 objects = {
8 8
9/* Begin PBXBuildFile section */ 9/* Begin PBXBuildFile section */
10 05F775C7E0743AF727B008EE /* LocalAPIContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */; };
10 38316D90539394C2CC7C12BE /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */; }; 11 38316D90539394C2CC7C12BE /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */; };
11 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; }; 12 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; };
12 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; }; 13 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; };
@@ -22,6 +23,7 @@
22 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; }; 23 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; };
23 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; }; 24 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; };
24 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */; }; 25 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */; };
26 EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */; };
25/* End PBXBuildFile section */ 27/* End PBXBuildFile section */
26 28
27/* Begin PBXContainerItemProxy section */ 29/* Begin PBXContainerItemProxy section */
@@ -71,9 +73,11 @@
71/* End PBXCopyFilesBuildPhase section */ 73/* End PBXCopyFilesBuildPhase section */
72 74
73/* Begin PBXFileReference section */ 75/* Begin PBXFileReference section */
76 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContractTests.swift; sourceTree = "<group>"; };
74 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = "<group>"; }; 77 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = "<group>"; };
75 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = "<group>"; }; 78 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = "<group>"; };
76 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotFixture.swift; sourceTree = "<group>"; }; 79 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotFixture.swift; sourceTree = "<group>"; };
80 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContract.swift; sourceTree = "<group>"; };
77 8B6472EF300EBFA30018E10A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; }; 81 8B6472EF300EBFA30018E10A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
78 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; 82 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
79 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; 83 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
@@ -224,6 +228,7 @@
224 E82320955416797CFE59464A /* DomainReportBuilderTests.swift */, 228 E82320955416797CFE59464A /* DomainReportBuilderTests.swift */,
225 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */, 229 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */,
226 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */, 230 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */,
231 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */,
227 ); 232 );
228 name = DomainDigTests; 233 name = DomainDigTests;
229 path = DomainDigTests; 234 path = DomainDigTests;
@@ -248,6 +253,7 @@
248 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */, 253 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */,
249 12026CD045DFB45E9E37D207 /* Frameworks */, 254 12026CD045DFB45E9E37D207 /* Frameworks */,
250 7CC29A88387286DE8B4D17B3 /* DomainDigTests */, 255 7CC29A88387286DE8B4D17B3 /* DomainDigTests */,
256 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */,
251 ); 257 );
252 sourceTree = "<group>"; 258 sourceTree = "<group>";
253 }; 259 };
@@ -480,6 +486,7 @@
480 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */, 486 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */,
481 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */, 487 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */,
482 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */, 488 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */,
489 EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */,
483 ); 490 );
484 runOnlyForDeploymentPostprocessing = 0; 491 runOnlyForDeploymentPostprocessing = 0;
485 }; 492 };
@@ -494,6 +501,7 @@
494 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, 501 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
495 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */, 502 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
496 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */, 503 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */,
504 05F775C7E0743AF727B008EE /* LocalAPIContract.swift in Sources */,
497 ); 505 );
498 runOnlyForDeploymentPostprocessing = 0; 506 runOnlyForDeploymentPostprocessing = 0;
499 }; 507 };
DomainDigTests/LocalAPIContractTests.swift added +179
@@ -0,0 +1,179 @@
1import XCTest
2@testable import DomainDig
3
4/// Locks the Local API wire contract: the envelope shape, each payload's field
5/// names, and the encoding conventions (`v1`, ISO-8601 dates, nil omission).
6/// A rename or removed field fails here instead of silently breaking an external
7/// consumer. Values are deliberately not asserted — only structure — so ordinary
8/// behavior changes don't churn these tests.
9final class LocalAPIContractTests: XCTestCase {
10 private let encoder = LocalAPIContract.makeEncoder()
11
12 // MARK: - Helpers
13
14 private func json<T: Encodable>(_ value: T) throws -> [String: Any] {
15 let data = try encoder.encode(value)
16 let object = try JSONSerialization.jsonObject(with: data)
17 return try XCTUnwrap(object as? [String: Any])
18 }
19
20 private func keys<T: Encodable>(_ value: T) throws -> Set<String> {
21 Set(try json(value).keys)
22 }
23
24 // MARK: - Envelope
25
26 func testVersionIsV1() {
27 XCTAssertEqual(LocalAPIContract.version, "v1")
28 }
29
30 func testSuccessEnvelopeOmitsErrorAndReportsVersion() throws {
31 let envelope = LocalAPIEnvelope(
32 success: true,
33 data: PortfolioPayload(summary: Self.sampleSummary),
34 error: nil,
35 version: LocalAPIContract.version
36 )
37 let object = try json(envelope)
38
39 XCTAssertEqual(Set(object.keys), ["success", "data", "version"], "nil error must be omitted from the envelope")
40 XCTAssertEqual(object["success"] as? Bool, true)
41 XCTAssertEqual(object["version"] as? String, "v1")
42 }
43
44 func testErrorEnvelopeOmitsDataAndCarriesCodeAndMessage() throws {
45 let envelope = LocalAPIEnvelope<EmptyPayload>(
46 success: false,
47 data: nil,
48 error: LocalAPIErrorPayload(code: "not_found", message: "The requested Local API route does not exist."),
49 version: LocalAPIContract.version
50 )
51 let object = try json(envelope)
52
53 XCTAssertEqual(Set(object.keys), ["success", "error", "version"], "nil data must be omitted from the envelope")
54 XCTAssertEqual(object["success"] as? Bool, false)
55 let error = try XCTUnwrap(object["error"] as? [String: Any])
56 XCTAssertEqual(Set(error.keys), ["code", "message"])
57 XCTAssertEqual(error["code"] as? String, "not_found")
58 }
59
60 // MARK: - Payload field names
61
62 func testPortfolioSummaryFields() throws {
63 XCTAssertEqual(
64 try keys(Self.sampleSummary),
65 ["totalDomains", "healthyCount", "warningCount", "criticalCount",
66 "changedLast24h", "expiringSoonCount", "unreachableCount"]
67 )
68 }
69
70 func testPortfolioPayloadFields() throws {
71 XCTAssertEqual(try keys(PortfolioPayload(summary: Self.sampleSummary)), ["summary"])
72 }
73
74 func testDomainListPayloadFields() throws {
75 let payload = DomainListPayload(domains: [TrackedDomain(domain: "example.com")])
76 XCTAssertEqual(try keys(payload), ["domains"])
77 }
78
79 func testDomainDetailPayloadFieldsWhenPopulated() throws {
80 let payload = DomainDetailPayload(
81 domain: "example.com",
82 trackedDomain: TrackedDomain(domain: "example.com"),
83 latestReport: SnapshotFixture.report(domain: "example.com")
84 )
85 XCTAssertEqual(try keys(payload), ["domain", "trackedDomain", "latestReport"])
86 }
87
88 func testDomainDetailPayloadOmitsAbsentOptionals() throws {
89 let payload = DomainDetailPayload(domain: "example.com", trackedDomain: nil, latestReport: nil)
90 XCTAssertEqual(try keys(payload), ["domain"], "absent trackedDomain/latestReport are omitted, not null")
91 }
92
93 func testDomainHistoryPayloadFields() throws {
94 let payload = DomainHistoryPayload(domain: "example.com", history: [])
95 XCTAssertEqual(try keys(payload), ["domain", "history"])
96 }
97
98 func testRecentEventPayloadFields() throws {
99 XCTAssertEqual(try keys(Self.sampleEvent), ["timestamp", "domain", "summary", "status", "severity"])
100 }
101
102 func testRecentEventsPayloadFields() throws {
103 XCTAssertEqual(try keys(RecentEventsPayload(events: [Self.sampleEvent])), ["events"])
104 }
105
106 func testMonitoringPayloadFieldsAndEnumEncoding() throws {
107 let payload = MonitoringPayload(
108 isEnabled: true,
109 scope: .allTracked,
110 alertsEnabled: true,
111 monitoredDomains: [Self.sampleMonitoringDomain]
112 )
113 let object = try json(payload)
114 XCTAssertEqual(Set(object.keys), ["isEnabled", "scope", "alertsEnabled", "monitoredDomains"])
115 XCTAssertEqual(object["scope"] as? String, "allTracked", "MonitoringScope encodes as its String raw value")
116 }
117
118 func testMonitoringDomainPayloadFieldsAndEnumEncoding() throws {
119 let object = try json(Self.sampleMonitoringDomain)
120 XCTAssertEqual(
121 Set(object.keys),
122 ["domain", "monitoringEnabled", "lastMonitoredAt", "lastAlertAt", "certificateWarningLevel"]
123 )
124 XCTAssertEqual(object["certificateWarningLevel"] as? String, "none")
125 }
126
127 func testMonitoringMutationPayloadFields() throws {
128 XCTAssertEqual(
129 try keys(MonitoringMutationPayload(domain: "example.com", monitoringEnabled: true)),
130 ["domain", "monitoringEnabled"]
131 )
132 }
133
134 func testInspectResponsePayloadFields() throws {
135 XCTAssertEqual(try keys(InspectResponsePayload(report: SnapshotFixture.report())), ["report"])
136 }
137
138 // MARK: - Encoding conventions
139
140 func testDatesEncodeAsISO8601() throws {
141 let event = RecentEventPayload(
142 timestamp: Date(timeIntervalSince1970: 1_700_000_000),
143 domain: "example.com",
144 summary: "changed",
145 status: "changed",
146 severity: "medium"
147 )
148 let object = try json(event)
149 XCTAssertEqual(object["timestamp"] as? String, "2023-11-14T22:13:20Z")
150 }
151
152 // MARK: - Fixtures
153
154 private static let sampleSummary = PortfolioSummary(
155 totalDomains: 3,
156 healthyCount: 1,
157 warningCount: 1,
158 criticalCount: 1,
159 changedLast24h: 2,
160 expiringSoonCount: 1,
161 unreachableCount: 0
162 )
163
164 private static let sampleEvent = RecentEventPayload(
165 timestamp: Date(timeIntervalSince1970: 1_700_000_000),
166 domain: "example.com",
167 summary: "Certificate is approaching expiry",
168 status: "changed",
169 severity: "medium"
170 )
171
172 private static let sampleMonitoringDomain = MonitoringDomainPayload(
173 domain: "example.com",
174 monitoringEnabled: true,
175 lastMonitoredAt: Date(timeIntervalSince1970: 1_700_000_000),
176 lastAlertAt: Date(timeIntervalSince1970: 1_700_000_000),
177 certificateWarningLevel: .none
178 )
179}
LocalAPIContract.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2
3/// The versioned wire contract for the Local API.
4///
5/// External consumers — Shortcuts, scripts, and third-party integrations — depend
6/// on the JSON this API produces: the response envelope, the field names of every
7/// payload, and the encoding conventions. This type is the single source of truth
8/// for the parts that must stay stable, and `LocalAPIContractTests` pins them so
9/// an accidental rename or shape change fails CI instead of silently breaking a
10/// consumer.
11///
12/// ## Compatibility policy
13///
14/// The `version` string reported in every envelope follows a semantic-version-style
15/// promise:
16///
17/// - **Backward-compatible** changes keep `version` at `"v1"`: adding a new
18/// endpoint, or adding a new field to a payload. Consumers must ignore unknown
19/// fields, so additions never require a bump.
20/// - **Breaking** changes require bumping `version` (and updating `Docs/local-api.md`
21/// plus the contract tests): renaming or removing a field, changing a field's
22/// type, or changing the meaning/units of an existing field.
23///
24/// See `Docs/local-api.md` for the full endpoint and schema reference.
25enum LocalAPIContract {
26 /// Wire-format version reported in every envelope's `version` field.
27 static let version = "v1"
28
29 /// The canonical encoder for every Local API response. ISO-8601 dates and
30 /// sorted keys keep the output deterministic, which is what lets the contract
31 /// tests pin the shape. Both the success and error paths route through this so
32 /// the wire format can never drift between them.
33 static func makeEncoder() -> JSONEncoder {
34 let encoder = JSONEncoder()
35 encoder.dateEncodingStrategy = .iso8601
36 encoder.outputFormatting = [.sortedKeys]
37 return encoder
38 }
39}
LocalAPIService.swift +19 −28
@@ -3,8 +3,6 @@ import Network
3import Observation 3import Observation
4import Security 4import Security
5 5
6private let localAPIVersion = "v1"
7
8private enum LocalAPIServerError: LocalizedError { 6private enum LocalAPIServerError: LocalizedError {
9 case missingSecret 7 case missingSecret
10 case secretPersistenceFailed 8 case secretPersistenceFailed
@@ -508,12 +506,7 @@ private final class LocalAPIServer: @unchecked Sendable {
508} 506}
509 507
510private struct LocalAPIRequestHandler { 508private struct LocalAPIRequestHandler {
511 private let encoder: JSONEncoder = { 509 private let encoder = LocalAPIContract.makeEncoder()
512 let encoder = JSONEncoder()
513 encoder.dateEncodingStrategy = .iso8601
514 encoder.outputFormatting = [.sortedKeys]
515 return encoder
516 }()
517 510
518 private let decoder: JSONDecoder = { 511 private let decoder: JSONDecoder = {
519 let decoder = JSONDecoder() 512 let decoder = JSONDecoder()
@@ -606,7 +599,7 @@ private struct LocalAPIRequestHandler {
606 } 599 }
607 600
608 private func successResponse<Value: Encodable>(_ value: Value) -> LocalAPIHTTPResponse { 601 private func successResponse<Value: Encodable>(_ value: Value) -> LocalAPIHTTPResponse {
609 let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: localAPIVersion) 602 let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: LocalAPIContract.version)
610 guard let body = try? encoder.encode(envelope) else { 603 guard let body = try? encoder.encode(envelope) else {
611 return .error(statusCode: 500, code: "encoding_failed", message: "Could not encode the Local API response.") 604 return .error(statusCode: 500, code: "encoding_failed", message: "Could not encode the Local API response.")
612 } 605 }
@@ -836,12 +829,10 @@ private struct LocalAPIHTTPResponse {
836 success: false, 829 success: false,
837 data: nil, 830 data: nil,
838 error: LocalAPIErrorPayload(code: code, message: message), 831 error: LocalAPIErrorPayload(code: code, message: message),
839 version: localAPIVersion 832 version: LocalAPIContract.version
840 ) 833 )
841 834
842 let encoder = JSONEncoder() 835 let body = (try? LocalAPIContract.makeEncoder().encode(payload)) ?? Data()
843 encoder.outputFormatting = [.sortedKeys]
844 let body = (try? encoder.encode(payload)) ?? Data()
845 return LocalAPIHTTPResponse(statusCode: statusCode, body: body) 836 return LocalAPIHTTPResponse(statusCode: statusCode, body: body)
846 } 837 }
847 838
@@ -951,25 +942,25 @@ private enum LocalAPIHTTPParser {
951 } 942 }
952} 943}
953 944
954private struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable { 945struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable {
955 let success: Bool 946 let success: Bool
956 let data: DataPayload? 947 let data: DataPayload?
957 let error: LocalAPIErrorPayload? 948 let error: LocalAPIErrorPayload?
958 let version: String 949 let version: String
959} 950}
960 951
961private struct LocalAPIErrorPayload: Encodable { 952struct LocalAPIErrorPayload: Encodable {
962 let code: String 953 let code: String
963 let message: String 954 let message: String
964} 955}
965 956
966private struct EmptyPayload: Encodable {} 957struct EmptyPayload: Encodable {}
967 958
968private struct PortfolioPayload: Encodable { 959struct PortfolioPayload: Encodable {
969 let summary: PortfolioSummary 960 let summary: PortfolioSummary
970} 961}
971 962
972private struct PortfolioSummary: Encodable { 963struct PortfolioSummary: Encodable {
973 let totalDomains: Int 964 let totalDomains: Int
974 let healthyCount: Int 965 let healthyCount: Int
975 let warningCount: Int 966 let warningCount: Int
@@ -979,26 +970,26 @@ private struct PortfolioSummary: Encodable {
979 let unreachableCount: Int 970 let unreachableCount: Int
980} 971}
981 972
982private struct DomainListPayload: Encodable { 973struct DomainListPayload: Encodable {
983 let domains: [TrackedDomain] 974 let domains: [TrackedDomain]
984} 975}
985 976
986private struct DomainDetailPayload: Encodable { 977struct DomainDetailPayload: Encodable {
987 let domain: String 978 let domain: String
988 let trackedDomain: TrackedDomain? 979 let trackedDomain: TrackedDomain?
989 let latestReport: DomainReport? 980 let latestReport: DomainReport?
990} 981}
991 982
992private struct DomainHistoryPayload: Encodable { 983struct DomainHistoryPayload: Encodable {
993 let domain: String 984 let domain: String
994 let history: [HistoryEntry] 985 let history: [HistoryEntry]
995} 986}
996 987
997private struct RecentEventsPayload: Encodable { 988struct RecentEventsPayload: Encodable {
998 let events: [RecentEventPayload] 989 let events: [RecentEventPayload]
999} 990}
1000 991
1001private struct RecentEventPayload: Encodable { 992struct RecentEventPayload: Encodable {
1002 let timestamp: Date 993 let timestamp: Date
1003 let domain: String 994 let domain: String
1004 let summary: String 995 let summary: String
@@ -1006,14 +997,14 @@ private struct RecentEventPayload: Encodable {
1006 let severity: String 997 let severity: String
1007} 998}
1008 999
1009private struct MonitoringPayload: Encodable { 1000struct MonitoringPayload: Encodable {
1010 let isEnabled: Bool 1001 let isEnabled: Bool
1011 let scope: MonitoringScope 1002 let scope: MonitoringScope
1012 let alertsEnabled: Bool 1003 let alertsEnabled: Bool
1013 let monitoredDomains: [MonitoringDomainPayload] 1004 let monitoredDomains: [MonitoringDomainPayload]
1014} 1005}
1015 1006
1016private struct MonitoringDomainPayload: Encodable { 1007struct MonitoringDomainPayload: Encodable {
1017 let domain: String 1008 let domain: String
1018 let monitoringEnabled: Bool 1009 let monitoringEnabled: Bool
1019 let lastMonitoredAt: Date? 1010 let lastMonitoredAt: Date?
@@ -1021,15 +1012,15 @@ private struct MonitoringDomainPayload: Encodable {
1021 let certificateWarningLevel: CertificateWarningLevel 1012 let certificateWarningLevel: CertificateWarningLevel
1022} 1013}
1023 1014
1024private struct MonitoringMutationPayload: Encodable { 1015struct MonitoringMutationPayload: Encodable {
1025 let domain: String 1016 let domain: String
1026 let monitoringEnabled: Bool 1017 let monitoringEnabled: Bool
1027} 1018}
1028 1019
1029private struct InspectRequestPayload: Decodable { 1020struct InspectRequestPayload: Decodable {
1030 let domain: String 1021 let domain: String
1031} 1022}
1032 1023
1033private struct InspectResponsePayload: Encodable { 1024struct InspectResponsePayload: Encodable {
1034 let report: DomainReport 1025 let report: DomainReport
1035} 1026}
README.md +1 −1
@@ -51,7 +51,7 @@ Network inspection requests are made only to perform the requested domain checks
51xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build 51xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build
52``` 52```
53 53
54The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`. 54The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`. The Local API's endpoints, response envelope, and `v1` compatibility policy are documented in [Docs/local-api.md](Docs/local-api.md).
55 55
56### Accessibility Audit 56### Accessibility Audit
57 57