Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/contract-validation.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions .github/workflows/sync-core-provider-contract.yml
Original file line number Diff line number Diff line change
@@ -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`
111 changes: 111 additions & 0 deletions __tests__/core_provider_contract.test.js
Original file line number Diff line number Diff line change
@@ -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())
})
})
Loading