Commit a52dee116d
Unsigned
Layout: unified · split
Docs/local-api.md added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | # DomainDig Local API — `v1` | |
| 2 | ||
| 3 | The Local API exposes DomainDig's canonical report data to on-device automation | |
| 4 | (Shortcuts, scripts, integrations). It is **off by default** and, when enabled, | |
| 5 | binds 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 | ||
| 13 | This 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 | |
| 16 | and the JSON encoder. | |
| 17 | ||
| 18 | ## Authentication | |
| 19 | ||
| 20 | Every request requires the token shown in Settings → Local API, supplied either | |
| 21 | way: | |
| 22 | ||
| 23 | ``` | |
| 24 | Authorization: Bearer <token> | |
| 25 | ``` | |
| 26 | ``` | |
| 27 | X-API-Token: <token> | |
| 28 | ``` | |
| 29 | ||
| 30 | A 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 | ||
| 35 | Every 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 | |
| 82 | export produces); see `DomainReportBuilder.swift` for its fields. It is a large | |
| 83 | object and is treated as an additive contract: new fields may appear without a | |
| 84 | version 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 | ||
| 101 | The `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 | ||
| 111 | There are currently no deprecated fields or endpoints. When a field is | |
| 112 | deprecated, it will be listed here with the version in which it becomes eligible | |
| 113 | for removal, and will remain present for at least one subsequent version. | |
DomainDig.xcodeproj/project.pbxproj +8
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | objects = { |
| 8 | 8 | |
| 9 | 9 | /* Begin PBXBuildFile section */ |
| 10 | 05F775C7E0743AF727B008EE /* LocalAPIContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */; }; | |
| 10 | 11 | 38316D90539394C2CC7C12BE /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */; }; |
| 11 | 12 | 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; }; |
| 12 | 13 | 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; }; |
| @@ -22,6 +23,7 @@ | ||
| 22 | 23 | A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; }; |
| 23 | 24 | C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; }; |
| 24 | 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 | 27 | /* End PBXBuildFile section */ |
| 26 | 28 | |
| 27 | 29 | /* Begin PBXContainerItemProxy section */ |
| @@ -71,9 +73,11 @@ | ||
| 71 | 73 | /* End PBXCopyFilesBuildPhase section */ |
| 72 | 74 | |
| 73 | 75 | /* Begin PBXFileReference section */ |
| 76 | 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContractTests.swift; sourceTree = "<group>"; }; | |
| 74 | 77 | 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = "<group>"; }; |
| 75 | 78 | 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = "<group>"; }; |
| 76 | 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 | 81 | 8B6472EF300EBFA30018E10A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; }; |
| 78 | 82 | 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 79 | 83 | 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; |
| @@ -224,6 +228,7 @@ | ||
| 224 | 228 | E82320955416797CFE59464A /* DomainReportBuilderTests.swift */, |
| 225 | 229 | 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */, |
| 226 | 230 | CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */, |
| 231 | 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */, | |
| 227 | 232 | ); |
| 228 | 233 | name = DomainDigTests; |
| 229 | 234 | path = DomainDigTests; |
| @@ -248,6 +253,7 @@ | ||
| 248 | 253 | 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */, |
| 249 | 254 | 12026CD045DFB45E9E37D207 /* Frameworks */, |
| 250 | 255 | 7CC29A88387286DE8B4D17B3 /* DomainDigTests */, |
| 256 | 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */, | |
| 251 | 257 | ); |
| 252 | 258 | sourceTree = "<group>"; |
| 253 | 259 | }; |
| @@ -480,6 +486,7 @@ | ||
| 480 | 486 | 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */, |
| 481 | 487 | C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */, |
| 482 | 488 | A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */, |
| 489 | EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */, | |
| 483 | 490 | ); |
| 484 | 491 | runOnlyForDeploymentPostprocessing = 0; |
| 485 | 492 | }; |
| @@ -494,6 +501,7 @@ | ||
| 494 | 501 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, |
| 495 | 502 | 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */, |
| 496 | 503 | 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */, |
| 504 | 05F775C7E0743AF727B008EE /* LocalAPIContract.swift in Sources */, | |
| 497 | 505 | ); |
| 498 | 506 | runOnlyForDeploymentPostprocessing = 0; |
| 499 | 507 | }; |
DomainDigTests/LocalAPIContractTests.swift added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | import 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. | |
| 9 | final 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 @@ | ||
| 1 | import 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. | |
| 25 | enum 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 | ||
| 3 | 3 | import Observation |
| 4 | 4 | import Security |
| 5 | 5 | |
| 6 | private let localAPIVersion = "v1" | |
| 7 | ||
| 8 | 6 | private enum LocalAPIServerError: LocalizedError { |
| 9 | 7 | case missingSecret |
| 10 | 8 | case secretPersistenceFailed |
| @@ -508,12 +506,7 @@ private final class LocalAPIServer: @unchecked Sendable { | ||
| 508 | 506 | } |
| 509 | 507 | |
| 510 | 508 | private struct LocalAPIRequestHandler { |
| 511 | private let encoder: JSONEncoder = { | |
| 512 | let encoder = JSONEncoder() | |
| 513 | encoder.dateEncodingStrategy = .iso8601 | |
| 514 | encoder.outputFormatting = [.sortedKeys] | |
| 515 | return encoder | |
| 516 | }() | |
| 509 | private let encoder = LocalAPIContract.makeEncoder() | |
| 517 | 510 | |
| 518 | 511 | private let decoder: JSONDecoder = { |
| 519 | 512 | let decoder = JSONDecoder() |
| @@ -606,7 +599,7 @@ private struct LocalAPIRequestHandler { | ||
| 606 | 599 | } |
| 607 | 600 | |
| 608 | 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 | 603 | guard let body = try? encoder.encode(envelope) else { |
| 611 | 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 | 829 | success: false, |
| 837 | 830 | data: nil, |
| 838 | 831 | error: LocalAPIErrorPayload(code: code, message: message), |
| 839 | version: localAPIVersion | |
| 832 | version: LocalAPIContract.version | |
| 840 | 833 | ) |
| 841 | 834 | |
| 842 | let encoder = JSONEncoder() | |
| 843 | encoder.outputFormatting = [.sortedKeys] | |
| 844 | let body = (try? encoder.encode(payload)) ?? Data() | |
| 835 | let body = (try? LocalAPIContract.makeEncoder().encode(payload)) ?? Data() | |
| 845 | 836 | return LocalAPIHTTPResponse(statusCode: statusCode, body: body) |
| 846 | 837 | } |
| 847 | 838 | |
| @@ -951,25 +942,25 @@ private enum LocalAPIHTTPParser { | ||
| 951 | 942 | } |
| 952 | 943 | } |
| 953 | 944 | |
| 954 | private struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable { | |
| 945 | struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable { | |
| 955 | 946 | let success: Bool |
| 956 | 947 | let data: DataPayload? |
| 957 | 948 | let error: LocalAPIErrorPayload? |
| 958 | 949 | let version: String |
| 959 | 950 | } |
| 960 | 951 | |
| 961 | private struct LocalAPIErrorPayload: Encodable { | |
| 952 | struct LocalAPIErrorPayload: Encodable { | |
| 962 | 953 | let code: String |
| 963 | 954 | let message: String |
| 964 | 955 | } |
| 965 | 956 | |
| 966 | private struct EmptyPayload: Encodable {} | |
| 957 | struct EmptyPayload: Encodable {} | |
| 967 | 958 | |
| 968 | private struct PortfolioPayload: Encodable { | |
| 959 | struct PortfolioPayload: Encodable { | |
| 969 | 960 | let summary: PortfolioSummary |
| 970 | 961 | } |
| 971 | 962 | |
| 972 | private struct PortfolioSummary: Encodable { | |
| 963 | struct PortfolioSummary: Encodable { | |
| 973 | 964 | let totalDomains: Int |
| 974 | 965 | let healthyCount: Int |
| 975 | 966 | let warningCount: Int |
| @@ -979,26 +970,26 @@ private struct PortfolioSummary: Encodable { | ||
| 979 | 970 | let unreachableCount: Int |
| 980 | 971 | } |
| 981 | 972 | |
| 982 | private struct DomainListPayload: Encodable { | |
| 973 | struct DomainListPayload: Encodable { | |
| 983 | 974 | let domains: [TrackedDomain] |
| 984 | 975 | } |
| 985 | 976 | |
| 986 | private struct DomainDetailPayload: Encodable { | |
| 977 | struct DomainDetailPayload: Encodable { | |
| 987 | 978 | let domain: String |
| 988 | 979 | let trackedDomain: TrackedDomain? |
| 989 | 980 | let latestReport: DomainReport? |
| 990 | 981 | } |
| 991 | 982 | |
| 992 | private struct DomainHistoryPayload: Encodable { | |
| 983 | struct DomainHistoryPayload: Encodable { | |
| 993 | 984 | let domain: String |
| 994 | 985 | let history: [HistoryEntry] |
| 995 | 986 | } |
| 996 | 987 | |
| 997 | private struct RecentEventsPayload: Encodable { | |
| 988 | struct RecentEventsPayload: Encodable { | |
| 998 | 989 | let events: [RecentEventPayload] |
| 999 | 990 | } |
| 1000 | 991 | |
| 1001 | private struct RecentEventPayload: Encodable { | |
| 992 | struct RecentEventPayload: Encodable { | |
| 1002 | 993 | let timestamp: Date |
| 1003 | 994 | let domain: String |
| 1004 | 995 | let summary: String |
| @@ -1006,14 +997,14 @@ private struct RecentEventPayload: Encodable { | ||
| 1006 | 997 | let severity: String |
| 1007 | 998 | } |
| 1008 | 999 | |
| 1009 | private struct MonitoringPayload: Encodable { | |
| 1000 | struct MonitoringPayload: Encodable { | |
| 1010 | 1001 | let isEnabled: Bool |
| 1011 | 1002 | let scope: MonitoringScope |
| 1012 | 1003 | let alertsEnabled: Bool |
| 1013 | 1004 | let monitoredDomains: [MonitoringDomainPayload] |
| 1014 | 1005 | } |
| 1015 | 1006 | |
| 1016 | private struct MonitoringDomainPayload: Encodable { | |
| 1007 | struct MonitoringDomainPayload: Encodable { | |
| 1017 | 1008 | let domain: String |
| 1018 | 1009 | let monitoringEnabled: Bool |
| 1019 | 1010 | let lastMonitoredAt: Date? |
| @@ -1021,15 +1012,15 @@ private struct MonitoringDomainPayload: Encodable { | ||
| 1021 | 1012 | let certificateWarningLevel: CertificateWarningLevel |
| 1022 | 1013 | } |
| 1023 | 1014 | |
| 1024 | private struct MonitoringMutationPayload: Encodable { | |
| 1015 | struct MonitoringMutationPayload: Encodable { | |
| 1025 | 1016 | let domain: String |
| 1026 | 1017 | let monitoringEnabled: Bool |
| 1027 | 1018 | } |
| 1028 | 1019 | |
| 1029 | private struct InspectRequestPayload: Decodable { | |
| 1020 | struct InspectRequestPayload: Decodable { | |
| 1030 | 1021 | let domain: String |
| 1031 | 1022 | } |
| 1032 | 1023 | |
| 1033 | private struct InspectResponsePayload: Encodable { | |
| 1024 | struct InspectResponsePayload: Encodable { | |
| 1034 | 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 | ||
| 51 | 51 | xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build |
| 52 | 52 | ``` |
| 53 | 53 | |
| 54 | The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`. | |
| 54 | The 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 | 56 | ### Accessibility Audit |
| 57 | 57 | |