openvibers/openvibe.games Commits
/openapi.json: the game server's public API as OpenAPI 3.1 (SEO-3) (#275)
Changes
@@@ -190,6 +190,21 @@ describe('crawl artifacts (plan T11)', () => {expect((await send('/app-icons/..%2F..%2Fpackage.json')).status).not.toBe(200)})++ it('serves /openapi.json (SEO-3): the contracts\' OpenAPI 3.1 document, without internal-only operations', async () => {++ const r = await send('/openapi.json')++ expect(r.status).toBe(200)++ expect(r.headers['content-type']).toBe('application/json; charset=utf-8')++ expect(r.headers['access-control-allow-origin']).toBe('*')++ const doc = JSON.parse(r.body) as { openapi: string; paths: Record<string, Record<string, { 'x-openvibe-visibility'?: string | string[] }>> }++ expect(doc.openapi).toMatch(/^3\.1/)++ const ops = Object.values(doc.paths).flatMap((item) => Object.values(item))++ expect(ops.length).toBeGreaterThan(0)++ for (const op of ops) {++ const vis = ([] as string[]).concat(op['x-openvibe-visibility'] ?? [])++ expect(vis.length > 0 && vis.every((v) => v === 'internal')).toBe(false)++ }++ })++it('serves /llms-full.txt: text/plain, the home page, and nothing robots keeps out', async () => {const r = await send('/llms-full.txt')expect(r.status).toBe(200)@@@ -335,6 +335,35 @@ export function homeJsonLdTags(): string {.join('\n')}++type OpenApiDoc = { openapi?: string; paths?: Record<string, Record<string, unknown>> }++const contractsOpenApi = createRequire(import.meta.url)('openvibe-contracts').openapi as { document(service: string): OpenApiDoc | null }++const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options'])++++/**++ * GET /openapi.json (SEO-3): the game server's public API as OpenAPI 3.1, generated from the contracts++ * (openvibe-contracts), without the operations they mark internal-only.++ */++export function openApiBody(): string | null {++ const doc = contractsOpenApi.document('games')++ if (!doc) return null++ const paths: Record<string, Record<string, unknown>> = {}++ for (const [p, item] of Object.entries(doc.paths ?? {})) {++ const kept: Record<string, unknown> = {}++ for (const [k, op] of Object.entries(item ?? {})) {++ if (!HTTP_METHODS.has(k)) {++ kept[k] = op++ continue++ }++ const raw = (op as { 'x-openvibe-visibility'?: string | string[] } | null)?.['x-openvibe-visibility']++ const vis = raw === undefined ? [] : ([] as string[]).concat(raw)++ if (vis.length && vis.every((v) => v === 'internal')) continue++ kept[k] = op++ }++ if (Object.keys(kept).some((k) => HTTP_METHODS.has(k))) paths[p] = kept++ }++ return JSON.stringify({ ...doc, paths })++}++function send(res: ServerResponse, type: string, body: string): void {res.writeHead(200, {'content-type': `${type}; charset=utf-8`,@@@ -356,6 +385,7 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolurl !== '/sitemap.xml' &&url !== '/llms.txt' &&url !== '/llms-full.txt' &&++ url !== '/openapi.json' &&url !== '/manifest.webmanifest' &&!icon) {@@@ -366,6 +396,17 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): boolres.end()return true}++ if (url === '/openapi.json') {++ const body = openApiBody()++ if (!body) {++ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })++ res.end('No API document.')++ return true++ }++ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=3600', 'access-control-allow-origin': '*' })++ res.end(rq.method === 'HEAD' ? undefined : body)++ return true++ }if (url === '/manifest.webmanifest') {send(res, 'application/manifest+json', manifestBody())return true
@@ -190,6 +190,21 @@ describe('crawl artifacts (plan T11)', () => {
@@ -190,6 +190,21 @@ describe('crawl artifacts (plan T11)', () => {
expect((await send('/app-icons/..%2F..%2Fpackage.json')).status).not.toBe(200)
expect((await send('/app-icons/..%2F..%2Fpackage.json')).status).not.toBe(200)
})
})
+ it('serves /openapi.json (SEO-3): the contracts\' OpenAPI 3.1 document, without internal-only operations', async () => {
+ const r = await send('/openapi.json')
+ expect(r.status).toBe(200)
+ expect(r.headers['content-type']).toBe('application/json; charset=utf-8')
+ expect(r.headers['access-control-allow-origin']).toBe('*')
+ const doc = JSON.parse(r.body) as { openapi: string; paths: Record<string, Record<string, { 'x-openvibe-visibility'?: string | string[] }>> }
+ expect(doc.openapi).toMatch(/^3\.1/)
+ const ops = Object.values(doc.paths).flatMap((item) => Object.values(item))
+ expect(ops.length).toBeGreaterThan(0)
+ for (const op of ops) {
+ const vis = ([] as string[]).concat(op['x-openvibe-visibility'] ?? [])
+ expect(vis.length > 0 && vis.every((v) => v === 'internal')).toBe(false)
+ }
+ })
+
it('serves /llms-full.txt: text/plain, the home page, and nothing robots keeps out', async () => {
it('serves /llms-full.txt: text/plain, the home page, and nothing robots keeps out', async () => {
const r = await send('/llms-full.txt')
const r = await send('/llms-full.txt')
expect(r.status).toBe(200)
expect(r.status).toBe(200)
@@ -335,6 +335,35 @@ export function homeJsonLdTags(): string {
@@ -335,6 +335,35 @@ export function homeJsonLdTags(): string {
.join('\n')
.join('\n')
}
}
+type OpenApiDoc = { openapi?: string; paths?: Record<string, Record<string, unknown>> }
+const contractsOpenApi = createRequire(import.meta.url)('openvibe-contracts').openapi as { document(service: string): OpenApiDoc | null }
+const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options'])
+
+/**
+ * GET /openapi.json (SEO-3): the game server's public API as OpenAPI 3.1, generated from the contracts
+ * (openvibe-contracts), without the operations they mark internal-only.
+ */
+export function openApiBody(): string | null {
+ const doc = contractsOpenApi.document('games')
+ if (!doc) return null
+ const paths: Record<string, Record<string, unknown>> = {}
+ for (const [p, item] of Object.entries(doc.paths ?? {})) {
+ const kept: Record<string, unknown> = {}
+ for (const [k, op] of Object.entries(item ?? {})) {
+ if (!HTTP_METHODS.has(k)) {
+ kept[k] = op
+ continue
+ }
+ const raw = (op as { 'x-openvibe-visibility'?: string | string[] } | null)?.['x-openvibe-visibility']
+ const vis = raw === undefined ? [] : ([] as string[]).concat(raw)
+ if (vis.length && vis.every((v) => v === 'internal')) continue
+ kept[k] = op
+ }
+ if (Object.keys(kept).some((k) => HTTP_METHODS.has(k))) paths[p] = kept
+ }
+ return JSON.stringify({ ...doc, paths })
+}
+
function send(res: ServerResponse, type: string, body: string): void {
function send(res: ServerResponse, type: string, body: string): void {
res.writeHead(200, {
res.writeHead(200, {
'content-type': `${type}; charset=utf-8`,
'content-type': `${type}; charset=utf-8`,
@@ -356,6 +385,7 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
@@ -356,6 +385,7 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
url !== '/sitemap.xml' &&
url !== '/sitemap.xml' &&
url !== '/llms.txt' &&
url !== '/llms.txt' &&
url !== '/llms-full.txt' &&
url !== '/llms-full.txt' &&
+ url !== '/openapi.json' &&
url !== '/manifest.webmanifest' &&
url !== '/manifest.webmanifest' &&
!icon
!icon
) {
) {
@@ -366,6 +396,17 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
@@ -366,6 +396,17 @@ export function discoveryHandler(rq: IncomingMessage, res: ServerResponse): bool
res.end()
res.end()
return true
return true
}
}
+ if (url === '/openapi.json') {
+ const body = openApiBody()
+ if (!body) {
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
+ res.end('No API document.')
+ return true
+ }
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'public, max-age=3600', 'access-control-allow-origin': '*' })
+ res.end(rq.method === 'HEAD' ? undefined : body)
+ return true
+ }
if (url === '/manifest.webmanifest') {
if (url === '/manifest.webmanifest') {
send(res, 'application/manifest+json', manifestBody())
send(res, 'application/manifest+json', manifestBody())
return true
return true