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 | 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 @@ | |||
| 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 | import Observation | 3 | import Observation |
| 4 | import Security | 4 | import Security |
| 5 | 5 | ||
| 6 | private let localAPIVersion = "v1" | ||
| 7 | |||
| 8 | private enum LocalAPIServerError: LocalizedError { | 6 | private 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 | ||
| 510 | private struct LocalAPIRequestHandler { | 508 | private 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 | ||
| 954 | private struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable { | 945 | struct 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 | ||
| 961 | private struct LocalAPIErrorPayload: Encodable { | 952 | struct LocalAPIErrorPayload: Encodable { |
| 962 | let code: String | 953 | let code: String |
| 963 | let message: String | 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 | let summary: PortfolioSummary | 960 | let summary: PortfolioSummary |
| 970 | } | 961 | } |
| 971 | 962 | ||
| 972 | private struct PortfolioSummary: Encodable { | 963 | struct 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 | ||
| 982 | private struct DomainListPayload: Encodable { | 973 | struct DomainListPayload: Encodable { |
| 983 | let domains: [TrackedDomain] | 974 | let domains: [TrackedDomain] |
| 984 | } | 975 | } |
| 985 | 976 | ||
| 986 | private struct DomainDetailPayload: Encodable { | 977 | struct 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 | ||
| 992 | private struct DomainHistoryPayload: Encodable { | 983 | struct DomainHistoryPayload: Encodable { |
| 993 | let domain: String | 984 | let domain: String |
| 994 | let history: [HistoryEntry] | 985 | let history: [HistoryEntry] |
| 995 | } | 986 | } |
| 996 | 987 | ||
| 997 | private struct RecentEventsPayload: Encodable { | 988 | struct RecentEventsPayload: Encodable { |
| 998 | let events: [RecentEventPayload] | 989 | let events: [RecentEventPayload] |
| 999 | } | 990 | } |
| 1000 | 991 | ||
| 1001 | private struct RecentEventPayload: Encodable { | 992 | struct 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 | ||
| 1009 | private struct MonitoringPayload: Encodable { | 1000 | struct 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 | ||
| 1016 | private struct MonitoringDomainPayload: Encodable { | 1007 | struct 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 | ||
| 1024 | private struct MonitoringMutationPayload: Encodable { | 1015 | struct MonitoringMutationPayload: Encodable { |
| 1025 | let domain: String | 1016 | let domain: String |
| 1026 | let monitoringEnabled: Bool | 1017 | let monitoringEnabled: Bool |
| 1027 | } | 1018 | } |
| 1028 | 1019 | ||
| 1029 | private struct InspectRequestPayload: Decodable { | 1020 | struct InspectRequestPayload: Decodable { |
| 1030 | let domain: String | 1021 | let domain: String |
| 1031 | } | 1022 | } |
| 1032 | 1023 | ||
| 1033 | private struct InspectResponsePayload: Encodable { | 1024 | struct 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 | |||
| 51 | xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build | 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 | ### Accessibility Audit | 56 | ### Accessibility Audit |
| 57 | 57 | ||