From 25fc2c9057a083fa7b72ec2f2c0c2f1dbf29b006 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 19:40:33 +0200 Subject: [PATCH] feat(services): implement OpenAPI specification service and in-memory caching --- src/services/index.ts | 1 + src/services/openapi.ts | 218 ++++++++++++++++++++++++++++++++++++++++ tests/openapi.test.ts | 153 ++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 src/services/openapi.ts create mode 100644 tests/openapi.test.ts diff --git a/src/services/index.ts b/src/services/index.ts index 836b90d..fbb6d45 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -7,3 +7,4 @@ export * from "./projects.ts"; export * from "./work-packages.ts"; export * from "./queries.ts"; export * from "./metadata.ts"; +export * from "./openapi.ts"; diff --git a/src/services/openapi.ts b/src/services/openapi.ts new file mode 100644 index 0000000..4b746a9 --- /dev/null +++ b/src/services/openapi.ts @@ -0,0 +1,218 @@ +/** + * OpenAPI 3.0 Introspection Domain Service. + */ + +import type { OpenProjectClient } from "../client/api-client.ts"; +import { OpenProjectNotFoundError } from "../client/errors.ts"; +import { resolveClient } from "./helper.ts"; + +export interface OpenApiQueryOptions { + path?: string; + tag?: string; + schema?: string; + full?: boolean; + refresh?: boolean; +} + +export interface OpenApiSummary { + title: string; + version: string; + openapi: string; + totalPaths: number; + tags: Array<{ name: string; endpointCount: number }>; + availablePaths: string[]; + instructions: string; +} + +interface RawOpenApiOperation { + tags?: string[]; + summary?: string; + operationId?: string; + parameters?: Array<{ name: string; in?: string; [key: string]: unknown }>; + responses?: Record; + [key: string]: unknown; +} + +interface RawOpenApiDoc { + openapi: string; + info: { title: string; version: string; description?: string }; + paths: Record>; + components?: { + schemas?: Record; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +// In-memory cache keyed by baseUrl +const openApiCache = new Map(); + +/** + * Clears the in-memory OpenAPI cache. + */ +export function clearOpenApiCache(): void { + openApiCache.clear(); +} + +/** + * Normalizes an API path for flexible querying. + */ +function normalizePath(path: string): string { + let clean = path.trim(); + if (!clean.startsWith("/")) { + clean = `/${clean}`; + } + if (!clean.startsWith("/api/v3")) { + clean = `/api/v3${clean}`; + } + return clean.toLowerCase(); +} + +/** + * Fetches and filters OpenProject OpenAPI 3.0 specification. + */ +export async function getOpenApiSpec( + options?: OpenApiQueryOptions, + client?: OpenProjectClient +): Promise { + const activeClient = resolveClient(client); + const cacheKey = activeClient.baseUrl; + + if (options?.refresh) { + openApiCache.delete(cacheKey); + } + + let doc = openApiCache.get(cacheKey); + if (!doc) { + doc = await activeClient.get("openapi.json"); + openApiCache.set(cacheKey, doc); + } + + // 1. Full mode + if (options?.full) { + return doc; + } + + const allPaths = Object.keys(doc.paths || {}); + + // 2. Specific path lookup + if (options?.path) { + const target = normalizePath(options.path); + const matchedKey = + allPaths.find((p) => p.toLowerCase() === target) || + allPaths.find((p) => p.toLowerCase().endsWith(target.replace(/^\/api\/v3/, ""))); + + if (!matchedKey || !doc.paths[matchedKey]) { + const sample = allPaths.slice(0, 10).join(", "); + throw new OpenProjectNotFoundError( + `OpenAPI path '${options.path}' not found. Available paths include: ${sample}...`, + { errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" } + ); + } + + return { + path: matchedKey, + operations: doc.paths[matchedKey], + }; + } + + // 3. Tag filtering + if (options?.tag) { + const targetTag = options.tag.trim().toLowerCase(); + const matchedPaths: Record = {}; + + for (const [pathKey, methods] of Object.entries(doc.paths || {})) { + let pathMatched = false; + for (const op of Object.values(methods)) { + if ( + op && + typeof op === "object" && + Array.isArray(op.tags) && + op.tags.some((t: string) => t.toLowerCase() === targetTag) + ) { + pathMatched = true; + break; + } + } + if (pathMatched) { + matchedPaths[pathKey] = methods; + } + } + + const matchedCount = Object.keys(matchedPaths).length; + if (matchedCount === 0) { + const availableTags = Array.from( + new Set( + Object.values(doc.paths || {}).flatMap((methods) => + Object.values(methods).flatMap((op) => + op && typeof op === "object" && Array.isArray(op.tags) ? op.tags : [] + ) + ) + ) + ).sort(); + throw new OpenProjectNotFoundError( + `OpenAPI tag '${options.tag}' not found. Available tags include: ${availableTags.slice(0, 15).join(", ")}...`, + { errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" } + ); + } + + return { + tag: options.tag, + totalPaths: matchedCount, + paths: matchedPaths, + }; + } + + // 4. Schema model lookup + if (options?.schema) { + const targetSchema = options.schema.trim().toLowerCase(); + const schemas = doc.components?.schemas || {}; + const matchedKey = Object.keys(schemas).find((s) => s.toLowerCase() === targetSchema); + + if (!matchedKey || !schemas[matchedKey]) { + const availableSchemas = Object.keys(schemas).slice(0, 15).join(", "); + throw new OpenProjectNotFoundError( + `OpenAPI schema '${options.schema}' not found in components.schemas. Available schemas include: ${availableSchemas}...`, + { errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" } + ); + } + + return { + schemaName: matchedKey, + schema: schemas[matchedKey], + }; + } + + // 5. Default: Summary Overview + const tagCounts = new Map(); + for (const methods of Object.values(doc.paths || {})) { + const seenInPath = new Set(); + for (const op of Object.values(methods)) { + if (op && typeof op === "object" && Array.isArray(op.tags)) { + for (const t of op.tags) { + seenInPath.add(t); + } + } + } + for (const t of seenInPath) { + tagCounts.set(t, (tagCounts.get(t) || 0) + 1); + } + } + + const sortedTags = Array.from(tagCounts.entries()) + .map(([name, endpointCount]) => ({ name, endpointCount })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const summary: OpenApiSummary = { + title: doc.info?.title || "OpenProject API V3", + version: doc.info?.version || "3", + openapi: doc.openapi || "3.0.0", + totalPaths: allPaths.length, + tags: sortedTags, + availablePaths: allPaths, + instructions: + "Call openproject_get_openapi_spec with 'path' (e.g. '/api/v3/work_packages'), 'tag' (e.g. 'Work Packages'), or 'schema' (e.g. 'WorkPackageModel') to inspect detailed schemas and operations without requesting the full payload.", + }; + + return summary; +} diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts new file mode 100644 index 0000000..b2c2856 --- /dev/null +++ b/tests/openapi.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test, beforeEach } from "bun:test"; +import { + getOpenApiSpec, + clearOpenApiCache, + type OpenApiSummary, +} from "../src/services/openapi.ts"; +import { OpenProjectClient } from "../src/client/api-client.ts"; +import { OpenProjectNotFoundError } from "../src/client/errors.ts"; + +describe("OpenApi Service", () => { + const mockSpec = { + openapi: "3.0.3", + info: { title: "OpenProject API V3 (Test)", version: "3" }, + paths: { + "/api/v3/work_packages": { + get: { + summary: "List work packages", + operationId: "list_work_packages", + tags: ["Work Packages"], + parameters: [{ name: "offset", in: "query" }], + responses: { "200": { description: "OK" } }, + }, + post: { + summary: "Create work package", + operationId: "create_work_package", + tags: ["Work Packages"], + responses: { "201": { description: "Created" } }, + }, + }, + "/api/v3/projects": { + get: { + summary: "List projects", + operationId: "list_projects", + tags: ["Projects"], + responses: { "200": { description: "OK" } }, + }, + }, + }, + components: { + schemas: { + WorkPackageModel: { + type: "object", + properties: { id: { type: "integer" }, subject: { type: "string" } }, + }, + }, + }, + }; + + let getCallCount = 0; + const mockClient = { + baseUrl: "http://mock-openproject.local", + get: async (path: string) => { + getCallCount++; + if (path.includes("openapi.json")) { + return mockSpec; + } + throw new Error("Not found"); + }, + } as unknown as OpenProjectClient; + + beforeEach(() => { + clearOpenApiCache(); + getCallCount = 0; + }); + + test("summary mode returns overview and instructions when no filters passed", async () => { + const result = (await getOpenApiSpec({}, mockClient)) as OpenApiSummary; + expect(result.title).toBe("OpenProject API V3 (Test)"); + expect(result.version).toBe("3"); + expect(result.openapi).toBe("3.0.3"); + expect(result.totalPaths).toBe(2); + expect(result.tags).toHaveLength(2); + expect(result.tags[0]).toEqual({ name: "Projects", endpointCount: 1 }); + expect(result.tags[1]).toEqual({ name: "Work Packages", endpointCount: 1 }); + expect(result.availablePaths).toContain("/api/v3/work_packages"); + expect(result.instructions).toContain("Call openproject_get_openapi_spec"); + }); + + test("caches spec in memory and avoids duplicate HTTP GET calls", async () => { + await getOpenApiSpec({}, mockClient); + expect(getCallCount).toBe(1); + + await getOpenApiSpec({}, mockClient); + expect(getCallCount).toBe(1); // Served from cache + + await getOpenApiSpec({ refresh: true }, mockClient); + expect(getCallCount).toBe(2); // Evicted and re-fetched + }); + + test("path mode resolves exact and normalized path endpoints", async () => { + const res1 = (await getOpenApiSpec({ path: "/api/v3/work_packages" }, mockClient)) as { + path: string; + operations: Record; + }; + expect(res1.path).toBe("/api/v3/work_packages"); + expect(res1.operations.get).toBeDefined(); + expect(res1.operations.post).toBeDefined(); + + // Normalization test without /api/v3 prefix + const res2 = (await getOpenApiSpec({ path: "work_packages" }, mockClient)) as { + path: string; + operations: Record; + }; + expect(res2.path).toBe("/api/v3/work_packages"); + }); + + test("path mode throws OpenProjectNotFoundError when path does not exist", async () => { + expect(getOpenApiSpec({ path: "/api/v3/nonexistent" }, mockClient)).rejects.toThrow( + OpenProjectNotFoundError + ); + }); + + test("tag mode filters paths by functional domain tag", async () => { + const result = (await getOpenApiSpec({ tag: "Work Packages" }, mockClient)) as { + tag: string; + totalPaths: number; + paths: Record; + }; + expect(result.tag).toBe("Work Packages"); + expect(result.totalPaths).toBe(1); + expect(result.paths["/api/v3/work_packages"]).toBeDefined(); + expect(result.paths["/api/v3/projects"]).toBeUndefined(); + }); + + test("tag mode throws OpenProjectNotFoundError when tag does not exist", async () => { + expect(getOpenApiSpec({ tag: "NonExistentTag" }, mockClient)).rejects.toThrow( + OpenProjectNotFoundError + ); + }); + + test("schema mode extracts component model from components.schemas", async () => { + const result = (await getOpenApiSpec({ schema: "WorkPackageModel" }, mockClient)) as { + schemaName: string; + schema: { type: string; properties: Record }; + }; + expect(result.schemaName).toBe("WorkPackageModel"); + expect(result.schema.type).toBe("object"); + expect(result.schema.properties.subject).toBeDefined(); + }); + + test("schema mode throws OpenProjectNotFoundError when schema does not exist", async () => { + expect(getOpenApiSpec({ schema: "UnknownModel" }, mockClient)).rejects.toThrow( + OpenProjectNotFoundError + ); + }); + + test("full mode returns the complete raw specification", async () => { + const result = (await getOpenApiSpec({ full: true }, mockClient)) as typeof mockSpec; + expect(result.openapi).toBe("3.0.3"); + expect(result.paths).toBeDefined(); + expect(result.components).toBeDefined(); + }); +});