diff --git a/.github/workflows/contract-validation.yml b/.github/workflows/contract-validation.yml new file mode 100644 index 0000000..4f3f061 --- /dev/null +++ b/.github/workflows/contract-validation.yml @@ -0,0 +1,19 @@ +name: Provider Contract Validation + +on: + pull_request: + push: + branches: + - main + +jobs: + validate-tinynode-to-rerum: + uses: cubap/rerum_openapi/.github/workflows/seam-runtime-validation.yml@main + with: + contract_repo_ref: main + seam_manifest_path: seams/tinynode-to-rerum/manifest.yaml + target_base_url: https://store.rerum.io/v1 + compare_paths: true + compare_operations: true + fixture_file: test/contract-fixtures/tinynode-to-rerum.yaml + timeout_ms: 10000 diff --git a/.github/workflows/sync-core-provider-contract.yml b/.github/workflows/sync-core-provider-contract.yml new file mode 100644 index 0000000..6848ef8 --- /dev/null +++ b/.github/workflows/sync-core-provider-contract.yml @@ -0,0 +1,48 @@ +name: Sync Core Provider Contract + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - contracts/core-provider.openapi.yaml + - routes/** + +jobs: + sync-core-provider-contract: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout source repository + uses: actions/checkout@v4 + + - name: Checkout rerum_openapi + uses: actions/checkout@v4 + with: + repository: cubap/rerum_openapi + token: ${{ secrets.BRY_PAT }} + path: rerum_openapi + + - name: Copy provider contract baseline + run: | + cp contracts/core-provider.openapi.yaml rerum_openapi/seams/tinynode-to-rerum/openapi/baseline.openapi.yaml + + - name: Create sync pull request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.BRY_PAT }} + path: rerum_openapi + branch: automation/sync-rerum-core-provider-contract + delete-branch: true + commit-message: Sync RERUM core provider contract from rerum_server_nodejs + title: Sync RERUM core provider contract from rerum_server_nodejs + body: | + Automated sync from `${{ github.repository }}` at `${{ github.sha }}`. + + Source: + - `contracts/core-provider.openapi.yaml` + + Target: + - `seams/tinynode-to-rerum/openapi/baseline.openapi.yaml` diff --git a/__tests__/core_provider_contract.test.js b/__tests__/core_provider_contract.test.js new file mode 100644 index 0000000..b358a89 --- /dev/null +++ b/__tests__/core_provider_contract.test.js @@ -0,0 +1,111 @@ +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +const here = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(here, "..") +const apiRoutesPath = path.join(repoRoot, "routes", "api-routes.js") +const contractPath = path.join(repoRoot, "contracts", "core-provider.openapi.yaml") + +const skippedMountedRouters = new Set([ + "./static.js", + "./compatability.js" +]) + +function normalizeRoutePath(routePath) { + return routePath.replace(/\/:([A-Za-z0-9_]+)/g, "/{id}") +} + +function joinMountedPath(prefix, routePath) { + const suffix = routePath === "/" ? "" : routePath + return normalizeRoutePath(`${prefix}${suffix}`.replace(/\/+/g, "/")) +} + +function parseImports(source) { + const imports = new Map() + const importPattern = /^import\s+(\w+)\s+from\s+'(\.\/[^']+)';?$/gm + for (const match of source.matchAll(importPattern)) { + imports.set(match[1], match[2]) + } + return imports +} + +function parseMountedRouters(source, imports) { + const mounted = [] + const usePattern = /router\.use\('([^']+)',\s*(\w+)\)/g + for (const match of source.matchAll(usePattern)) { + const importPath = imports.get(match[2]) + if (!importPath || skippedMountedRouters.has(importPath)) { + continue + } + mounted.push({ + prefix: match[1], + filePath: path.join(repoRoot, "routes", importPath.replace("./", "")) + }) + } + return mounted +} + +function parseRouteOperations(filePath, prefix) { + const source = fs.readFileSync(filePath, "utf8") + const operations = new Set() + const routeBlockPattern = /router\.route\('([^']+)'\)([\s\S]*?)(?=\nrouter\.route\(|\nexport default)/g + for (const match of source.matchAll(routeBlockPattern)) { + const routePath = joinMountedPath(prefix, match[1]) + const methods = new Set() + for (const methodMatch of match[2].matchAll(/\.(get|post|put|patch|delete|head)\(/g)) { + methods.add(methodMatch[1].toUpperCase()) + } + for (const method of methods) { + operations.add(`${method} ${routePath}`) + } + } + return operations +} + +function parseDirectOperations(source) { + const operations = new Set() + const directPattern = /router\.(get|post|put|patch|delete|head)\('([^']+)'/g + for (const match of source.matchAll(directPattern)) { + if (match[2] === "/api") { + operations.add(`${match[1].toUpperCase()} ${match[2]}`) + } + } + return operations +} + +function getMountedCoreProviderOperations() { + const source = fs.readFileSync(apiRoutesPath, "utf8") + const imports = parseImports(source) + const operations = new Set(parseDirectOperations(source)) + for (const mountedRouter of parseMountedRouters(source, imports)) { + for (const operation of parseRouteOperations(mountedRouter.filePath, mountedRouter.prefix)) { + operations.add(operation) + } + } + return Array.from(operations).sort() +} + +function getContractOperations() { + const lines = fs.readFileSync(contractPath, "utf8").split("\n") + const operations = [] + let currentPath = "" + for (const line of lines) { + const pathMatch = line.match(/^ (\/[^:]+):\s*$/) + if (pathMatch) { + currentPath = pathMatch[1] + continue + } + const methodMatch = line.match(/^ (get|post|put|patch|delete|head):\s*$/) + if (methodMatch && currentPath) { + operations.push(`${methodMatch[1].toUpperCase()} ${currentPath}`) + } + } + return operations.sort() +} + +describe("core provider contract", () => { + it("matches the mounted core provider route surface", () => { + expect(getContractOperations()).toEqual(getMountedCoreProviderOperations()) + }) +}) diff --git a/contracts/core-provider.openapi.yaml b/contracts/core-provider.openapi.yaml new file mode 100644 index 0000000..a8070bc --- /dev/null +++ b/contracts/core-provider.openapi.yaml @@ -0,0 +1,382 @@ +openapi: 3.0.3 +info: + title: RERUM Core Provider Contract + version: 1.0.0 + description: Provider-maintained baseline for the core RERUM API surface. +servers: + - url: https://store.rerum.io/v1 +paths: + /api: + get: + summary: Describe the core API endpoints + operationId: describeCoreApi + responses: + '200': + description: Available endpoint summary + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /id/{id}: + get: + summary: Read object by id + operationId: getObjectById + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Object payload + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + head: + summary: Read object headers by id + operationId: headObjectById + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Object headers + /since/{id}: + get: + summary: Read updates since id + operationId: getSince + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Incremental updates + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + head: + summary: Read update headers since id + operationId: headSince + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Incremental update headers + /history/{id}: + get: + summary: Read object history by id + operationId: getHistory + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Version history + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + head: + summary: Read object history headers by id + operationId: headHistory + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Version history headers + /api/query: + post: + summary: Query objects + operationId: queryObjects + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Query results + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + head: + summary: Query object headers + operationId: headQueryObjects + responses: + '200': + description: Query result headers + /api/search: + post: + summary: Search objects by keywords + operationId: searchObjects + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + text/plain: + schema: + type: string + responses: + '200': + description: Search results + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + /api/search/phrase: + post: + summary: Search objects by phrase + operationId: searchObjectsByPhrase + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + text/plain: + schema: + type: string + responses: + '200': + description: Phrase search results + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + /api/create: + post: + summary: Create object + operationId: createObject + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Created object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /api/bulkCreate: + post: + summary: Create multiple objects + operationId: bulkCreateObjects + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + responses: + '200': + description: Created objects + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + /api/update: + put: + summary: Update object + operationId: updateObject + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Updated object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /api/bulkUpdate: + put: + summary: Update multiple objects + operationId: bulkUpdateObjects + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + responses: + '200': + description: Updated objects + content: + application/json: + schema: + $ref: '#/components/schemas/GenericArray' + /api/overwrite: + put: + summary: Overwrite object + operationId: overwriteObject + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Overwritten object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /api/patch: + patch: + summary: Patch object + operationId: patchObject + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Patched object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + post: + summary: Patch object via override-compatible POST + operationId: patchObjectViaPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Patched object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /api/set: + patch: + summary: Add properties to object + operationId: setObjectProperties + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Updated object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + post: + summary: Add properties to object via override-compatible POST + operationId: setObjectPropertiesViaPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Updated object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /api/unset: + patch: + summary: Remove properties from object + operationId: unsetObjectProperties + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Updated object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + post: + summary: Remove properties from object via override-compatible POST + operationId: unsetObjectPropertiesViaPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Updated object + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /api/delete: + delete: + summary: Delete object + operationId: deleteObject + responses: + '200': + description: Deletion result + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /api/delete/{id}: + delete: + summary: Delete object by id + operationId: deleteObjectById + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Deletion result + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + /api/release/{id}: + patch: + summary: Release object by id + operationId: releaseObject + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Release result + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' +components: + parameters: + ObjectId: + in: path + name: id + required: true + schema: + type: string + schemas: + GenericObject: + type: object + additionalProperties: true + GenericArray: + type: array + items: + $ref: '#/components/schemas/GenericObject' diff --git a/test/contract-fixtures/tinynode-to-rerum.yaml b/test/contract-fixtures/tinynode-to-rerum.yaml new file mode 100644 index 0000000..be20c4c --- /dev/null +++ b/test/contract-fixtures/tinynode-to-rerum.yaml @@ -0,0 +1,44 @@ +interactions: + - description: Read an existing object by id + path: /id/{id} + method: get + expectedStatus: "200" + response: + body: + "@id": https://store.rerum.io/v1/id/test123 + "@type": "http://www.w3.org/ns/oa#Annotation" + __rerum: + APIversion: "1.1.0" + generatedBy: https://store.rerum.io/agent + history: + prime: https://store.rerum.io/v1/id/test123 + next: [] + previous: "" + isReleased: "" + releaseDate: "" + releases: + next: [] + previous: "" + replaces: "" + + - description: Query objects + path: /api/query + method: post + expectedStatus: "200" + response: + body: + - "@id": https://store.rerum.io/v1/id/test123 + "@type": "http://www.w3.org/ns/oa#Annotation" + __rerum: + APIversion: "1.1.0" + generatedBy: https://store.rerum.io/agent + history: + prime: https://store.rerum.io/v1/id/test123 + next: [] + previous: "" + isReleased: "" + releaseDate: "" + releases: + next: [] + previous: "" + replaces: ""