aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-24 23:14:23 -0500
committerChristian Cleberg <[email protected]>2026-07-24 23:29:01 -0500
commita52dee116d4066d1b59bd90b4ebc4def4e1597d6 (patch)
tree77f852ffea7f6940710a532190225673275b9279
parent7001577e6798530c943ac4cf03258bc172927a67 (diff)
downloaddomain-dig-a52dee116d4066d1b59bd90b4ebc4def4e1597d6.tar.gz
domain-dig-a52dee116d4066d1b59bd90b4ebc4def4e1597d6.tar.bz2
domain-dig-a52dee116d4066d1b59bd90b4ebc4def4e1597d6.zip
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.
-rw-r--r--Docs/local-api.md113
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDigTests/LocalAPIContractTests.swift179
-rw-r--r--LocalAPIContract.swift39
-rw-r--r--LocalAPIService.swift47
-rw-r--r--README.md2
6 files changed, 359 insertions, 29 deletions
diff --git a/Docs/local-api.md b/Docs/local-api.md
new file mode 100644
index 0000000..a0cebdc
--- /dev/null
+++ b/Docs/local-api.md
@@ -0,0 +1,113 @@
+# DomainDig Local API — `v1`
+
+The Local API exposes DomainDig's canonical report data to on-device automation
+(Shortcuts, scripts, integrations). It is **off by default** and, when enabled,
+binds only to loopback.
+
+- **Base URL:** `http://127.0.0.1:<port>` (default port `47821`, configurable in
+ Settings → Local API)
+- **Binding:** loopback only (`acceptLocalOnly`); never reachable off-device
+- **Content type:** every response is `application/json`
+- **Version:** `v1` (reported in every response envelope)
+
+This document is the stable contract. The response shape is pinned by
+`DomainDigTests/LocalAPIContractTests.swift`; `LocalAPIContract` (in
+`LocalAPIContract.swift`) is the single source of truth for the version string
+and the JSON encoder.
+
+## Authentication
+
+Every request requires the token shown in Settings → Local API, supplied either
+way:
+
+```
+Authorization: Bearer <token>
+```
+```
+X-API-Token: <token>
+```
+
+A missing or wrong token returns `401 unauthorized`. Settings → Local API has a
+**Copy cURL Command** button that emits a ready-to-run authenticated request.
+
+## Response envelope
+
+Every response — success or error — is wrapped in the same envelope:
+
+```json
+{
+ "success": true,
+ "version": "v1",
+ "data": { "...": "payload, present on success" }
+}
+```
+```json
+{
+ "success": false,
+ "version": "v1",
+ "error": { "code": "not_found", "message": "The requested Local API route does not exist." }
+}
+```
+
+- On success, `data` holds the endpoint payload and `error` is **omitted**.
+- On failure, `error` holds a machine `code` plus a human `message`, and `data`
+ is **omitted**.
+
+### Encoding conventions
+
+- **Dates** are ISO-8601 UTC strings, e.g. `"2023-11-14T22:13:20Z"`.
+- **Absent optional fields are omitted, not `null`.** Consumers must treat a
+ missing key as "not present."
+- Object keys are emitted in sorted order (deterministic output; not
+ contractually meaningful — do not depend on key order).
+
+## Endpoints
+
+| Method | Path | Payload (`data`) fields |
+|--------|------|-------------------------|
+| GET | `/portfolio` | `summary` → `{ totalDomains, healthyCount, warningCount, criticalCount, changedLast24h, expiringSoonCount, unreachableCount }` |
+| GET | `/domains` | `domains: [TrackedDomain]` |
+| GET | `/domains/{domain}` | `domain`, `trackedDomain?` (`TrackedDomain`), `latestReport?` (`DomainReport`) |
+| GET | `/domains/{domain}/history` | `domain`, `history: [HistoryEntry]` |
+| GET | `/events` | `events: [{ timestamp, domain, summary, status, severity }]` |
+| GET | `/monitoring` | `isEnabled`, `scope` (`"allTracked"` \| `"selectedOnly"`), `alertsEnabled`, `monitoredDomains: [{ domain, monitoringEnabled, lastMonitoredAt?, lastAlertAt?, certificateWarningLevel }]` |
+| POST | `/inspect` | body `{ "domain": "example.com" }` → `report` (`DomainReport`) |
+| POST | `/inspect/{domain}` | `report` (`DomainReport`) |
+| POST | `/monitoring/{domain}/enable` | `domain`, `monitoringEnabled` |
+| POST | `/monitoring/{domain}/disable` | `domain`, `monitoringEnabled` |
+
+`certificateWarningLevel` encodes as `"none"`, `"warning"`, or `"critical"`.
+
+`DomainReport` is the app's canonical report model (the same shape the JSON
+export produces); see `DomainReportBuilder.swift` for its fields. It is a large
+object and is treated as an additive contract: new fields may appear without a
+version bump.
+
+## Error codes
+
+| HTTP | `code` | When |
+|------|--------|------|
+| 400 | `bad_request` | The HTTP request line/path could not be parsed |
+| 400 | `invalid_body` | `POST /inspect` body was not `{ "domain": "…" }` |
+| 400 | `invalid_domain` | A path/body domain was empty or invalid |
+| 401 | `unauthorized` | Missing or incorrect token |
+| 404 | `not_found` | No such route |
+| 404 | `domain_not_found` | No local data / tracked domain for the given name |
+| 500 | `encoding_failed` | The response could not be encoded |
+| 500 | `internal_error` | The request handler failed unexpectedly |
+
+## Compatibility policy
+
+The `version` field follows a semantic-version-style promise:
+
+- **Backward-compatible changes keep `version` at `v1`.** Adding a new endpoint,
+ or adding a new field to an existing payload, is non-breaking. **Consumers
+ must ignore unknown fields.**
+- **Breaking changes bump `version`.** Renaming or removing a field, changing a
+ field's type, or changing the meaning/units of an existing field requires a new
+ version, an update to this document, and an update to
+ `LocalAPIContractTests.swift`.
+
+There are currently no deprecated fields or endpoints. When a field is
+deprecated, it will be listed here with the version in which it becomes eligible
+for removal, and will remain present for at least one subsequent version.
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 37e5bea..e65c63a 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
+ 05F775C7E0743AF727B008EE /* LocalAPIContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */; };
38316D90539394C2CC7C12BE /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */; };
47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; };
81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; };
@@ -22,6 +23,7 @@
A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */; };
C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */; };
E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */; };
+ EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -71,9 +73,11 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContractTests.swift; sourceTree = "<group>"; };
15A25DF2B8BB52589D49986B /* DiffServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiffServiceTests.swift; sourceTree = "<group>"; };
54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportExporterTests.swift; sourceTree = "<group>"; };
5CA789D3607B55E3612E6FFA /* SnapshotFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotFixture.swift; sourceTree = "<group>"; };
+ 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAPIContract.swift; sourceTree = "<group>"; };
8B6472EF300EBFA30018E10A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
@@ -224,6 +228,7 @@
E82320955416797CFE59464A /* DomainReportBuilderTests.swift */,
54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */,
CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */,
+ 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */,
);
name = DomainDigTests;
path = DomainDigTests;
@@ -248,6 +253,7 @@
8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */,
12026CD045DFB45E9E37D207 /* Frameworks */,
7CC29A88387286DE8B4D17B3 /* DomainDigTests */,
+ 7572DA0A838E0A044F045260 /* LocalAPIContract.swift */,
);
sourceTree = "<group>";
};
@@ -480,6 +486,7 @@
47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */,
C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */,
A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */,
+ EA12012CDB4C21ABB4217D86 /* LocalAPIContractTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -494,6 +501,7 @@
8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */,
+ 05F775C7E0743AF727B008EE /* LocalAPIContract.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/DomainDigTests/LocalAPIContractTests.swift b/DomainDigTests/LocalAPIContractTests.swift
new file mode 100644
index 0000000..0e6515e
--- /dev/null
+++ b/DomainDigTests/LocalAPIContractTests.swift
@@ -0,0 +1,179 @@
+import XCTest
+@testable import DomainDig
+
+/// Locks the Local API wire contract: the envelope shape, each payload's field
+/// names, and the encoding conventions (`v1`, ISO-8601 dates, nil omission).
+/// A rename or removed field fails here instead of silently breaking an external
+/// consumer. Values are deliberately not asserted — only structure — so ordinary
+/// behavior changes don't churn these tests.
+final class LocalAPIContractTests: XCTestCase {
+ private let encoder = LocalAPIContract.makeEncoder()
+
+ // MARK: - Helpers
+
+ private func json<T: Encodable>(_ value: T) throws -> [String: Any] {
+ let data = try encoder.encode(value)
+ let object = try JSONSerialization.jsonObject(with: data)
+ return try XCTUnwrap(object as? [String: Any])
+ }
+
+ private func keys<T: Encodable>(_ value: T) throws -> Set<String> {
+ Set(try json(value).keys)
+ }
+
+ // MARK: - Envelope
+
+ func testVersionIsV1() {
+ XCTAssertEqual(LocalAPIContract.version, "v1")
+ }
+
+ func testSuccessEnvelopeOmitsErrorAndReportsVersion() throws {
+ let envelope = LocalAPIEnvelope(
+ success: true,
+ data: PortfolioPayload(summary: Self.sampleSummary),
+ error: nil,
+ version: LocalAPIContract.version
+ )
+ let object = try json(envelope)
+
+ XCTAssertEqual(Set(object.keys), ["success", "data", "version"], "nil error must be omitted from the envelope")
+ XCTAssertEqual(object["success"] as? Bool, true)
+ XCTAssertEqual(object["version"] as? String, "v1")
+ }
+
+ func testErrorEnvelopeOmitsDataAndCarriesCodeAndMessage() throws {
+ let envelope = LocalAPIEnvelope<EmptyPayload>(
+ success: false,
+ data: nil,
+ error: LocalAPIErrorPayload(code: "not_found", message: "The requested Local API route does not exist."),
+ version: LocalAPIContract.version
+ )
+ let object = try json(envelope)
+
+ XCTAssertEqual(Set(object.keys), ["success", "error", "version"], "nil data must be omitted from the envelope")
+ XCTAssertEqual(object["success"] as? Bool, false)
+ let error = try XCTUnwrap(object["error"] as? [String: Any])
+ XCTAssertEqual(Set(error.keys), ["code", "message"])
+ XCTAssertEqual(error["code"] as? String, "not_found")
+ }
+
+ // MARK: - Payload field names
+
+ func testPortfolioSummaryFields() throws {
+ XCTAssertEqual(
+ try keys(Self.sampleSummary),
+ ["totalDomains", "healthyCount", "warningCount", "criticalCount",
+ "changedLast24h", "expiringSoonCount", "unreachableCount"]
+ )
+ }
+
+ func testPortfolioPayloadFields() throws {
+ XCTAssertEqual(try keys(PortfolioPayload(summary: Self.sampleSummary)), ["summary"])
+ }
+
+ func testDomainListPayloadFields() throws {
+ let payload = DomainListPayload(domains: [TrackedDomain(domain: "example.com")])
+ XCTAssertEqual(try keys(payload), ["domains"])
+ }
+
+ func testDomainDetailPayloadFieldsWhenPopulated() throws {
+ let payload = DomainDetailPayload(
+ domain: "example.com",
+ trackedDomain: TrackedDomain(domain: "example.com"),
+ latestReport: SnapshotFixture.report(domain: "example.com")
+ )
+ XCTAssertEqual(try keys(payload), ["domain", "trackedDomain", "latestReport"])
+ }
+
+ func testDomainDetailPayloadOmitsAbsentOptionals() throws {
+ let payload = DomainDetailPayload(domain: "example.com", trackedDomain: nil, latestReport: nil)
+ XCTAssertEqual(try keys(payload), ["domain"], "absent trackedDomain/latestReport are omitted, not null")
+ }
+
+ func testDomainHistoryPayloadFields() throws {
+ let payload = DomainHistoryPayload(domain: "example.com", history: [])
+ XCTAssertEqual(try keys(payload), ["domain", "history"])
+ }
+
+ func testRecentEventPayloadFields() throws {
+ XCTAssertEqual(try keys(Self.sampleEvent), ["timestamp", "domain", "summary", "status", "severity"])
+ }
+
+ func testRecentEventsPayloadFields() throws {
+ XCTAssertEqual(try keys(RecentEventsPayload(events: [Self.sampleEvent])), ["events"])
+ }
+
+ func testMonitoringPayloadFieldsAndEnumEncoding() throws {
+ let payload = MonitoringPayload(
+ isEnabled: true,
+ scope: .allTracked,
+ alertsEnabled: true,
+ monitoredDomains: [Self.sampleMonitoringDomain]
+ )
+ let object = try json(payload)
+ XCTAssertEqual(Set(object.keys), ["isEnabled", "scope", "alertsEnabled", "monitoredDomains"])
+ XCTAssertEqual(object["scope"] as? String, "allTracked", "MonitoringScope encodes as its String raw value")
+ }
+
+ func testMonitoringDomainPayloadFieldsAndEnumEncoding() throws {
+ let object = try json(Self.sampleMonitoringDomain)
+ XCTAssertEqual(
+ Set(object.keys),
+ ["domain", "monitoringEnabled", "lastMonitoredAt", "lastAlertAt", "certificateWarningLevel"]
+ )
+ XCTAssertEqual(object["certificateWarningLevel"] as? String, "none")
+ }
+
+ func testMonitoringMutationPayloadFields() throws {
+ XCTAssertEqual(
+ try keys(MonitoringMutationPayload(domain: "example.com", monitoringEnabled: true)),
+ ["domain", "monitoringEnabled"]
+ )
+ }
+
+ func testInspectResponsePayloadFields() throws {
+ XCTAssertEqual(try keys(InspectResponsePayload(report: SnapshotFixture.report())), ["report"])
+ }
+
+ // MARK: - Encoding conventions
+
+ func testDatesEncodeAsISO8601() throws {
+ let event = RecentEventPayload(
+ timestamp: Date(timeIntervalSince1970: 1_700_000_000),
+ domain: "example.com",
+ summary: "changed",
+ status: "changed",
+ severity: "medium"
+ )
+ let object = try json(event)
+ XCTAssertEqual(object["timestamp"] as? String, "2023-11-14T22:13:20Z")
+ }
+
+ // MARK: - Fixtures
+
+ private static let sampleSummary = PortfolioSummary(
+ totalDomains: 3,
+ healthyCount: 1,
+ warningCount: 1,
+ criticalCount: 1,
+ changedLast24h: 2,
+ expiringSoonCount: 1,
+ unreachableCount: 0
+ )
+
+ private static let sampleEvent = RecentEventPayload(
+ timestamp: Date(timeIntervalSince1970: 1_700_000_000),
+ domain: "example.com",
+ summary: "Certificate is approaching expiry",
+ status: "changed",
+ severity: "medium"
+ )
+
+ private static let sampleMonitoringDomain = MonitoringDomainPayload(
+ domain: "example.com",
+ monitoringEnabled: true,
+ lastMonitoredAt: Date(timeIntervalSince1970: 1_700_000_000),
+ lastAlertAt: Date(timeIntervalSince1970: 1_700_000_000),
+ certificateWarningLevel: .none
+ )
+}
diff --git a/LocalAPIContract.swift b/LocalAPIContract.swift
new file mode 100644
index 0000000..f5f2463
--- /dev/null
+++ b/LocalAPIContract.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+/// The versioned wire contract for the Local API.
+///
+/// External consumers — Shortcuts, scripts, and third-party integrations — depend
+/// on the JSON this API produces: the response envelope, the field names of every
+/// payload, and the encoding conventions. This type is the single source of truth
+/// for the parts that must stay stable, and `LocalAPIContractTests` pins them so
+/// an accidental rename or shape change fails CI instead of silently breaking a
+/// consumer.
+///
+/// ## Compatibility policy
+///
+/// The `version` string reported in every envelope follows a semantic-version-style
+/// promise:
+///
+/// - **Backward-compatible** changes keep `version` at `"v1"`: adding a new
+/// endpoint, or adding a new field to a payload. Consumers must ignore unknown
+/// fields, so additions never require a bump.
+/// - **Breaking** changes require bumping `version` (and updating `Docs/local-api.md`
+/// plus the contract tests): renaming or removing a field, changing a field's
+/// type, or changing the meaning/units of an existing field.
+///
+/// See `Docs/local-api.md` for the full endpoint and schema reference.
+enum LocalAPIContract {
+ /// Wire-format version reported in every envelope's `version` field.
+ static let version = "v1"
+
+ /// The canonical encoder for every Local API response. ISO-8601 dates and
+ /// sorted keys keep the output deterministic, which is what lets the contract
+ /// tests pin the shape. Both the success and error paths route through this so
+ /// the wire format can never drift between them.
+ static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+}
diff --git a/LocalAPIService.swift b/LocalAPIService.swift
index 0a5f8a2..0ce7033 100644
--- a/LocalAPIService.swift
+++ b/LocalAPIService.swift
@@ -3,8 +3,6 @@ import Network
import Observation
import Security
-private let localAPIVersion = "v1"
-
private enum LocalAPIServerError: LocalizedError {
case missingSecret
case secretPersistenceFailed
@@ -508,12 +506,7 @@ private final class LocalAPIServer: @unchecked Sendable {
}
private struct LocalAPIRequestHandler {
- private let encoder: JSONEncoder = {
- let encoder = JSONEncoder()
- encoder.dateEncodingStrategy = .iso8601
- encoder.outputFormatting = [.sortedKeys]
- return encoder
- }()
+ private let encoder = LocalAPIContract.makeEncoder()
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
@@ -606,7 +599,7 @@ private struct LocalAPIRequestHandler {
}
private func successResponse<Value: Encodable>(_ value: Value) -> LocalAPIHTTPResponse {
- let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: localAPIVersion)
+ let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: LocalAPIContract.version)
guard let body = try? encoder.encode(envelope) else {
return .error(statusCode: 500, code: "encoding_failed", message: "Could not encode the Local API response.")
}
@@ -836,12 +829,10 @@ private struct LocalAPIHTTPResponse {
success: false,
data: nil,
error: LocalAPIErrorPayload(code: code, message: message),
- version: localAPIVersion
+ version: LocalAPIContract.version
)
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.sortedKeys]
- let body = (try? encoder.encode(payload)) ?? Data()
+ let body = (try? LocalAPIContract.makeEncoder().encode(payload)) ?? Data()
return LocalAPIHTTPResponse(statusCode: statusCode, body: body)
}
@@ -951,25 +942,25 @@ private enum LocalAPIHTTPParser {
}
}
-private struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable {
+struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable {
let success: Bool
let data: DataPayload?
let error: LocalAPIErrorPayload?
let version: String
}
-private struct LocalAPIErrorPayload: Encodable {
+struct LocalAPIErrorPayload: Encodable {
let code: String
let message: String
}
-private struct EmptyPayload: Encodable {}
+struct EmptyPayload: Encodable {}
-private struct PortfolioPayload: Encodable {
+struct PortfolioPayload: Encodable {
let summary: PortfolioSummary
}
-private struct PortfolioSummary: Encodable {
+struct PortfolioSummary: Encodable {
let totalDomains: Int
let healthyCount: Int
let warningCount: Int
@@ -979,26 +970,26 @@ private struct PortfolioSummary: Encodable {
let unreachableCount: Int
}
-private struct DomainListPayload: Encodable {
+struct DomainListPayload: Encodable {
let domains: [TrackedDomain]
}
-private struct DomainDetailPayload: Encodable {
+struct DomainDetailPayload: Encodable {
let domain: String
let trackedDomain: TrackedDomain?
let latestReport: DomainReport?
}
-private struct DomainHistoryPayload: Encodable {
+struct DomainHistoryPayload: Encodable {
let domain: String
let history: [HistoryEntry]
}
-private struct RecentEventsPayload: Encodable {
+struct RecentEventsPayload: Encodable {
let events: [RecentEventPayload]
}
-private struct RecentEventPayload: Encodable {
+struct RecentEventPayload: Encodable {
let timestamp: Date
let domain: String
let summary: String
@@ -1006,14 +997,14 @@ private struct RecentEventPayload: Encodable {
let severity: String
}
-private struct MonitoringPayload: Encodable {
+struct MonitoringPayload: Encodable {
let isEnabled: Bool
let scope: MonitoringScope
let alertsEnabled: Bool
let monitoredDomains: [MonitoringDomainPayload]
}
-private struct MonitoringDomainPayload: Encodable {
+struct MonitoringDomainPayload: Encodable {
let domain: String
let monitoringEnabled: Bool
let lastMonitoredAt: Date?
@@ -1021,15 +1012,15 @@ private struct MonitoringDomainPayload: Encodable {
let certificateWarningLevel: CertificateWarningLevel
}
-private struct MonitoringMutationPayload: Encodable {
+struct MonitoringMutationPayload: Encodable {
let domain: String
let monitoringEnabled: Bool
}
-private struct InspectRequestPayload: Decodable {
+struct InspectRequestPayload: Decodable {
let domain: String
}
-private struct InspectResponsePayload: Encodable {
+struct InspectResponsePayload: Encodable {
let report: DomainReport
}
diff --git a/README.md b/README.md
index 6d89ceb..3e61467 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ Network inspection requests are made only to perform the requested domain checks
xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build
```
-The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`.
+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).
### Accessibility Audit