diff --git a/.github/workflows/server-linux.yml b/.github/workflows/server-linux.yml new file mode 100644 index 0000000..10ec22a --- /dev/null +++ b/.github/workflows/server-linux.yml @@ -0,0 +1,45 @@ +name: Native Server Linux + +on: + pull_request: + paths: + - Package.swift + - Package.resolved + - Sources/MarkdownUtilitiesCore/** + - Sources/MarkdownUtilities/** + - Sources/MarkdownUtilitiesServer/** + - Sources/md-utils-server/** + - Tests/MarkdownUtilitiesServerTests/** + - Dockerfile.server-linux + - .github/workflows/server-linux.yml + push: + branches: + - main + paths: + - Package.swift + - Package.resolved + - Sources/MarkdownUtilitiesCore/** + - Sources/MarkdownUtilities/** + - Sources/MarkdownUtilitiesServer/** + - Sources/md-utils-server/** + - Tests/MarkdownUtilitiesServerTests/** + - Dockerfile.server-linux + - .github/workflows/server-linux.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + native-server: + runs-on: ubuntu-latest + container: swift:6.2-noble + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Build native server + run: swift build --product md-utils-server + + - name: Run native server route smoke test + run: swift run MarkdownUtilitiesServerLinuxSmoke diff --git a/AGENTS.md b/AGENTS.md index 210ed65..0ab2b4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,17 +12,17 @@ md-utils is a Swift package for parsing and manipulating Markdown files. It cons ## Project Brief - **Language**: Swift 6.2+ -- **Frameworks/Libraries**: Foundation, MarkdownSyntax, swift-parsing, PathKit, Yams, swift-toml, JMESPath, JSONSchema.swift, swift-argument-parser, Rainbow +- **Frameworks/Libraries**: Foundation, MarkdownSyntax, swift-parsing, PathKit, Yams, swift-toml, JMESPath, JSONSchema.swift, swift-argument-parser, Rainbow, Hummingbird 2, Swift Logging - **Package Manager / Build Tool**: Swift Package Manager -- **CLI Target**: `md-utils` +- **Executable Targets**: `md-utils`, `md-utils-server` - **Library Targets**: `MarkdownUtilitiesCore`, `MarkdownUtilities` - **Test Framework**: Swift Testing, not XCTest - **Build Command**: `swift build` -- **Test Command**: `swift test` +- **Test Command**: `swift test`; native Linux server route smoke test with `swift run MarkdownUtilitiesServerLinuxSmoke` - **Formatter/Linter**: No dedicated formatter or linter is configured in-package - **Documentation**: README.md, AGENTS.md, docs/*.md, generated CLI help, and bundled Agent Skill docs - **Security**: Avoid unsafe optional force unwraps; treat filesystem and YAML/TOML/JSON parsing failures as user-visible errors -- **CI/Coverage**: No project-specific CI or coverage command is documented in this repo +- **CI/Coverage**: Schema publication, Pages, WebAssembly, and native Linux server workflows are configured; local Linux server verification uses `Dockerfile.server-linux`; no coverage command is documented ## Requirements @@ -42,6 +42,9 @@ swift test # Build and test Core on Linux docker build --file Dockerfile.core-linux --tag md-utils-core-linux . +# Build and test the native server on Linux +docker build --file Dockerfile.server-linux --tag md-utils-server-linux . + # Run CLI swift run md-utils ``` diff --git a/Dockerfile.server-linux b/Dockerfile.server-linux new file mode 100644 index 0000000..a1e27fc --- /dev/null +++ b/Dockerfile.server-linux @@ -0,0 +1,11 @@ +FROM swift:6.2-noble + +WORKDIR /workspace/ + +COPY Package.swift Package.resolved ./ +RUN swift package resolve + +COPY . . + +RUN swift build --product md-utils-server +RUN swift run MarkdownUtilitiesServerLinuxSmoke diff --git a/IntegrationTests/LinuxServerSmoke/main.swift b/IntegrationTests/LinuxServerSmoke/main.swift new file mode 100644 index 0000000..72a409d --- /dev/null +++ b/IntegrationTests/LinuxServerSmoke/main.swift @@ -0,0 +1,75 @@ +import Foundation +import Hummingbird +import HummingbirdTesting +import MarkdownUtilitiesCore +import MarkdownUtilitiesServer + +enum LinuxServerSmokeError: Error { + case unexpectedStatus(HTTPResponse.Status) + case unexpectedRecord(GenericMarkdownRecord) +} + +@main +enum LinuxServerSmoke { + static func main() async throws { + #if os(macOS) + guard #available(macOS 14.0, *) else { return } + #endif + try await run() + } + + @available(macOS 14.0, *) + private static func run() async throws { + let typeRegistry = try MarkdownTypeRegistry(definitions: []) + let rule = MarkdownRuleDefinition( + name: "books", + applicability: MarkdownRuleApplicability(paths: ["books/**"]) + ) + let ruleRegistry = try MarkdownRuleCompiler(typeRegistry: typeRegistry).compile([rule]) + let plan = try EndpointPlanCompiler( + ruleRegistry: ruleRegistry, + typeRegistry: typeRegistry + ).compile(MarkdownServerConfiguration(resources: [ + MarkdownResourceConfiguration( + name: "books", + route: "/books", + operations: [.list, .get], + selection: .rule(name: rule.name), + identityPolicy: MarkdownRecordIdentityPolicy(source: .existingIdentity) + ) + ])) + let store = try InMemoryRecordStore(records: [ + MarkdownRecord( + identity: MarkdownRecordIdentity(rawValue: "dune"), + content: "# Book\nDune", + context: MarkdownRecordContext(path: try MarkdownRecordPath("books/dune.md")) + ) + ]) + let snapshot = try await MarkdownServerReadSnapshotBuilder( + store: store, + plan: plan, + ruleRegistry: ruleRegistry, + typeRegistry: typeRegistry + ).build() + let router = Router() + try MarkdownServerHTTPAdapter.register(plan: plan, snapshot: snapshot, on: router) + let app = Application(router: router) + + try await app.test(.router) { client in + try await client.execute(uri: "/books/dune", method: .get) { response in + guard response.status == .ok else { + throw LinuxServerSmokeError.unexpectedStatus(response.status) + } + let record = try JSONDecoder().decode( + GenericMarkdownRecord.self, + from: Data(response.body.readableBytesView) + ) + guard record.canonicalIdentity?.rawValue == "dune", + record.logicalPath?.rawValue == "books/dune.md" + else { + throw LinuxServerSmokeError.unexpectedRecord(record) + } + } + } + } +} diff --git a/Package.resolved b/Package.resolved index 3bb150b..476120f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "b958849452099a703c0fa144f682a70c8c022f42586f75f3b038e9cb85799aa4", + "originHash" : "b062f903fa31180e051fa77f7046b54f4cdc0e72cc442dee56a0d1a2062c4cfa", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "9544287b9416c0bc71e58b9f3aead8dd14b16103", + "version" : "1.36.0" + } + }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird.git", + "state" : { + "revision" : "55bc9025a4825ee2a234b1f82b51b87be6ef74e4", + "version" : "2.26.0" + } + }, { "identity" : "jmespath.swift", "kind" : "remoteSourceControl", @@ -55,6 +73,15 @@ "version" : "0.10.1" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -64,6 +91,33 @@ "version" : "1.7.0" } }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", @@ -73,6 +127,15 @@ "version" : "1.7.2" } }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "449dbbecd0f31e82b510ada227ca152caa8b5e98", + "version" : "1.19.4" + } + }, { "identity" : "swift-cmark", "kind" : "remoteSourceControl", @@ -82,6 +145,42 @@ "version" : "0.7.1" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", + "version" : "4.5.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", @@ -100,6 +199,96 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222", + "version" : "1.15.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "88a51340f59cf181ebde888bd1b749296b3ec029", + "version" : "1.34.3" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "45bdf670248be5f16ec0340e125dca285536f0fb", + "version" : "1.45.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "d930168b86f46ca51a4bc09c5ca45c1833db8067", + "version" : "2.37.2" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, { "identity" : "swift-parsing", "kind" : "remoteSourceControl", @@ -109,6 +298,24 @@ "version" : "0.14.1" } }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -118,6 +325,15 @@ "version" : "602.0.0" } }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + }, { "identity" : "swift-toml", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 6fdfd2b..6e2aed5 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,10 @@ let package = Package( name: "md-utils", targets: ["md-utils"] ), + .executable( + name: "md-utils-server", + targets: ["md-utils-server"] + ), ], dependencies: [ .package(url: "https://github.com/hebertialmeida/MarkdownSyntax", from: "1.3.0"), @@ -36,6 +40,8 @@ let package = Package( .package(url: "https://github.com/mattt/swift-toml.git", from: "2.0.0"), .package(url: "https://github.com/adam-fowler/jmespath.swift.git", from: "1.0.3"), .package(url: "https://github.com/onevcat/Rainbow", from: "4.2.1"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.26.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.15.0"), .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.4.0"), ], targets: [ @@ -89,13 +95,45 @@ let package = Package( // MARK: MarkdownUtilitiesServer .target( name: "MarkdownUtilitiesServer", - dependencies: ["MarkdownUtilitiesCore"] + dependencies: [ + "MarkdownUtilitiesCore", + "MarkdownUtilities", + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "JMESPath", package: "jmespath.swift"), + .product(name: "PathKit", package: "PathKit"), + "Yams", + ] ), .testTarget( name: "MarkdownUtilitiesServerTests", dependencies: [ "MarkdownUtilitiesCore", "MarkdownUtilitiesServer", + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HummingbirdTesting", package: "hummingbird"), + .product(name: "PathKit", package: "PathKit"), + ] + ), + .executableTarget( + name: "MarkdownUtilitiesServerLinuxSmoke", + dependencies: [ + "MarkdownUtilitiesCore", + "MarkdownUtilitiesServer", + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HummingbirdTesting", package: "hummingbird"), + ], + path: "IntegrationTests/LinuxServerSmoke/" + ), + + // MARK: md-utils-server (native HTTP server) + .executableTarget( + name: "md-utils-server", + dependencies: [ + "MarkdownUtilitiesServer", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "Logging", package: "swift-log"), + .product(name: "PathKit", package: "PathKit"), ] ), diff --git a/README.md b/README.md index 34801e6..9a67f85 100644 --- a/README.md +++ b/README.md @@ -415,11 +415,84 @@ The bundled CLI schema in `Sources/md-utils/Resources/0.2.0_md-utils.schema.json When the Pages workflow prepares its artifact, it copies `site/schemas/$CURRENT_MD_UTILS_JSONSCHEMA_VERSION/md-utils.schema.json` to both `md-utils.schema.json` at the site root and `schemas/latest/md-utils.schema.json`. Versioned schema URLs are immutable after release. For future schema releases, add a new versioned folder under `site/schemas/`, update `CURRENT_MD_UTILS_JSONSCHEMA_VERSION` in `.github/workflows/pages.yml`, and keep the canonical bundled schema synchronized with the new published copy. Do not edit already-published versioned schema files; publish a new version instead. +## Native Read-Only Server + +`md-utils-server` uses Hummingbird 2 to expose explicitly configured resources. It +loads `.md-utils/server.yaml`, rules from `.md-utils/md-utils.json`, mdtypes from +`.md-utils/types/`, and `.md` or `.markdown` records recursively beneath the project +root. Files inside `.md-utils/` are never imported as records. + +```yaml +serverConfigVersion: "1" +resources: + - name: books + route: /books + operations: [list, get] + selection: + mode: type + type: Book + searchRoot: books/ + identityPolicy: + source: frontmatter + path: [slug] + format: string + logicalPathFallbackEnabled: true +``` + +Identity `source` supports `existingIdentity`, `logicalPath`, and `frontmatter`. +Frontmatter identities require a nonempty `path` and a `format` of `string`, +`integer`, `uuid`, or `slug`; slug identities also require `slugPolicy` set to +`strictASCII`, `unicode`, or `preserve`. Projection defaults to `genericRecord`, +operation-ID overrides default to an empty list, and logical-path fallback defaults +to enabled. + +Start the server from the directory containing `.md-utils/`: + +```bash +swift run md-utils-server +swift run md-utils-server \ + --project-root ./example/ \ + --config .md-utils/server.yaml \ + --hostname 0.0.0.0 \ + --port 8080 +``` + +The default bind address is `127.0.0.1:8080`. `LOG_LEVEL` controls Swift Logging. +Hummingbird handles `SIGINT` and `SIGTERM` through graceful service shutdown. + +- `GET /books` returns the complete configured resource as a JSON array. +- `GET /books/{id}` returns one unambiguous primary-ID record. +- `GET /_md-utils/path/**` returns an exact nested logical path when at least one + `get` resource enables fallback. +- Missing records return `404`; invalid lookup paths return `400`; identity or path + collisions return `409` with every candidate in + `{"error":{"code","message","candidates"}}`. + +Rule-selected records remain present when checks or an expected mdtype fail; their +generic envelopes report `valid: false` and structured diagnostics. Missing primary +IDs remain visible in collections but cannot be fetched by item ID. A canonical +record may appear through several resources with the same canonical identity and +revision. + +Startup performs one deterministic recursive import into `InMemoryRecordStore`, +then builds one immutable snapshot. Requests do not rescan files or reparse Markdown. +Filesystem changes require a process restart, and collection responses are currently +unpaginated, so this initial distribution is intended for bounded project trees. It +does not provide hot reload, writes, authentication, or a persistent production +store. + +Verify the native server on Linux with: + +```bash +docker build --file Dockerfile.server-linux --tag md-utils-server-linux . +``` + ## Architecture - **Swift 6.2** or later - **MarkdownUtilitiesCore** for portable content operations on Apple platforms, Linux, and WebAssembly - **MarkdownUtilities** for native filesystem and metadata integrations +- **MarkdownUtilitiesServer** for immutable server planning, snapshots, and Hummingbird 2 routes - All testing uses the native Swift Testing framework ### Dependencies @@ -432,11 +505,12 @@ When the Pages workflow prepares its artifact, it copies `site/schemas/$CURRENT_ - [Yams](https://github.com/jpsim/Yams) — YAML parsing and serialization - [swift-toml](https://github.com/mattt/swift-toml) — TOML parsing and serialization - [jmespath.swift](https://github.com/nicktmro/jmespath.swift) — JMESPath query language for JSON +- [Hummingbird 2](https://github.com/hummingbird-project/hummingbird) — Native HTTP routing and lifecycle ## Platform Compatibility **macOS** is the primary development and testing platform. Core, native integrations, and the CLI are covered by the full Swift test suite. -**Linux**: `MarkdownUtilitiesCore` is supported and verified with Swift 6.2 using `Dockerfile.core-linux`. The container builds Core and runs an isolated parsing, AST, frontmatter, and rendering smoke executable. The complete native `MarkdownUtilities` and `md-utils` CLI layers are not covered by this Core guarantee. +**Linux**: `MarkdownUtilitiesCore` is supported and verified with Swift 6.2 using `Dockerfile.core-linux`. The native read-only server is separately built and tested with `Dockerfile.server-linux`. The complete `MarkdownUtilities` and `md-utils` CLI layers are not covered by the Core guarantee. **WebAssembly**: `MarkdownUtilitiesCore` is supported with the official Swift 6.3.1 WASI SDK. Run `scripts/build-wasm.sh` to compile Core and execute the root-package smoke target under WasmKit. See [WebAssembly Support](docs/webassembly.md) for SDK installation, dependency compatibility patches, artifact location, and current scope. diff --git a/Sources/MarkdownUtilitiesServer/Documentation.docc/MarkdownUtilitiesServer.md b/Sources/MarkdownUtilitiesServer/Documentation.docc/MarkdownUtilitiesServer.md index 650e2f5..8eb351d 100644 --- a/Sources/MarkdownUtilitiesServer/Documentation.docc/MarkdownUtilitiesServer.md +++ b/Sources/MarkdownUtilitiesServer/Documentation.docc/MarkdownUtilitiesServer.md @@ -1,12 +1,14 @@ # ``MarkdownUtilitiesServer`` -Persist canonical Markdown records and compile explicit resources into one deterministic endpoint plan. +Persist canonical Markdown records, compile explicit resources, and serve immutable native reads. ## Overview `MarkdownUtilitiesServer` defines the transport-neutral contract shared by runtime -route registration and OpenAPI generation. It does not load configuration files, -start an HTTP application, or depend on Hummingbird. +route registration and OpenAPI generation. It also provides native project loading +and the generic Hummingbird 2 adapter. The `md-utils-server` executable remains the +thin owner of command-line options, logging, application construction, and process +lifecycle. The module also defines the storage-neutral ``RecordStore`` contract. Stores persist canonical `MarkdownRecord` values without treating filesystem paths, SQL, parsed @@ -109,8 +111,8 @@ cannot use the `/_md-utils` namespace. ``EndpointRouteDescription`` values contain only an HTTP method, canonical path template, semantic route kind, optional resource name, and stable operation ID. -Issue #77 adapts these descriptions to Hummingbird 2. Issue #84 generates OpenAPI -3.1 from the same ``EndpointPlan``. +``MarkdownServerHTTPAdapter`` installs these routes in Hummingbird 2. Issue #84 +generates OpenAPI 3.1 from the same ``EndpointPlan``. ## Startup validation @@ -127,12 +129,15 @@ projection policies without mutating the plan. ## Configuration boundary ``MarkdownServerConfiguration`` is a versioned `Codable` model, currently version -`1`. The `md-utils-server` executable introduced by issue #77 will own JSON or YAML -file discovery and decoding. Server configuration is separate from the md-utils CLI -configuration and does not extend `.md-utils.json`. +`1`. ``MarkdownServerProjectLoader`` decodes the human-authored YAML at +`.md-utils/server.yaml`, loads rules and mdtypes, recursively imports Markdown, and +builds the immutable plan and snapshot before route registration. Server +configuration is separate from the md-utils CLI configuration and does not extend +`.md-utils/md-utils.json`. ## Topics ### Read-side composition - +- diff --git a/Sources/MarkdownUtilitiesServer/Documentation.docc/NativeReadOnlyServer.md b/Sources/MarkdownUtilitiesServer/Documentation.docc/NativeReadOnlyServer.md new file mode 100644 index 0000000..0e041ff --- /dev/null +++ b/Sources/MarkdownUtilitiesServer/Documentation.docc/NativeReadOnlyServer.md @@ -0,0 +1,47 @@ +# Native Read-Only Server + +Compose a project snapshot once and expose it through generic Hummingbird 2 routes. + +## Startup + +`md-utils-server` reads `.md-utils/server.yaml` by default. `--project-root` changes +the root used for records, rules, mdtypes, and schemas; `--config` selects another +YAML file; `--hostname` and `--port` override `127.0.0.1:8080`. + +``MarkdownServerProjectLoader`` loads `.md-utils/md-utils.json` when present, +recursively imports `.md` and `.markdown` files outside `.md-utils/` into +``InMemoryRecordStore``, compiles one ``EndpointPlan``, and builds one +``MarkdownServerReadSnapshot``. Any decoding, reference, route, or snapshot failure +stops startup before Hummingbird accepts requests. + +The snapshot is immutable. Restart the process to observe filesystem changes. +Hummingbird's `runService()` performs graceful lifecycle shutdown for `SIGINT` and +`SIGTERM`. + +## Routing and Responses + +``MarkdownServerHTTPAdapter`` registers each route in the plan without generated or +resource-specific Swift code: + +- Collection routes return `[GenericMarkdownRecord]`. +- Item routes return one record by the resource's primary identity. +- `/_md-utils/path/**` returns one record by exact nested logical path when fallback + is enabled. + +Not-found results map to `404`. Invalid logical paths map to `400`. Identity and +logical-path collisions map to `409` with every candidate in a stable +``MarkdownServerHTTPErrorEnvelope``. A handler never chooses an arbitrary colliding +record. + +Rule-selected invalid candidates remain in successful collection and item responses +with `valid: false` and diagnostics. Missing primary identities remain visible in a +collection but cannot be addressed through the item route. Overlapping resource +membership retains one canonical identity and revision across every representation. + +## Performance Boundary + +Recursive discovery, Markdown parsing, rule checks, type assessment, and index +construction occur once during startup. Requests read immutable precomputed arrays +and lookup indexes. The first release returns unpaginated collections and stores the +startup import in memory, so it targets bounded project trees rather than an +unbounded or live-updating repository. diff --git a/Sources/MarkdownUtilitiesServer/MarkdownServerHTTPAdapter.swift b/Sources/MarkdownUtilitiesServer/MarkdownServerHTTPAdapter.swift new file mode 100644 index 0000000..c944700 --- /dev/null +++ b/Sources/MarkdownUtilitiesServer/MarkdownServerHTTPAdapter.swift @@ -0,0 +1,221 @@ +import Foundation +import Hummingbird +import MarkdownUtilitiesCore + +/// Stable machine-readable details for an HTTP failure. +public struct MarkdownServerHTTPError: Codable, Equatable, Sendable { + /// Stable error code suitable for client branching. + public let code: String + /// Human-readable explanation of the failure. + public let message: String + /// Every ambiguous record when a lookup cannot choose one canonical result. + public let candidates: [GenericMarkdownRecord]? + + /// Creates a structured HTTP error. + public init( + code: String, + message: String, + candidates: [GenericMarkdownRecord]? = nil + ) { + self.code = code + self.message = message + self.candidates = candidates + } +} + +/// Top-level JSON error envelope returned by every md-utils server failure. +public struct MarkdownServerHTTPErrorEnvelope: Codable, Equatable, Sendable { + /// Structured error details. + public let error: MarkdownServerHTTPError + + /// Creates a top-level error envelope. + public init(error: MarkdownServerHTTPError) { + self.error = error + } +} + +/// Startup failures detected while adapting an endpoint plan to Hummingbird. +public enum MarkdownServerHTTPAdapterError: Error, Equatable, LocalizedError, Sendable { + /// The immutable endpoint plan and read snapshot describe different resources. + case resourceSnapshotMismatch(planned: [String], available: [String]) + /// A route references no resource or a resource absent from the validated plan. + case missingRouteResource(operationID: String, resourceName: String?) + /// The plan contains a method unsupported by the read-only adapter. + case unsupportedMethod(operationID: String, method: EndpointHTTPMethod) + + /// Human-readable startup failure description. + public var errorDescription: String? { + switch self { + case .resourceSnapshotMismatch(let planned, let available): + return "Endpoint plan resources \(planned) do not match read snapshot resources \(available)" + case .missingRouteResource(let operationID, let resourceName): + return "Route \"\(operationID)\" references unavailable resource \"\(resourceName ?? "nil")\"" + case .unsupportedMethod(let operationID, let method): + return "Route \"\(operationID)\" uses unsupported HTTP method \"\(method.rawValue)\"" + } + } +} + +/// Registers generic Hummingbird 2 handlers directly from an immutable endpoint plan. +@available(macOS 14.0, iOS 17.0, tvOS 17.0, *) +public enum MarkdownServerHTTPAdapter { + /// Installs every planned collection, item, and reserved logical-path route. + /// + /// Registration validates that the supplied snapshot was built for the same resource + /// set. No resource-specific Swift source is generated and no request reparses records. + /// + /// - Parameters: + /// - plan: Validated source of route truth. + /// - snapshot: Immutable read-side state used by every handler. + /// - router: Hummingbird router that receives the planned routes. + /// - Throws: ``MarkdownServerHTTPAdapterError`` when the plan and snapshot drift. + public static func register( + plan: EndpointPlan, + snapshot: MarkdownServerReadSnapshot, + on router: Router + ) throws { + let plannedNames = plan.resources.map(\.name).sorted() + let snapshotNames = snapshot.resources.map(\.name).sorted() + guard plannedNames == snapshotNames else { + throw MarkdownServerHTTPAdapterError.resourceSnapshotMismatch( + planned: plannedNames, + available: snapshotNames + ) + } + + let resources = Dictionary(uniqueKeysWithValues: snapshot.resources.map { ($0.name, $0) }) + for route in plan.routes { + guard route.method == .get else { + throw MarkdownServerHTTPAdapterError.unsupportedMethod( + operationID: route.operationID, + method: route.method + ) + } + + switch route.kind { + case .collection: + let resource = try routeResource(route, resources: resources) + router.get(RouterPath(route.path.rawValue)) { _, _ in + try jsonResponse(resource.records, status: .ok) + } + + case .item: + let resource = try routeResource(route, resources: resources) + router.get(RouterPath(route.path.rawValue)) { _, context in + guard let encodedIdentity = context.parameters.get("id"), + let identity = encodedIdentity.removingPercentEncoding + else { + return try errorResponse( + status: .badRequest, + code: "request.invalid-identity", + message: "The item identity is missing or has invalid percent encoding" + ) + } + return try lookupResponse( + resource.lookup(primary: MarkdownRecordIdentity(rawValue: identity)), + notFoundMessage: "No record exists with primary identity \"\(identity)\"", + conflictCode: "record.identity-conflict", + conflictMessage: "Several records share the requested primary identity" + ) + } + + case .logicalPath: + router.get(RouterPath(hummingbirdPath(for: route))) { _, context in + let encodedPath = context.parameters.getCatchAll().joined(separator: "/") + guard let pathString = encodedPath.removingPercentEncoding, + let path = try? MarkdownRecordPath(pathString) + else { + return try errorResponse( + status: .badRequest, + code: "request.invalid-logical-path", + message: "The logical path must be a valid collection-relative record path" + ) + } + return try lookupResponse( + snapshot.lookup(logicalPath: path), + notFoundMessage: "No record exists at logical path \"\(path.rawValue)\"", + conflictCode: "record.logical-path-conflict", + conflictMessage: "Several records share the requested logical path" + ) + } + } + } + } + + private static func routeResource( + _ route: EndpointRouteDescription, + resources: [String: MarkdownResourceReadSnapshot] + ) throws -> MarkdownResourceReadSnapshot { + guard let name = route.resourceName, let resource = resources[name] else { + throw MarkdownServerHTTPAdapterError.missingRouteResource( + operationID: route.operationID, + resourceName: route.resourceName + ) + } + return resource + } + + private static func hummingbirdPath(for route: EndpointRouteDescription) -> String { + switch route.kind { + case .logicalPath: + return "/_md-utils/path/**" + case .collection, .item: + return route.path.rawValue + } + } + + private static func lookupResponse( + _ result: MarkdownServerReadLookupResult, + notFoundMessage: String, + conflictCode: String, + conflictMessage: String + ) throws -> Response { + switch result { + case .record(let record): + return try jsonResponse(record, status: .ok) + case .notFound: + return try errorResponse( + status: .notFound, + code: "record.not-found", + message: notFoundMessage + ) + case .conflict(let conflict): + return try errorResponse( + status: .conflict, + code: conflictCode, + message: conflictMessage, + candidates: conflict.candidates + ) + } + } + + private static func errorResponse( + status: HTTPResponse.Status, + code: String, + message: String, + candidates: [GenericMarkdownRecord]? = nil + ) throws -> Response { + try jsonResponse( + MarkdownServerHTTPErrorEnvelope(error: MarkdownServerHTTPError( + code: code, + message: message, + candidates: candidates + )), + status: status + ) + } + + private static func jsonResponse( + _ value: Value, + status: HTTPResponse.Status + ) throws -> Response { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(value) + return Response( + status: status, + headers: [.contentType: "application/json; charset=utf-8"], + body: .init(byteBuffer: ByteBuffer(bytes: data)) + ) + } +} diff --git a/Sources/MarkdownUtilitiesServer/MarkdownServerProjectLoader.swift b/Sources/MarkdownUtilitiesServer/MarkdownServerProjectLoader.swift new file mode 100644 index 0000000..8f6e29c --- /dev/null +++ b/Sources/MarkdownUtilitiesServer/MarkdownServerProjectLoader.swift @@ -0,0 +1,437 @@ +import Foundation +import JMESPath +import MarkdownUtilities +import MarkdownUtilitiesCore +import PathKit +import Yams + +/// A completely composed, immutable native server runtime ready for route registration. +public struct MarkdownServerRuntime: Sendable { + /// Decoded opt-in resource configuration. + public let configuration: MarkdownServerConfiguration + /// Validated source of runtime route truth. + public let plan: EndpointPlan + /// Immutable records, assessments, and collision-safe lookup indexes. + public let snapshot: MarkdownServerReadSnapshot + /// Number of Markdown files imported during the startup scan. + public let importedRecordCount: Int + + /// Creates a composed server runtime. + public init( + configuration: MarkdownServerConfiguration, + plan: EndpointPlan, + snapshot: MarkdownServerReadSnapshot, + importedRecordCount: Int + ) { + self.configuration = configuration + self.plan = plan + self.snapshot = snapshot + self.importedRecordCount = importedRecordCount + } +} + +/// Native startup failures produced before Hummingbird begins accepting requests. +public enum MarkdownServerProjectLoaderError: Error, Equatable, LocalizedError, Sendable { + /// The supplied project root does not exist. + case projectRootNotFound(String) + /// The supplied project root is not a directory. + case projectRootNotDirectory(String) + /// The server configuration file does not exist. + case configurationNotFound(String) + /// The server configuration path is not a regular file. + case configurationNotFile(String) + /// YAML could not be decoded as the versioned server configuration model. + case invalidConfiguration(path: String, message: String) + /// A recursively discovered symlink resolves outside the selected project root. + case recordOutsideProject(String) + + /// Human-readable startup failure description. + public var errorDescription: String? { + switch self { + case .projectRootNotFound(let path): + return "Server project root not found: \(path)" + case .projectRootNotDirectory(let path): + return "Server project root is not a directory: \(path)" + case .configurationNotFound(let path): + return "Server configuration not found: \(path)" + case .configurationNotFile(let path): + return "Server configuration is not a file: \(path)" + case .invalidConfiguration(let path, let message): + return "Invalid server configuration at \(path): \(message)" + case .recordOutsideProject(let path): + return "Markdown records must remain inside the server project root: \(path)" + } + } +} + +/// Loads one native project into the immutable read-only server model. +/// +/// Markdown files are recursively imported once into ``InMemoryRecordStore``. The +/// resulting snapshot remains fixed until the process restarts; this loader is not a +/// filesystem watcher or a persistent store. +public struct MarkdownServerProjectLoader: @unchecked Sendable { + /// Default configuration location relative to a project root. + public static let defaultConfigurationPath = ".md-utils/server.yaml" + /// Supported canonical Markdown filename extensions. + public static let markdownExtensions: Set = ["md", "markdown"] + + /// Native project root whose Markdown files and `.md-utils/` definitions are loaded. + public let projectRoot: Path + /// YAML resource configuration file, absolute or relative to ``projectRoot``. + public let configurationFile: Path + + /// Creates a startup project loader. + /// + /// - Parameters: + /// - projectRoot: Root used for record paths, rules, types, and schemas. + /// - configurationFile: Optional YAML path. Relative paths resolve from the root. + public init( + projectRoot: Path = .current, + configurationFile: Path? = nil + ) { + let root = projectRoot.absolute().normalize() + self.projectRoot = root + if let configurationFile { + self.configurationFile = configurationFile.isAbsolute + ? configurationFile.normalize() + : (root + configurationFile).normalize() + } else { + self.configurationFile = (root + Path(Self.defaultConfigurationPath)).normalize() + } + } + + /// Decodes configuration, compiles definitions, imports records, and builds a snapshot. + /// + /// Every step completes before an immutable runtime is returned, so malformed startup + /// inputs cannot produce a partially registered server. + public func load() async throws -> MarkdownServerRuntime { + try validatePaths() + let configuration = try loadServerConfiguration() + let typeRegistry = try loadTypeRegistry() + let ruleRegistry = try loadRuleRegistry(typeRegistry: typeRegistry) + let plan = try EndpointPlanCompiler( + ruleRegistry: ruleRegistry, + typeRegistry: typeRegistry + ).compile(configuration) + let records = try loadRecords() + let store = try InMemoryRecordStore(records: records) + let snapshot = try await MarkdownServerReadSnapshotBuilder( + store: store, + plan: plan, + ruleRegistry: ruleRegistry, + typeRegistry: typeRegistry + ).build() + return MarkdownServerRuntime( + configuration: configuration, + plan: plan, + snapshot: snapshot, + importedRecordCount: records.count + ) + } + + private func validatePaths() throws { + guard projectRoot.exists else { + throw MarkdownServerProjectLoaderError.projectRootNotFound(projectRoot.string) + } + guard projectRoot.isDirectory else { + throw MarkdownServerProjectLoaderError.projectRootNotDirectory(projectRoot.string) + } + guard configurationFile.exists else { + throw MarkdownServerProjectLoaderError.configurationNotFound(configurationFile.string) + } + guard configurationFile.isFile else { + throw MarkdownServerProjectLoaderError.configurationNotFile(configurationFile.string) + } + } + + private func loadServerConfiguration() throws -> MarkdownServerConfiguration { + do { + return try YAMLDecoder().decode( + NativeMarkdownServerConfigurationFile.self, + from: configurationFile.read(.utf8) + ).configuration + } catch { + throw MarkdownServerProjectLoaderError.invalidConfiguration( + path: configurationFile.string, + message: error.localizedDescription + ) + } + } + + private func loadTypeRegistry() throws -> MarkdownTypeRegistry { + let typesDirectory = projectRoot + Path(MarkdownTypeFileRegistryLoader.relativeTypesDirectory) + guard typesDirectory.exists else { + return try MarkdownTypeRegistry(definitions: []) + } + return try MarkdownTypeFileRegistryLoader.load(projectRoot: projectRoot) + } + + private func loadRuleRegistry( + typeRegistry: MarkdownTypeRegistry + ) throws -> MarkdownRuleRegistry { + let configurationPath = projectRoot + Path(".md-utils/md-utils.json") + let configuration: MarkdownRuleConfiguration + if configurationPath.exists { + configuration = try MarkdownRuleConfigurationDecoder.decode( + configurationPath.read(.utf8) + ) + } else { + configuration = MarkdownRuleConfiguration() + } + + let schemaDirectory = Path(configuration.schemaDirectory).isAbsolute + ? Path(configuration.schemaDirectory) + : projectRoot + Path(configuration.schemaDirectory) + let source = URL( + fileURLWithPath: (schemaDirectory + "__md-utils-server-rule-source.json").string + ).absoluteString + let definitions = configuration.rules.map { definition in + var definition = definition + definition.source = source + return definition + } + let queryProvider = NativeJMESPathRuleCapabilityProvider() + return try MarkdownRuleCompiler( + capabilities: [.modificationDate, .frontmatterJMESPath], + typeRegistry: typeRegistry, + schemaProvider: FileMarkdownSchemaResourceProvider(projectRoot: projectRoot), + queryProvider: queryProvider + ).compile(definitions) + } + + private func loadRecords() throws -> [MarkdownRecord] { + let configurationDirectory = (projectRoot + Path(".md-utils/")).normalize() + let candidates = try projectRoot.recursiveChildren() + .filter { path in + guard path.isFile, + let pathExtension = path.extension?.lowercased(), + Self.markdownExtensions.contains(pathExtension) + else { return false } + return Self.isDescendant(path, of: configurationDirectory) == false + } + .sorted { $0.string < $1.string } + let canonicalRoot = Self.resolvingSymbolicLinks(in: projectRoot) + let canonicalConfigurationDirectory = Self.resolvingSymbolicLinks( + in: configurationDirectory + ) + var records: [MarkdownRecord] = [] + records.reserveCapacity(candidates.count) + for candidate in candidates { + let canonical = Self.resolvingSymbolicLinks(in: candidate) + guard Self.isDescendant(canonical, of: canonicalRoot) else { + throw MarkdownServerProjectLoaderError.recordOutsideProject(candidate.string) + } + guard Self.isDescendant(canonical, of: canonicalConfigurationDirectory) == false else { + continue + } + records.append(try MarkdownRecordFileAdapter.read(candidate, projectRoot: projectRoot)) + } + return records + } + + private static func isDescendant(_ path: Path, of directory: Path) -> Bool { + let directoryString = directory.absolute().normalize().string + let prefix = directoryString.hasSuffix("/") ? directoryString : directoryString + "/" + return path.absolute().normalize().string.hasPrefix(prefix) + } + + private static func resolvingSymbolicLinks(in path: Path) -> Path { + Path(URL(fileURLWithPath: path.absolute().string).resolvingSymlinksInPath().path) + .normalize() + } +} + +/// Human-authored YAML boundary mapped into the transport-neutral configuration model. +private struct NativeMarkdownServerConfigurationFile: Decodable { + let serverConfigVersion: String + let resources: [NativeMarkdownResourceConfiguration] + + var configuration: MarkdownServerConfiguration { + MarkdownServerConfiguration( + serverConfigVersion: serverConfigVersion, + resources: resources.map(\.configuration) + ) + } +} + +private struct NativeMarkdownResourceConfiguration: Decodable { + let name: String + let route: String + let operations: [MarkdownResourceOperation] + let selection: MarkdownResourceSelection + let identityPolicy: NativeMarkdownRecordIdentityPolicy + let projectionPolicy: MarkdownResourceProjectionPolicy + let operationIDOverrides: [MarkdownOperationIDOverride] + + private enum CodingKeys: String, CodingKey { + case name + case route + case operations + case selection + case identityPolicy + case projectionPolicy + case operationIDOverrides + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + route = try container.decode(String.self, forKey: .route) + operations = try container.decode([MarkdownResourceOperation].self, forKey: .operations) + selection = try container.decode(MarkdownResourceSelection.self, forKey: .selection) + identityPolicy = try container.decode(NativeMarkdownRecordIdentityPolicy.self, forKey: .identityPolicy) + projectionPolicy = try container.decodeIfPresent( + MarkdownResourceProjectionPolicy.self, + forKey: .projectionPolicy + ) ?? .genericRecord + operationIDOverrides = try container.decodeIfPresent( + [MarkdownOperationIDOverride].self, + forKey: .operationIDOverrides + ) ?? [] + } + + var configuration: MarkdownResourceConfiguration { + MarkdownResourceConfiguration( + name: name, + route: route, + operations: operations, + selection: selection, + identityPolicy: identityPolicy.policy, + projectionPolicy: projectionPolicy, + operationIDOverrides: operationIDOverrides + ) + } +} + +private struct NativeMarkdownRecordIdentityPolicy: Decodable { + enum Source: String, Decodable { + case existingIdentity + case logicalPath + case frontmatter + } + + enum Format: String, Decodable { + case string + case integer + case uuid + case slug + } + + let source: Source + let path: [String]? + let format: Format? + let slugPolicy: MarkdownSlugPolicy? + let logicalPathFallbackEnabled: Bool + + private enum CodingKeys: String, CodingKey { + case source + case path + case format + case slugPolicy + case logicalPathFallbackEnabled + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + source = try container.decode(Source.self, forKey: .source) + path = try container.decodeIfPresent([String].self, forKey: .path) + format = try container.decodeIfPresent(Format.self, forKey: .format) + slugPolicy = try container.decodeIfPresent(MarkdownSlugPolicy.self, forKey: .slugPolicy) + logicalPathFallbackEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .logicalPathFallbackEnabled + ) ?? true + + switch source { + case .existingIdentity, .logicalPath: + guard path == nil, format == nil, slugPolicy == nil else { + throw DecodingError.dataCorruptedError( + forKey: .source, + in: container, + debugDescription: "Identity path, format, and slugPolicy require source: frontmatter" + ) + } + case .frontmatter: + guard let path, path.isEmpty == false, let format else { + throw DecodingError.dataCorruptedError( + forKey: .source, + in: container, + debugDescription: "Frontmatter identity requires a nonempty path and format" + ) + } + if format == .slug, slugPolicy == nil { + throw DecodingError.dataCorruptedError( + forKey: .slugPolicy, + in: container, + debugDescription: "Slug identity format requires slugPolicy" + ) + } + if format != .slug, slugPolicy != nil { + throw DecodingError.dataCorruptedError( + forKey: .slugPolicy, + in: container, + debugDescription: "slugPolicy is valid only with format: slug" + ) + } + } + } + + var policy: MarkdownRecordIdentityPolicy { + let modelSource: MarkdownRecordIdentitySource + switch source { + case .existingIdentity: + modelSource = .existingIdentity + case .logicalPath: + modelSource = .logicalPath + case .frontmatter: + let identityPath = path ?? [] + switch format { + case .string: + modelSource = .frontmatter(path: identityPath, format: .string) + case .integer: + modelSource = .frontmatter(path: identityPath, format: .integer) + case .uuid: + modelSource = .frontmatter(path: identityPath, format: .uuid) + case .slug: + modelSource = .frontmatter( + path: identityPath, + format: .slug(slugPolicy ?? .strictASCII) + ) + case nil: + modelSource = .frontmatter(path: identityPath, format: .string) + } + } + return MarkdownRecordIdentityPolicy( + source: modelSource, + logicalPathFallbackEnabled: logicalPathFallbackEnabled + ) + } +} + +/// Serialized native bridge for the non-Sendable JMESPath implementation. +private final class NativeJMESPathRuleCapabilityProvider: + MarkdownRuleQueryCapabilityProvider, + @unchecked Sendable +{ + let capabilities: Set = [.frontmatterJMESPath] + private let lock = NSLock() + + func validateJMESPath(_ expression: String) throws { + lock.lock() + defer { lock.unlock() } + _ = try JMESExpression.compile(expression) + } + + func evaluateJMESPath( + _ expression: String, + frontmatter: JSONValue + ) throws -> JSONValue? { + lock.lock() + defer { lock.unlock() } + let compiled = try JMESExpression.compile(expression) + guard let result = try compiled.search(object: frontmatter.foundationValue) else { + return nil + } + return try JSONValue(any: result) + } +} diff --git a/Sources/md-utils-server/ServerEntry.swift b/Sources/md-utils-server/ServerEntry.swift new file mode 100644 index 0000000..8c7c1cb --- /dev/null +++ b/Sources/md-utils-server/ServerEntry.swift @@ -0,0 +1,102 @@ +import ArgumentParser +import Foundation +import Hummingbird +import Logging +import MarkdownUtilitiesServer +import PathKit + +/// Native read-only HTTP distribution for explicitly configured Markdown resources. +@main +struct ServerEntry: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "md-utils-server", + abstract: "Serve configured Markdown resources through a read-only HTTP API.", + version: "0.1.0-alpha" + ) + + @Option( + name: .long, + help: "Project directory containing Markdown records and .md-utils/." + ) + var projectRoot = "." + + @Option( + name: .long, + help: "Server YAML path relative to --project-root (default: .md-utils/server.yaml)." + ) + var config: String? + + @Option(name: .long, help: "Hostname or IP address to bind.") + var hostname = "127.0.0.1" + + @Option(name: .long, help: "TCP port to bind.") + var port = 8080 + + mutating func validate() throws { + guard (1...65_535).contains(port) else { + throw ValidationError("--port must be between 1 and 65535") + } + guard hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw ValidationError("--hostname must not be empty") + } + } + + mutating func run() async throws { + #if os(macOS) + guard #available(macOS 14.0, *) else { + throw ValidationError("md-utils-server requires macOS 14 or later") + } + #endif + try await runServer() + } + + @available(macOS 14.0, *) + private func runServer() async throws { + let root = Path(projectRoot) + let loader = MarkdownServerProjectLoader( + projectRoot: root, + configurationFile: config.map { Path($0) } + ) + let runtime = try await loader.load() + + let router = Router() + try MarkdownServerHTTPAdapter.register( + plan: runtime.plan, + snapshot: runtime.snapshot, + on: router + ) + + var logger = Logger(label: "md-utils-server") + if let configuredLevel = ProcessInfo.processInfo.environment["LOG_LEVEL"], + let level = Logger.Level(rawValue: configuredLevel.lowercased()) + { + logger.logLevel = level + } + logger.info("Loaded md-utils server project", metadata: [ + "project_root": "\(loader.projectRoot.string)", + "configuration": "\(loader.configurationFile.string)", + "records": "\(runtime.importedRecordCount)", + "resources": "\(runtime.plan.resources.count)", + "routes": "\(runtime.plan.routes.count)", + ]) + let applicationLogger = logger + + let bindHostname = hostname + let bindPort = port + let app = Application( + router: router, + configuration: .init( + address: .hostname(bindHostname, port: bindPort), + serverName: "md-utils-server" + ), + onServerRunning: { _ in + applicationLogger.info("md-utils server listening", metadata: [ + "hostname": "\(bindHostname)", + "port": "\(bindPort)", + ]) + }, + logger: applicationLogger + ) + try await app.runService() + } +} diff --git a/Sources/md-utils/Documentation.docc/YAMLVsTOML.md b/Sources/md-utils/Documentation.docc/YAMLVsTOML.md index d648c00..84853e3 100644 --- a/Sources/md-utils/Documentation.docc/YAMLVsTOML.md +++ b/Sources/md-utils/Documentation.docc/YAMLVsTOML.md @@ -40,7 +40,7 @@ JSON is a useful common denominator, but it is not identical to either format. I YAML and TOML both allow comments. JSON does not, and neither does the `md-utils fm` data model. -Once an `fm` command mutates frontmatter, however, `md-utils` parses and serializes the complete block. Comments—and syntax choices such as quoting or inline layout—are not guaranteed to survive. Avoid comments in frontmatter that will be managed with `md-utils fm`. Note: Read-only operations do not rewrite a file, and `fm dump --format raw` can return the original frontmatter text. +Once an `fm` command mutates frontmatter, however, `md-utils` parses and serializes the complete block. Comments—and syntax choices such as quoting or inline layout—are not guaranteed to survive. Avoid comments in frontmatter that will be managed with `md-utils fm`. Note: Read-only operations do not rewrite a file, and `fm dump --format raw` can return the original frontmatter text. ## CLI-specific differences diff --git a/Tests/MarkdownUtilitiesServerTests/MarkdownServerHTTPAdapterTests.swift b/Tests/MarkdownUtilitiesServerTests/MarkdownServerHTTPAdapterTests.swift new file mode 100644 index 0000000..b2677ff --- /dev/null +++ b/Tests/MarkdownUtilitiesServerTests/MarkdownServerHTTPAdapterTests.swift @@ -0,0 +1,219 @@ +import Foundation +import Hummingbird +import HummingbirdTesting +import MarkdownUtilitiesCore +import Testing +@testable import MarkdownUtilitiesServer + +@Suite("Hummingbird read-only server adapter") +struct MarkdownServerHTTPAdapterTests { + @Test + func `Planned routes preserve validity overlap paths missing IDs and collisions`() async throws { + guard #available(macOS 14.0, *) else { return } + let fixture = try await makeFixture() + let router = Router() + try MarkdownServerHTTPAdapter.register( + plan: fixture.plan, + snapshot: fixture.snapshot, + on: router + ) + let app = Application(router: router) + + try await app.test(.router) { client in + try await client.execute(uri: "/books", method: .get) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == "application/json; charset=utf-8") + let records = try decode([GenericMarkdownRecord].self, from: response.body) + #expect(records.count == 5) + + let invalid = try #require(records.first { + $0.canonicalIdentity?.rawValue == "canonical-invalid" + }) + #expect(invalid.valid == false) + #expect(invalid.diagnostics.contains { $0.source == .type && $0.severity == .error }) + + let missing = try #require(records.first { + $0.canonicalIdentity?.rawValue == "canonical-missing" + }) + #expect(missing.memberships.first { $0.resourceName == "books" }?.identity == nil) + #expect(missing.memberships.first { $0.resourceName == "books" }?.identityStatus == .missing) + + let overlapping = try #require(records.first { + $0.canonicalIdentity?.rawValue == "canonical-dune" + }) + #expect(overlapping.memberships.map(\.resourceName) == ["books", "library"]) + } + + try await client.execute(uri: "/books/dune", method: .get) { response in + #expect(response.status == .ok) + let record = try decode(GenericMarkdownRecord.self, from: response.body) + #expect(record.canonicalIdentity?.rawValue == "canonical-dune") + #expect(record.logicalPath?.rawValue == "books/classics/dune.md") + } + + try await client.execute(uri: "/books/does-not-exist", method: .get) { response in + #expect(response.status == .notFound) + let envelope = try decode(MarkdownServerHTTPErrorEnvelope.self, from: response.body) + #expect(envelope.error.code == "record.not-found") + #expect(envelope.error.candidates == nil) + } + + try await client.execute(uri: "/books/canonical-missing", method: .get) { response in + #expect(response.status == .notFound) + } + + try await client.execute(uri: "/books/shared", method: .get) { response in + #expect(response.status == .conflict) + let envelope = try decode(MarkdownServerHTTPErrorEnvelope.self, from: response.body) + #expect(envelope.error.code == "record.identity-conflict") + #expect(envelope.error.candidates?.count == 2) + #expect(envelope.error.candidates?.compactMap(\.canonicalIdentity?.rawValue) == [ + "canonical-shared-a", "canonical-shared-b", + ]) + } + + try await client.execute( + uri: "/_md-utils/path/books/classics/dune.md", + method: .get + ) { response in + #expect(response.status == .ok) + let record = try decode(GenericMarkdownRecord.self, from: response.body) + #expect(record.canonicalIdentity?.rawValue == "canonical-dune") + } + + try await client.execute( + uri: "/_md-utils/path/books/missing.md", + method: .get + ) { response in + #expect(response.status == .notFound) + let envelope = try decode(MarkdownServerHTTPErrorEnvelope.self, from: response.body) + #expect(envelope.error.code == "record.not-found") + } + } + } + + @Test + func `Registration rejects plan and snapshot drift before serving`() async throws { + guard #available(macOS 14.0, *) else { return } + let fixture = try await makeFixture() + let mismatched = MarkdownServerReadSnapshot( + resources: [], + canonicalRecords: [], + logicalPathLookup: [:] + ) + let router = Router() + + #expect(throws: MarkdownServerHTTPAdapterError.resourceSnapshotMismatch( + planned: ["books", "library"], + available: [] + )) { + try MarkdownServerHTTPAdapter.register( + plan: fixture.plan, + snapshot: mismatched, + on: router + ) + } + } + + private func makeFixture() async throws -> ( + plan: EndpointPlan, + snapshot: MarkdownServerReadSnapshot + ) { + let book = type(named: "Book") + let libraryItem = type(named: "LibraryItem") + let typeRegistry = try MarkdownTypeRegistry(definitions: [book, libraryItem]) + let candidates = MarkdownRuleDefinition( + name: "book-candidates", + applicability: MarkdownRuleApplicability(paths: ["books/**"]) + ) + let ruleRegistry = try MarkdownRuleCompiler(typeRegistry: typeRegistry).compile([candidates]) + let primaryIdentity = MarkdownRecordIdentityPolicy( + source: .frontmatter(path: ["slug"], format: .string) + ) + let plan = try EndpointPlanCompiler( + ruleRegistry: ruleRegistry, + typeRegistry: typeRegistry + ).compile(MarkdownServerConfiguration(resources: [ + MarkdownResourceConfiguration( + name: "books", + route: "/books", + operations: [.list, .get], + selection: .ruleWithExpectedType(rule: candidates.name, expectedType: book.name), + identityPolicy: primaryIdentity + ), + MarkdownResourceConfiguration( + name: "library", + route: "/library", + operations: [.list, .get], + selection: .type(name: libraryItem.name, searchRoot: "books/"), + identityPolicy: MarkdownRecordIdentityPolicy(source: .existingIdentity) + ), + ])) + let store = try InMemoryRecordStore(records: [ + record( + identity: "canonical-dune", + path: "books/classics/dune.md", + content: "---\nslug: dune\n---\n# Book\nDune" + ), + record( + identity: "canonical-invalid", + path: "books/invalid.md", + content: "---\nslug: invalid\n---\n# Note" + ), + record( + identity: "canonical-missing", + path: "books/no-slug.md", + content: "# Book" + ), + record( + identity: "canonical-shared-a", + path: "books/shared-a.md", + content: "---\nslug: shared\n---\n# Book\nA" + ), + record( + identity: "canonical-shared-b", + path: "books/shared-b.md", + content: "---\nslug: shared\n---\n# Book\nB" + ), + ]) + let snapshot = try await MarkdownServerReadSnapshotBuilder( + store: store, + plan: plan, + ruleRegistry: ruleRegistry, + typeRegistry: typeRegistry + ).build() + return (plan, snapshot) + } + + private func type(named name: String) -> MarkdownTypeDefinition { + MarkdownTypeDefinition( + name: MarkdownTypeName(rawValue: name), + version: "1", + body: MarkdownConstraintGroup(requirements: [ + MarkdownConstraint( + id: "\(name).heading", + predicate: .heading(MarkdownHeadingPredicate(text: "Book")) + ) + ]) + ) + } + + private func record( + identity: String, + path: String, + content: String + ) throws -> MarkdownRecord { + MarkdownRecord( + identity: MarkdownRecordIdentity(rawValue: identity), + content: content, + context: MarkdownRecordContext(path: try MarkdownRecordPath(path)) + ) + } + + private func decode( + _ type: Value.Type, + from buffer: ByteBuffer + ) throws -> Value { + try JSONDecoder().decode(type, from: Data(buffer.readableBytesView)) + } +} diff --git a/Tests/MarkdownUtilitiesServerTests/MarkdownServerProjectLoaderTests.swift b/Tests/MarkdownUtilitiesServerTests/MarkdownServerProjectLoaderTests.swift new file mode 100644 index 0000000..6b602e4 --- /dev/null +++ b/Tests/MarkdownUtilitiesServerTests/MarkdownServerProjectLoaderTests.swift @@ -0,0 +1,104 @@ +import Foundation +import PathKit +import Testing +@testable import MarkdownUtilitiesServer + +@Suite("Native server project loading") +struct MarkdownServerProjectLoaderTests { + @Test + func `Default YAML loads rules and recursively imports Markdown once`() async throws { + let root = Path("tmp/server-loader-tests/\(UUID().uuidString)/").absolute() + defer { try? root.delete() } + try (root + ".md-utils/").mkpath() + try (root + "books/classics/").mkpath() + try (root + "notes/").mkpath() + + try (root + ".md-utils/server.yaml").write( + """ + serverConfigVersion: "1" + resources: + - name: books + route: /books + operations: [list, get] + selection: + mode: rule + rule: books + identityPolicy: + source: frontmatter + path: [slug] + format: string + logicalPathFallbackEnabled: true + """ + ) + try (root + ".md-utils/md-utils.json").write( + """ + { + "configVersion": "0.2.0", + "schemaDirectory": ".md-utils/schemas/", + "rules": [{ + "name": "books", + "match": { "paths": ["books/**"] }, + "checks": [{ "type": "requiredHeading", "heading": "Book" }] + }] + } + """ + ) + try (root + "books/classics/dune.md").write("---\nslug: dune\n---\n# Book\nDune") + try (root + "notes/ignored.md").write("# Note") + try (root + ".md-utils/ignored.md").write("# Configuration documentation") + + let loader = MarkdownServerProjectLoader(projectRoot: root) + let runtime = try await loader.load() + + #expect(loader.configurationFile == root + ".md-utils/server.yaml") + #expect(runtime.importedRecordCount == 2) + #expect(runtime.plan.resources.map(\.name) == ["books"]) + #expect(runtime.plan.routes.map(\.path.rawValue) == [ + "/_md-utils/path/{path...}", "/books", "/books/{id}", + ]) + let books = try #require(runtime.snapshot.resource(named: "books")) + #expect(books.records.count == 1) + #expect(books.records.first?.canonicalIdentity?.rawValue == "books/classics/dune.md") + #expect(books.records.first?.logicalPath?.rawValue == "books/classics/dune.md") + #expect(books.records.first?.revision != nil) + #expect(books.records.first?.valid == true) + #expect(books.records.first?.memberships.first?.identity?.rawValue == "dune") + } + + @Test + func `Missing default configuration fails before partial startup`() async throws { + let root = Path("tmp/server-loader-tests/\(UUID().uuidString)/").absolute() + defer { try? root.delete() } + try root.mkpath() + let expectedPath = (root + ".md-utils/server.yaml").normalize().string + + await #expect(throws: MarkdownServerProjectLoaderError.configurationNotFound(expectedPath)) { + try await MarkdownServerProjectLoader(projectRoot: root).load() + } + } + + @Test + func `Recursive import rejects a symlink outside the project`() async throws { + let base = Path("tmp/server-loader-tests/\(UUID().uuidString)/").absolute() + let root = base + "project/" + defer { try? base.delete() } + try (root + ".md-utils/").mkpath() + try (root + ".md-utils/server.yaml").write( + """ + serverConfigVersion: "1" + resources: [] + """ + ) + let outside = base + "outside.md" + try outside.write("# Outside") + let link = root + "linked.md" + try FileManager.default.createSymbolicLink( + atPath: link.string, + withDestinationPath: outside.string + ) + + await #expect(throws: MarkdownServerProjectLoaderError.recordOutsideProject(link.string)) { + try await MarkdownServerProjectLoader(projectRoot: root).load() + } + } +} diff --git a/docs/server-architecture.md b/docs/server-architecture.md index ca4ae9d..650e671 100644 --- a/docs/server-architecture.md +++ b/docs/server-architecture.md @@ -1,7 +1,7 @@ # MarkdownUtilities Server Architecture -- Status: mdtype and portable rules prerequisites implemented; server design can proceed -- Last updated: 2026-08-02 +- Status: native read-only Hummingbird 2 vertical slice implemented +- Last updated: 2026-08-09 This document records the architectural direction for exposing Markdown-backed data through conventional HTTP APIs. It distinguishes implemented foundations, decisions already made, the next recommended milestone, and questions that still require explicit design. @@ -31,6 +31,8 @@ The original type-system prerequisite has been met. The project now has enough p - native adapters can read filesystem records, construct logical paths, resolve project-confined schema resources, and write records atomically. - `MarkdownUtilitiesServer` defines the asynchronous, storage-neutral `RecordStore` contract and actor-backed `InMemoryRecordStore` reference implementation. - `MarkdownServerReadSnapshotBuilder` performs one bounded scan, reuses one analysis per candidate, and builds immutable generic record, membership, validity, identity, and lookup indexes. +- `MarkdownServerHTTPAdapter` registers generic Hummingbird 2 collection, item, and reserved logical-path handlers directly from the immutable plan. +- `md-utils-server` loads `.md-utils/server.yaml`, imports project Markdown recursively, builds one immutable snapshot, logs startup state, and runs with signal-aware lifecycle handling. The normative type design is documented in [RFC 0001: mdtype](rfcs/0001-mdtype.md). The portable dependency and runtime status is documented in [WebAssembly Support](webassembly.md). @@ -41,11 +43,11 @@ The following pieces do not yet exist: - a domain-resource projection and encoding contract; - a public schema-introspection API suitable for generators outside `MarkdownUtilitiesCore`; - OpenAPI generation from exposed resources; -- server request semantics, HTTP error mapping, resource pagination, and query behavior; -- native HTTP or Cloudflare Workers distributions; and +- resource pagination, filtering, and query behavior; +- the Cloudflare Workers distribution; and - persistent type indexes or a production storage backend. -The next blocker is native HTTP adaptation of the accepted generic read representation. +The native read-only adapter is complete. The next contract milestone is OpenAPI 3.1 generation from the same accepted endpoint plan. ## Decisions @@ -108,24 +110,32 @@ MarkdownUtilities MarkdownUtilitiesServer ├── MarkdownUtilitiesCore +├── MarkdownUtilities native loaders ├── Storage-neutral RecordStore and InMemoryRecordStore ├── Explicit versioned resource configuration ├── Deterministic EndpointPlan compilation ├── Transport-neutral route descriptions -└── Structured startup diagnostics +├── Native project composition and structured startup diagnostics +└── Generic Hummingbird 2 route adapter md-utils ├── MarkdownUtilities ├── ArgumentParser commands ├── CLI presentation and prompts └── Process exit behavior + +md-utils-server +├── MarkdownUtilitiesServer +├── ArgumentParser startup options +├── Hummingbird application and logging composition +└── Signal-aware process lifecycle ``` `MarkdownUtilitiesCore` runs on Linux and compiles to WebAssembly. It avoids direct filesystem access, process execution, CLI dependencies, and platform-specific APIs. Linux support is verified with `Dockerfile.core-linux`; WASI compilation and representative runtime behavior are verified with `scripts/build-wasm.sh`. `MarkdownUtilities` contains functionality appropriate for native platforms but unavailable or unsuitable in WebAssembly. The `md-utils` executable is not a server runtime and must not be spawned by server code. -The standalone `md-utils-server` executable will own configuration-file loading and Hummingbird 2 application composition. Its server configuration remains separate from the md-utils CLI configuration and `.md-utils.json`. +The standalone `md-utils-server` executable selects startup paths and owns Hummingbird 2 application composition. The server library performs configuration-file loading so it remains directly testable. Server configuration remains separate from the md-utils CLI configuration and `.md-utils/md-utils.json`. ### Treat Types and Rules as Distinct Server Inputs @@ -327,25 +337,25 @@ The implemented `RecordStore` is asynchronous, `Sendable`, and storage-neutral. Logical paths narrow enumeration but are not universal storage keys. The collection root retains pathless records. Cancellation is checked before work, during enumeration, and immediately before commits, and is propagated without wrapping. Paging is deterministic while store contents remain unchanged; immutable cross-mutation snapshots belong to the read-snapshot layer. -`InMemoryRecordStore` is the first implementation because it enables portable repository and contract tests without deciding the production database. A `FileRecordStore` should follow to validate ordinary folder-backed operation. SQLite should be evaluated after repository semantics are stable. +`InMemoryRecordStore` is the first implementation because it enables portable repository and contract tests without deciding the production database. The native executable recursively imports `.md` and `.markdown` files into this store once at startup; it does not claim persistent filesystem-store semantics. A `FileRecordStore` should follow if live folder-backed persistence is required. SQLite should be evaluated after repository semantics are stable. The initial repository may enumerate candidates and assess types on demand. Persistent type indexes are an optimization and must never become the source of truth. -## Recommended First Vertical Slice +## Implemented Native Read-Only Vertical Slice -The next implementation milestone should prove endpoint derivation with the smallest useful read path: +The first vertical slice proves endpoint derivation with the smallest useful read path: 1. Define one explicit exposed resource backed by a `Book` mdtype. 2. Define a minimal response projection for that resource. -3. Implement `InMemoryRecordStore` and the read-only portion of `TypedMarkdownRepository`. +3. Import recursively discovered Markdown into `InMemoryRecordStore` and build the generic immutable read snapshot. 4. Compile the resource into an immutable `EndpointPlan` containing `GET /books` and `GET /books/{id}`. 5. Register generic Hummingbird 2 handlers from that plan. 6. For type selection, return records that conform to `Book`; separately test rule selection with an expected type, where invalid candidates remain visible with `valid: false` and diagnostics. 7. Add in-process HTTP tests for success, not found, nonconforming stored data, overlapping type membership, route collisions, and revision exposure. -This slice intentionally excludes OpenAPI generation, create, update, delete, authentication, SQLite, persistent indexes, and Workers deployment. Its purpose is to validate the resource model, endpoint-planning boundary, and typed read repository before expensive infrastructure choices are made. OpenAPI 3.1 generation follows as a separate milestone using the accepted plan. +This slice intentionally excludes OpenAPI generation, create, update, delete, authentication, SQLite, persistent indexes, and Workers deployment. It validates the resource model, endpoint-planning boundary, collision-safe generic reads, and native transport before expensive infrastructure choices are made. OpenAPI 3.1 generation follows as a separate milestone using the accepted plan. -Success means the same repository fixture produces the same resource responses through direct library calls and Hummingbird without exposing Markdown storage details. +The same snapshot fixtures produce the same resource responses through direct library calls and in-process Hummingbird tests without exposing Markdown storage details. ## Native Server Architecture @@ -367,7 +377,13 @@ rules + mdtype definitions + resource configuration FileRecordStore or SQLiteRecordStore ``` -`MarkdownUtilitiesServer` owns plan construction and generic handlers. The `md-utils-server` executable performs startup composition and serves the registered plan through Hummingbird 2. No resource-specific Swift source is generated. +`MarkdownUtilitiesServer` owns plan construction and generic handlers. The `md-utils-server` executable performs startup composition and serves the registered plan through Hummingbird 2. No resource-specific Swift source is generated. The process accepts `--project-root`, `--config`, `--hostname`, and `--port`; it defaults to `.md-utils/server.yaml` and `127.0.0.1:8080`. + +At startup, the executable loads rule and mdtype definitions, recursively imports project Markdown while excluding `.md-utils/`, compiles the plan, and publishes one immutable snapshot. A filesystem change becomes visible only after restart. `runService()` handles `SIGINT` and `SIGTERM` through graceful lifecycle shutdown. + +Collection handlers return the complete selected resource. Item and logical-path handlers switch exhaustively over record, not-found, and conflict lookup results. All HTTP failures use a stable JSON error envelope; `409 Conflict` includes every candidate and never selects one arbitrarily. Invalid rule-selected candidates remain normal `200` representations with `valid: false` and diagnostics. Missing primary identities remain visible in collections but have no item lookup key. + +The startup scan and parsing cost is paid once. Concurrent handlers only read immutable arrays and indexes. The initial server deliberately has no response pagination or production persistence, so operators should treat it as a bounded-project distribution and restart it after content changes. Storage must remain replaceable so a maintainer can use an ordinary folder hierarchy, SQLite, or a future adapter appropriate to the deployment.