diff --git a/docs/api-contracts.md b/docs/api-contracts.md new file mode 100644 index 0000000000..76cf4b9cde --- /dev/null +++ b/docs/api-contracts.md @@ -0,0 +1,102 @@ +# Offline API contract inventory + +`inspect_api_contract` / `rea inspect-api-contract` reads one local OpenAPI +3.0, 3.1 or 3.2 JSON document. It inventories inline operation slots under +`paths`; the facts describe declarations, not implemented, reachable or +authorized runtime behavior. YAML, AsyncAPI, callbacks/webhooks, schema graphs, +contract diffs and traffic correlation are outside this version. + +The importer never resolves references, opens sibling documents, evaluates +examples or contacts declared servers. OpenAPI 3.2 `query` and +`additionalOperations` follow the [Path Item contract](https://spec.openapis.org/oas/v3.2.1.html#path-item-object), +including case-sensitive additional HTTP method spelling. + +## Select a useful answer + +MCP defaults to a summary retaining the complete inventory. An optional view +returns selected facts immediately, taking precedence over `detail`: + +```json +{ + "path": "/artifacts/openapi.json", + "view": { + "kind": "api-contract", + "collection": "operations", + "methods": ["GET"], + "limit": 20 + } +} +``` + +`operations` supports exact `operation_ids`, `operation_keys`, `methods`, +`paths`, `declared_ids` and `states` filters. Filters combine by intersection; +empty arrays select nothing. `fields` chooses columns; identity, source pointer, +state and unknown reason remain. The other collections are +`path-item-references` (optional `reference_ids`) and `diagnostics` (optional +`codes`), also with fields and offset/limit. The default page contains 64 rows. + +Query a retained result with `inspect_analysis_view`, using the same view and +`source: {"kind":"retained-evidence","evidence_id":"..."}`. Copy the complete +parent ID from `normalized_result.parent_evidence_id`, not the summary's own ID. +These pages use immutable retained facts without rereading the specification. +Complete Evidence remains available through `get_evidence_bundle`. + +CLI defaults to complete Evidence. For a selected answer, save that complete +Evidence in an absent bundle path: + +```sh +rea inspect-api-contract ./openapi.json --detail summary \ + --view '{"kind":"api-contract","collection":"operations","methods":["GET"],"limit":20}' \ + --evidence_output ./contract.evidence.json --json +``` + +Later `rea inspect-analysis-view` can read that bundle through an exact +`evidence-file` source selection, even after the original specification is +unavailable. Publication never overwrites an existing file. CLI preserves its +original path spelling in `caller_input_path`; analysis locations and MCP inputs +use absolute filesystem paths. + +## Identity, provenance and coverage + +Each operation has an artifact-scoped `operation_id`, an exact method/path +`operation_key`, an optional `declared_id` from `operationId`, and its original +JSON Pointer. Keys can correlate exact declarations in a caller-selected pair +of revisions; they do not establish equivalence across unrelated APIs, renamed +path templates or runtime handlers. Declared IDs are not assumed unique. +The artifact includes the verified byte length, SHA-256 and selected path. + +Malformed siblings retain useful declarations plus diagnostics. A Path Item +`$ref`, including a local fragment, remains unresolved; adjacent inline slots +are unknown because referenced fields may overlap. Missing or malformed paths +are explicit, and missing required OpenAPI 3.0 paths does not become a complete +empty inventory. `source_coverage` is limited to inline paths and records that +reference resolution and full schema validation were not performed. Page +exhaustion describes the selected rows and never upgrades source coverage. + +Structured transport userinfo credentials are excluded from reference URIs; +`credential_redacted` reports that change, and an ambiguous credential span leaves +the displayed reference unknown. Ordinary reference query/fragment text and +names are retained. Original bytes remain in the caller's artifact; complete +inventory Evidence does not embed the entire source JSON. Duplicate JSON members +use the shared parser's last-value-wins behavior, recorded in source coverage. + +If decoding or later validation fails after a verified read, the typed error +retains a compact `file-snapshot-hash` observation with the exact path, digest +and byte length. Syntax diagnostics do not quote source JSON. Cancellation +does not replace a parser failure that has already completed. + +## Resource bounds and verification + +The file-byte allowance and conservative expanded-facts allowance are each +32 MiB resource policies, not OpenAPI format limits. `max_file_bytes` can lower +the input allowance. Native JSON parsing additionally checks available heap +against the shared representation-expansion estimate before decoding. The +shared JSON nesting limit is 1,000, including uninterpreted schema regions. +Capacity refusals are typed; reply paging does not increase parser allowances. + +The committed [Petstore fixture](https://github.com/morluto/rea/blob/b7c8caedd4166e6032c49024cdc6fb1154f1a2df/tests/fixtures/openapi/README.md) is an +original document from the published application, replayed offline through +public CLI/MCP tests. These check advertised schemas, complete/selected parity, +retained paging, partial references, zero requests to a loopback reference +server and unchanged source bytes. They do not establish runtime endpoint +behavior or full OpenAPI conformance. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 07e1afd8f7..93e9be79e3 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -572,6 +572,18 @@ that the response budget accounts for. It does not alter `content` or such as requiring approval for process-launching or device-mutating tools after target content was read. +## Offline API contract declarations + +`inspect_api_contract` reads a local OpenAPI 3.0/3.1/3.2 JSON document without +an active target or any requests to declared servers or references. Default +summary delivery retains complete declaration Evidence; an optional +`api-contract` view selects operations, unresolved Path Items or diagnostics in +this call. Exact filters, field selection and paging share the retained +`inspect_analysis_view` projector. Coverage describes inline paths only and +never establishes runtime implementation or full specification conformance. +See [offline API contracts](api-contracts.md) for identities, source pointers, +resource bounds and CLI publication. + ## Retained application Evidence inputs `analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, diff --git a/src/application/Utf8JsonInput.ts b/src/application/Utf8JsonInput.ts index 3844fd6454..6981a3146e 100644 --- a/src/application/Utf8JsonInput.ts +++ b/src/application/Utf8JsonInput.ts @@ -92,5 +92,15 @@ export const parseUtf8Json = ( cause: undefined, userMessage: JSON_BYTE_ORDER_MARK_MESSAGE, }; - return safeParseJson(text); + const parsed = safeParseJson(text); + // Native syntax errors can quote source snippets containing transport + // credentials. Keep the original exception internal, never in public text. + return parsed.ok + ? parsed + : { + ok: false, + error: + "JSON input is not valid JSON. Check syntax in the selected file.", + cause: parsed.cause, + }; }; diff --git a/src/application/apiContract/ApiContractRead.ts b/src/application/apiContract/ApiContractRead.ts new file mode 100644 index 0000000000..1382042a1e --- /dev/null +++ b/src/application/apiContract/ApiContractRead.ts @@ -0,0 +1,133 @@ +import { + artifactFileCancellationCause, + artifactFilePrimaryFacts, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + projectStableArtifactFailure, + stableArtifactInputRecovery, +} from "../../artifacts/StableArtifactFailure.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; +import { + API_CONTRACT_OPERATION as operation, + API_CONTRACT_PROVIDER, +} from "../../domain/apiContract/apiContract.js"; +import type { InspectApiContractInput } from "../../domain/apiContract/apiContractQuery.js"; +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { err, ok } from "../../domain/result.js"; + +const inputFailure = (path: string, cause: unknown, message: string) => + new AnalysisInputError( + operation, + { + cause, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, + [ + { + path: ["path"], + reason: "invalid_format", + message: `${message} (${path})`, + }, + ], + ); + +const readFailure = ( + failure: unknown, + input: InspectApiContractInput, +): AnalysisError => { + const { cause, origin } = artifactFilePrimaryFacts(failure); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "cancelled") + return new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + }); + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError( + operation, + input.path, + cause.message, + { cause }, + ); + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + operation, + cause.boundary ?? "file-size", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: + "Select a complete document within max_file_bytes; increasing reply limits or paging does not change this file admission.", + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return inputFailure(input.path, cause, cause.message); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(operation, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return inputFailure( + input.path, + cause, + "Selected API document could not be read", + ); + } + return new ArtifactOperationError( + operation, + "io", + undefined, + "API document read failed.", + { cause }, + ); +}; + +/** The existing owner verifies exactly the bytes whose SHA-256 the inventory cites. */ +export const readApiContract = async ( + input: InspectApiContractInput, + resources: ArtifactResourceScope, + signal?: AbortSignal, +) => { + try { + return ok( + await resources.run(() => + readStableArtifact( + input.path, + input.max_file_bytes, + signal, + undefined, + resources, + ), + ), + ); + } catch (cause: unknown) { + return err( + projectStableArtifactFailure( + cause, + readFailure(cause, input), + input.path, + API_CONTRACT_PROVIDER.id, + operation, + ), + ); + } +}; diff --git a/src/application/apiContract/ApiContractService.test.ts b/src/application/apiContract/ApiContractService.test.ts new file mode 100644 index 0000000000..dee612195d --- /dev/null +++ b/src/application/apiContract/ApiContractService.test.ts @@ -0,0 +1,321 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { apiContractSchema } from "../../domain/apiContract/apiContract.js"; +import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { inspectApiContractEvidence } from "./ApiContractService.js"; +import * as readOwner from "./ApiContractRead.js"; +import * as parser from "../../domain/apiContract/parseApiContract.js"; + +const fixture = new URL( + "../../../tests/fixtures/openapi/petstore.json.txt", + import.meta.url, +); +const inputFile = async (bytes: Uint8Array | string) => { + const root = await createTestTempDirectory("rea-api-contract-"); + const path = join(root, "contract.json"); + await writeFile(path, bytes); + return { root, path }; +}; + +it("binds the published source bytes to complete Evidence independently of reply selection", async () => { + const bytes = await readFile(fixture); + const { path } = await inputFile(bytes); + const resources = new ArtifactResourceScope(); + try { + const results = await Promise.all([ + inspectApiContractEvidence({ path }, resources), + inspectApiContractEvidence({ path, detail: "complete" }, resources), + inspectApiContractEvidence( + { + path, + view: { + kind: "api-contract", + collection: "operations", + fields: [], + limit: 1, + }, + }, + resources, + ), + ]); + for (const result of results) expect(result.ok).toBe(true); + const [summary, complete, page] = results; + if (!summary?.ok || !complete?.ok || !page?.ok) + throw new Error("Expected verified imports"); + expect(summary.value.evidence_id).toBe(complete.value.evidence_id); + expect(page.value.evidence_id).toBe(complete.value.evidence_id); + const value = apiContractSchema.parse(complete.value.normalized_result); + expect(value.artifact).toEqual({ + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + expect(value.summary.declared).toBe(19); + expect(complete.value.operation).toBe("inspect_api_contract"); + expect(complete.value.parameters).not.toHaveProperty("detail"); + expect(complete.value.parameters).not.toHaveProperty("view"); + expect((await readFile(path)).equals(bytes)).toBe(true); + } finally { + await resources.close(); + } +}); + +it.each([ + [Buffer.from([255]), "not valid UTF-8"], + [Buffer.from("{broken"), "JSON"], + [Buffer.from("\uFEFF{}"), "byte-order mark"], +])( + "reports malformed UTF-8/JSON without rewriting source %#", + async (bytes, reason) => { + const { path } = await inputFile(bytes); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence({ path }, resources); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisInputError"); + expect(JSON.stringify(result.error)).toContain(reason); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } + expect((await readFile(path)).equals(bytes)).toBe(true); + } finally { + await resources.close(); + } + }, +); + +it("rejects the selected physical byte budget before parsing", async () => { + const { path } = await inputFile("{bad json}"); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence( + { path, max_file_bytes: 4 }, + resources, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + if (result.error instanceof AnalysisResourceConstraintError) + expect(result.error.operation).toBe("inspect_api_contract"); + } + } finally { + await resources.close(); + } +}); + +it("bounds nesting even in schema regions omitted from the inventory", async () => { + let nested: unknown = {}; + for (let depth = 0; depth < 1001; depth++) nested = { child: nested }; + const { path } = await inputFile( + JSON.stringify({ + openapi: "3.1.2", + info: { title: "Nested", version: "1" }, + paths: {}, + components: nested, + }), + ); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence({ path }, resources); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisInputError"); + expect(JSON.stringify(result.error)).toContain("maximum nesting depth"); + expect(result.error.partialObservation).toMatchObject({ + kind: "file-snapshot-hash", + path, + }); + } + } finally { + await resources.close(); + } +}); + +it("rejects relative, missing and non-regular selections at the input owner", async () => { + const { root } = await inputFile("{}"); + const directory = join(root, "directory"); + await mkdir(directory); + const resources = new ArtifactResourceScope(); + try { + for (const path of [ + "relative.json", + join(root, "missing.json"), + directory, + ]) { + const result = await inspectApiContractEvidence({ path }, resources); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisInputError"); + } + } finally { + await resources.close(); + } +}); + +it.skipIf(process.platform === "win32")( + "preserves an actionable regular-file retry for a selected symlink", + async () => { + const { root, path } = await inputFile("{}"); + const link = join(root, "linked.json"); + await symlink(path, link); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence( + { path: link }, + resources, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisInputError"); + expect(JSON.stringify(result.error)).toContain(path); + } + } finally { + await resources.close(); + } + }, +); + +it("honors pre-cancellation with an opaque caller reason", async () => { + const controller = new AbortController(); + const reason = { _tag: "ArtifactReaderFailure", reason: "integrity" }; + controller.abort(reason); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence( + { path: "/unused.json" }, + resources, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBeUndefined(); + } + } finally { + await resources.close(); + } +}); + +it("keeps native JSON source excerpts internal while preserving the failure cause", async () => { + const bytes = '{"$ref":"https://u:p@h","":NaN}'; + const { path } = await inputFile(bytes); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence({ path }, resources); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected a syntax refusal"); + expect(result.error.cause).toBeInstanceOf(SyntaxError); + expect(JSON.stringify(result.error)).not.toContain("u:p@h"); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(bytes), + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } finally { + await resources.close(); + } +}); + +it.each([ + [{ openapi: "2.0" }, "AnalysisUnsupportedTargetError"], + [ + { + openapi: "3.1.2", + info: { title: "a".repeat(3 * 1024 * 1024), version: "1" }, + }, + "AnalysisResourceConstraintError", + ], +])( + "keeps a completed parser failure ahead of queued cancellation %#", + async (document, tag) => { + const bytes = JSON.stringify(document); + const { path } = await inputFile(bytes); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const realParse = parser.parseApiContractSteps; + const scheduled = vi + .spyOn(parser, "parseApiContractSteps") + .mockImplementationOnce(function* (...args) { + const result = yield* realParse(...args); + expect(result.ok).toBe(false); + setImmediate(() => controller.abort("after-parser-failure")); + return result; + }); + try { + const result = await inspectApiContractEvidence({ path }, resources, { + signal: controller.signal, + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(controller.signal.aborted).toBe(true); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected the parser failure"); + expect(result.error._tag).toBe(tag); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(bytes), + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } finally { + scheduled.mockRestore(); + await resources.close(); + } + }, +); + +it("cancels a cooperative document traversal after the actual verified file read", async () => { + const paths = Object.fromEntries( + Array.from({ length: 3000 }, (_, index) => [ + `/items/${index}`, + { get: { operationId: String(index) } }, + ]), + ); + const bytes = JSON.stringify({ + openapi: "3.1.2", + info: { title: "Cancellation", version: "1" }, + paths, + }); + const { path } = await inputFile(bytes); + const controller = new AbortController(); + const reason = { stage: "after-source-read" }; + const realRead = readOwner.readApiContract; + const scheduled = vi + .spyOn(readOwner, "readApiContract") + .mockImplementationOnce(async (...args) => { + const loaded = await realRead(...args); + expect(loaded.ok).toBe(true); + setImmediate(() => controller.abort(reason)); + return loaded; + }); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence({ path }, resources, { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(bytes), + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } + expect(await readFile(path, "utf8")).toBe(bytes); + } finally { + scheduled.mockRestore(); + await resources.close(); + } +}); diff --git a/src/application/apiContract/ApiContractService.ts b/src/application/apiContract/ApiContractService.ts new file mode 100644 index 0000000000..3802f225cc --- /dev/null +++ b/src/application/apiContract/ApiContractService.ts @@ -0,0 +1,178 @@ +import { isAbsolute } from "node:path"; +import { setImmediate } from "node:timers/promises"; +import { getHeapStatistics } from "node:v8"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { jsonValueValidationSteps } from "../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + API_CONTRACT_OPERATION as operation, + API_CONTRACT_PROVIDER, + type ApiContractArtifact, +} from "../../domain/apiContract/apiContract.js"; +import { + inspectApiContractInputSchema, + type InspectApiContractInput, +} from "../../domain/apiContract/apiContractQuery.js"; +import { parseApiContractSteps } from "../../domain/apiContract/parseApiContract.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; +import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js"; +import { parseUtf8Json } from "../Utf8JsonInput.js"; +import { readApiContract } from "./ApiContractRead.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const inputFailure = (message: string, cause?: unknown) => + new AnalysisInputError(operation, { cause }, [ + { path: ["path"], reason: "invalid_format", message }, + ]); + +/** Cooperative traversals release their provisional state on cancellation. */ +const completeSteps = async ( + steps: Generator>, + signal?: AbortSignal, +): Promise> => { + let completed = false; + try { + for (;;) { + if (signal?.aborted) + return err( + new AnalysisCancelledError(operation, { cause: signal.reason }), + ); + const next = steps.next(); + if (next.done) { + completed = true; + if (!next.value.ok) return next.value; + // Include cancellation queued during the final synchronous projection. + await setImmediate(); + return signal?.aborted + ? err(new AnalysisCancelledError(operation, { cause: signal.reason })) + : next.value; + } + await setImmediate(); + } + } finally { + if (!completed) steps.return(undefined as never); + } +}; + +const decodeDocument = ( + bytes: Buffer, + path: string, +): Result => { + const required = nativeJsonParseRequiredBytes(bytes.length); + const available = getHeapStatistics().total_available_size; + if (required > available) + return err( + new AnalysisResourceConstraintError( + operation, + "memory", + "Insufficient parser headroom for the admitted JSON representation expansion.", + { + input_bytes: bytes.length, + required_bytes: required, + available_heap_bytes: available, + }, + { + remediationAction: + "Select a smaller complete document or free parser memory; paging a reply does not reduce JSON parsing work.", + }, + ), + ); + try { + const parsed = parseUtf8Json(bytes, operation, path); + return parsed.ok + ? ok(parsed.value) + : err(inputFailure(parsed.error, parsed.cause)); + } catch (cause: unknown) { + if (cause instanceof AnalysisError) return err(cause); + throw cause; + } +}; + +function* documentSteps(document: unknown, artifact: ApiContractArtifact) { + const issue = yield* jsonValueValidationSteps(document); + if (issue !== undefined) return err(inputFailure(issue)); + return yield* parseApiContractSteps(document, artifact); +} + +const importDocument = async ( + input: InspectApiContractInput, + bytes: Buffer, + sha256: string, + signal?: AbortSignal, +): Promise> => { + const document = decodeDocument(bytes, input.path); + if (!document.ok) return document; + const parsed = await completeSteps( + documentSteps(document.value, { + path: input.path, + sha256, + bytes: bytes.length, + }), + signal, + ); + if (!parsed.ok) return parsed; + const result = parsed.value; + return completeProviderEvidence( + createEvidence( + { path: input.path, format: "file", sha256 }, + API_CONTRACT_PROVIDER, + { + operation, + predicateType: "rea.api-contract-inventory", + parameters: { + path: input.path, + format: input.format, + max_file_bytes: input.max_file_bytes, + }, + result, + confidence: "observed", + limitations: result.limitations, + locations: [{ kind: "artifact-path", path: input.path }], + }, + ), + signal, + ); +}; + +/** Shared CLI/MCP import: one verified file read, no references, network or target execution. */ +export const inspectApiContractEvidence = async ( + rawInput: unknown, + resources: ArtifactResourceScope, + options: { readonly signal?: AbortSignal } = {}, +): Promise> => { + const admitted = parseAnalysisInput( + operation, + rawInput, + inspectApiContractInputSchema, + options.signal, + ); + if (!admitted.ok) return admitted; + const input = admitted.value; + if (!isAbsolute(input.path)) + return err( + inputFailure("Expected an absolute filesystem path on this host."), + ); + const source = await readApiContract(input, resources, options.signal); + if (!source.ok) return source; + const result = await importDocument( + input, + source.value.bytes, + source.value.sha256, + options.signal, + ); + if (!result.ok) + result.error.retainPartialObservation({ + kind: "file-snapshot-hash", + path: input.path, + sha256: source.value.sha256, + bytes: source.value.bytes.length, + }); + return result; +}; diff --git a/src/cli/apiContractCommand.ts b/src/cli/apiContractCommand.ts new file mode 100644 index 0000000000..46628a3014 --- /dev/null +++ b/src/cli/apiContractCommand.ts @@ -0,0 +1,108 @@ +import { z } from "incur"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { projectArtifactCloseFailure } from "../artifacts/StableArtifactFailure.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { localPathStringSchema } from "../domain/localPath.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + API_CONTRACT_OPERATION as operation, + API_CONTRACT_PROVIDER, +} from "../domain/apiContract/apiContract.js"; +import { + apiContractViewRequestSchema, + inspectApiContractInputSchema, +} from "../domain/apiContract/apiContractQuery.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { + analysisEvidenceDeliveryOptions, + parseCliAnalysisEvidenceDelivery, + deliverCliAnalysisEvidence, +} from "./analysisEvidenceDelivery.js"; + +const deliverySchema = z + .object({ + detail: analysisEvidenceDeliveryOptions.detail, + view: apiContractViewRequestSchema.optional(), + }) + .superRefine((value, context) => { + if (value.view !== undefined && value.detail !== "summary") + context.addIssue({ + code: "custom", + path: ["view"], + message: + "A selected page requires --detail summary and --evidence_output.", + }); + }); + +/** Standard one-shot publication keeps complete inventory Evidence for later offline pages. */ +export const registerApiContractCommand = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectApiContract, { + description: + "Inventory local OpenAPI 3.0/3.1/3.2 JSON declarations offline without resolving references", + args: z.object({ + path: localPathStringSchema.describe("Local OpenAPI JSON path"), + }), + options: z.object({ + ...analysisEvidenceDeliveryOptions, + maxFileBytes: inspectApiContractInputSchema.shape.max_file_bytes, + view: z + .string() + .optional() + .describe( + "JSON api-contract page; requires --detail summary and --evidence_output", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectApiContract, async () => { + const selected = parseCliAnalysisEvidenceDelivery( + operation, + options, + deliverySchema, + ); + if (!selected.ok) return projectAnalysisError(selected.error); + const resources = new ArtifactResourceScope(); + const { inspectApiContractEvidence } = + await import("../application/apiContract/ApiContractService.js"); + return deliverCliAnalysisEvidence({ + callerInputPath: args.path, + run: () => + inspectApiContractEvidence( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + detail: "complete", + }, + resources, + { signal }, + ), + service: { + close: () => + resources.close().catch((cause: unknown) => { + throw projectArtifactCloseFailure( + cause, + API_CONTRACT_PROVIDER.id, + operation, + ); + }), + }, + providerId: API_CONTRACT_PROVIDER.id, + operation, + signal, + detail: selected.value.detail, + evidenceOutput: selected.value.evidenceOutput, + ...(selected.value.view === undefined + ? {} + : { view: selected.value.view }), + }); + }), + ), + }); +}; diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index fe14a4f0f3..fea2774ae0 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -1,3 +1,4 @@ +import { registerApiContractCommand } from "./apiContractCommand.js"; import { Cli, z } from "incur"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -74,6 +75,7 @@ export const registerApplicationCommands = ( }); registerSourceToBundleCommand(cli, logger); registerStaticObservedEndpointsCommand(cli, logger); + registerApiContractCommand(cli, logger); registerEvidenceJsonCommand({ cli, logger, diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 60d2be5ad6..626570fbcc 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -68,6 +68,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectGoBinary: "inspect-go-binary", inspectIl2cppMetadata: "inspect-il2cpp-metadata", inspectModelArtifact: "inspect-model-artifact", + inspectApiContract: "inspect-api-contract", inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", inspectPythonPyc: "inspect-python-pyc", diff --git a/src/contracts/apiContractToolContracts.ts b/src/contracts/apiContractToolContracts.ts new file mode 100644 index 0000000000..833eab1cc1 --- /dev/null +++ b/src/contracts/apiContractToolContracts.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { apiContractSchema } from "../domain/apiContract/apiContract.js"; +import { inspectApiContractInputSchema } from "../domain/apiContract/apiContractQuery.js"; +import { + apiContractSummaryViewResultSchema, + apiContractPageViewResultSchema, +} from "../domain/analysisView/analysisView.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import type { ToolContract } from "./toolContractTypes.js"; + +/** Named, provider-free contract for declarations in one local specification. */ +export const API_CONTRACT_TOOL_CONTRACTS = [ + { + name: "inspect_api_contract", + ...toolContractMetadata("inspect_api_contract"), + kind: "application", + description: + "Inventory declared operations in a local OpenAPI 3.0/3.1/3.2 JSON document offline. No target session, network, endpoint requests or reference resolution. Returns exact method/path, source JSON Pointers, source-scoped IDs and separate exact method/path keys. Candidate runtime behavior is never inferred from a contract. Default summary retains complete Evidence; optional view returns selected operations, unresolved Path Items or diagnostics immediately and takes precedence over detail. Retained inspect_analysis_view kind api-contract supports exact batch ID/method/path selection, fields and paging. Coverage describes inline paths only; callbacks/webhooks, schemas, servers, parameters and security remain unanalyzed. Malformed siblings and unresolved references remain explicit; this is not full OpenAPI validation. File and expanded-facts allowances are each 32 MiB, with JSON parsing additionally admitted against available heap and the shared nesting limit.", + inputSchema: inspectApiContractInputSchema, + outputSchema: evidenceResultOf( + z.union([ + apiContractSchema, + apiContractSummaryViewResultSchema, + apiContractPageViewResultSchema, + ]), + ), + examples: [ + { + title: + "Read selected declared HTTP operations without contacting servers", + input: { + path: "/artifacts/openapi.json", + view: { + kind: "api-contract", + collection: "operations", + methods: ["GET"], + limit: 20, + }, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index 09b0e18959..fb48b71608 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -1,3 +1,4 @@ +import { API_CONTRACT_TOOL_CONTRACTS } from "./apiContractToolContracts.js"; import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js"; import { z } from "zod"; import { @@ -92,6 +93,7 @@ const retained = (evidence_id: string) => ({ /** Provider-neutral graph workflow contracts shared by MCP and CLI adapters. */ export const APPLICATION_TOOL_CONTRACTS = [ + ...API_CONTRACT_TOOL_CONTRACTS, { name: "search_guidance", ...toolContractMetadata("search_guidance"), diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 4e98ab96a7..9793726836 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -158,6 +158,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_go_binary: evidence, inspect_il2cpp_metadata: evidence, inspect_model_artifact: evidence, + inspect_api_contract: evidence, inspect_gpu_code_objects: evidence, inspect_rust_binary: evidence, inspect_apk_signature: evidence, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index cf2d23698c..e675d686de 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,4 @@ +import { projectApiContractView } from "./apiContractView.js"; import { isNativeObservationOperation, NATIVE_OBSERVATION_OPERATIONS, @@ -157,6 +158,8 @@ import { sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, endpointComparisonSummaryViewResultSchema, + apiContractSummaryViewResultSchema, + apiContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, @@ -228,6 +231,8 @@ export { sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, endpointComparisonSummaryViewResultSchema, + apiContractSummaryViewResultSchema, + apiContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, @@ -356,6 +361,7 @@ const retainedViewProjectors = new Map([ retain("inspect_gpu_code_objects", projectGpuCodeObjectsView), retain("match_native_functions", projectNativeFunctionMatchingView), retain("compare_source_to_bundle", projectSourceToBundleView), + retain("inspect_api_contract", projectApiContractView), retain( "compare_static_and_observed_endpoints", projectEndpointComparisonView, diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index 716562a1b4..93e1f17fde 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -1,3 +1,4 @@ +import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; import { nativeObservationViewRequestSchema, nativeContextViewRequestSchema, @@ -74,6 +75,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ nativeFunctionMatchingViewRequestSchema, sourceToBundleViewRequestSchema, endpointComparisonViewRequestSchema, + apiContractViewRequestSchema, featureTraceViewRequestSchema, nativeFeatureTraceViewRequestSchema, batchDecompileViewRequestSchema, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index 32acb45c50..b8f9a29851 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -1,3 +1,9 @@ +import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; +import { apiContractArtifactSchema } from "../apiContract/apiContract.js"; +import { + apiContractViewContextSchema, + apiContractViewItemsSchema, +} from "./apiContractView.js"; import { analysisViewCoverageSchema, nullableCollectionViewCoverageSchema, @@ -1022,6 +1028,20 @@ export const sourceToBundleSummaryViewResultSchema = z.strictObject({ parent_operation: z.literal("compare_source_to_bundle"), summary: sourceToBundleViewContextSchema, }); +export const apiContractSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_api_contract"), + summary: apiContractViewContextSchema, +}); +export const apiContractPageViewResultSchema = z.strictObject({ + ...viewResultBase, + ...apiContractViewContextSchema.shape, + kind: z.literal("api-contract"), + artifact: apiContractArtifactSchema, + parent_operation: z.literal("inspect_api_contract"), + view: apiContractViewRequestSchema, + items: apiContractViewItemsSchema, +}); export const endpointComparisonSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, artifact: z.null(), @@ -1167,6 +1187,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ webBundlePageViewResultSchema, sourceToBundlePageViewResultSchema, endpointComparisonPageViewResultSchema, + apiContractPageViewResultSchema, featureTraceViewResultSchema, batchDecompileViewResultSchema, z.strictObject({ @@ -1263,6 +1284,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, endpointComparisonSummaryViewResultSchema, + apiContractSummaryViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, webBundleSummaryViewResultSchema, diff --git a/src/domain/analysisView/apiContractView.ts b/src/domain/analysisView/apiContractView.ts new file mode 100644 index 0000000000..6040ac5caf --- /dev/null +++ b/src/domain/analysisView/apiContractView.ts @@ -0,0 +1,211 @@ +import { z } from "zod"; +import { + apiContractSchema, + apiContractOperationSchema, + apiContractPathReferenceSchema, + apiContractDiagnosticSchema, + API_CONTRACT_OPERATION, + type ApiContract, +} from "../apiContract/apiContract.js"; +import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok } from "../result.js"; +import { + createAnalysisViewResultReader, + type AnalysisViewParent, +} from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { selectViewRows } from "./selectViewRows.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; + +export const apiContractViewContextSchema = apiContractSchema.omit({ + operations: true, + path_item_references: true, + diagnostics: true, + limitations: true, +}); +export const apiContractViewItemsSchema = z.union([ + z.array( + apiContractOperationSchema.partial().required({ + operation_id: true, + operation_key: true, + source_pointer: true, + state: true, + reason: true, + }), + ), + z.array( + apiContractPathReferenceSchema.partial().required({ + reference_id: true, + source_pointer: true, + state: true, + credential_redacted: true, + }), + ), + z.array( + apiContractDiagnosticSchema + .partial() + .required({ diagnostic_id: true, source_pointer: true, code: true }), + ), +]); +const readReport = createAnalysisViewResultReader(apiContractSchema); +const values = (selected: readonly string[] | undefined) => + selected === undefined ? undefined : new Set(selected); +const admits = (selection: ReadonlySet | undefined, value: string) => + selection === undefined || selection.has(value); +const fieldsOf = ( + row: object, + fields: readonly string[], +): Record => { + const source = row as Record; + return Object.fromEntries(fields.map((field) => [field, source[field]!])); +}; + +const selectOperations = ( + report: ApiContract, + view: Extract< + z.output, + { collection: "operations" } + >, +) => { + const ids = values(view.operation_ids), + keys = values(view.operation_keys), + methods = values(view.methods), + paths = values(view.paths), + declared = values(view.declared_ids), + states = values(view.states); + return selectViewRows( + report.operations, + view, + (row) => + admits(ids, row.operation_id) && + admits(keys, row.operation_key) && + admits(methods, row.method) && + admits(paths, row.path) && + admits(states, row.state) && + (declared === undefined || + (row.declared_id !== null && declared.has(row.declared_id))), + (row) => ({ + ...fieldsOf(row, view.fields ?? ["method", "path", "declared_id"]), + operation_id: row.operation_id, + operation_key: row.operation_key, + source_pointer: row.source_pointer, + state: row.state, + reason: row.reason, + }), + ); +}; + +const selectRows = ( + report: ApiContract, + view: z.output, +) => { + if (view.collection === "operations") return selectOperations(report, view); + if (view.collection === "path-item-references") { + const ids = values(view.reference_ids); + return selectViewRows( + report.path_item_references, + view, + (row) => admits(ids, row.reference_id), + (row) => ({ + ...fieldsOf(row, view.fields ?? ["path", "reference"]), + reference_id: row.reference_id, + source_pointer: row.source_pointer, + state: row.state, + credential_redacted: row.credential_redacted, + }), + ); + } + const codes = values(view.codes); + return selectViewRows( + report.diagnostics, + view, + (row) => admits(codes, row.code), + (row) => ({ + ...fieldsOf(row, view.fields ?? ["message"]), + diagnostic_id: row.diagnostic_id, + source_pointer: row.source_pointer, + code: row.code, + }), + ); +}; + +/** Queries borrow completed declarations; an exhausted page never upgrades source completeness. */ +export const projectApiContractView = ( + parent: AnalysisViewParent, + admitted: AdmittedView, +) => { + const view = ownedView< + SummaryAnalysisView | z.output + >(admitted, "summary", "api-contract"); + if (view === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "API contract inventories support summary and api-contract pages.", + }, + ]), + ); + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const { + operations: _operations, + path_item_references: _references, + diagnostics: _diagnostics, + limitations, + ...context + } = parsed.value; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: API_CONTRACT_OPERATION, + parent_digest: parent.evidenceId.slice(3), + limitations: boundedViewMessages( + [...parent.limitations, ...limitations], + parent.evidenceId, + "limitations", + ), + unknowns: + context.source_coverage.status === "partial" + ? [ + "Unresolved or malformed declarations limit this inventory; query path-item-references and diagnostics for exact source pointers.", + ] + : [], + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary" as const, + view, + artifact: { + path: context.artifact.path, + sha256: context.artifact.sha256, + }, + summary: context, + coverage: completeWithinViewCoverage(1, 1), + }); + const selected = selectRows(parsed.value, view); + return ok({ + ...shared, + ...context, + kind: "api-contract" as const, + view, + items: selected.rows, + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), + }); +}; diff --git a/src/domain/apiContract/apiContract.ts b/src/domain/apiContract/apiContract.ts new file mode 100644 index 0000000000..024c08e107 --- /dev/null +++ b/src/domain/apiContract/apiContract.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { localPathStringSchema } from "../localPath.js"; + +/** Resource allowances, not OpenAPI format limits. */ +export const API_CONTRACT_MAX_FILE_BYTES = 32 * 1024 * 1024; +export const API_CONTRACT_FACT_BYTES = 32 * 1024 * 1024; +export const API_CONTRACT_OPERATION = "inspect_api_contract" as const; +export const API_CONTRACT_PROVIDER = { + id: "rea-api-contract", + name: "REA offline API contract inventory", + version: "1", +} as const; + +export const apiContractArtifactSchema = z.strictObject({ + path: localPathStringSchema, + sha256: digestSchema, + bytes: z.int().nonnegative(), +}); + +/** Identity is source-scoped; operation_key separately survives source revisions. */ +export const apiContractOperationSchema = z.strictObject({ + operation_id: prefixedDigestSchema("api_operation"), + operation_key: prefixedDigestSchema("api_operation_key"), + method: z.string(), + path: z.string(), + declared_id: z.string().nullable(), + source_pointer: z.string(), + state: z.enum(["declared", "unknown"]), + reason: z + .enum(["unresolved-path-item-reference", "malformed-operation"]) + .nullable(), +}); +export const apiContractPathReferenceSchema = z.strictObject({ + reference_id: prefixedDigestSchema("api_path_reference"), + path: z.string(), + source_pointer: z.string(), + reference: z.string().nullable(), + credential_redacted: z.boolean(), + state: z.enum(["unresolved", "malformed"]), +}); +export const apiContractDiagnosticSchema = z.strictObject({ + diagnostic_id: prefixedDigestSchema("api_contract_diagnostic"), + source_pointer: z.string(), + code: z.enum([ + "invalid-info", + "invalid-paths", + "invalid-path-key", + "invalid-path-item", + "invalid-path-reference", + "invalid-operation", + "invalid-operation-id", + "unknown-path-item-field", + "invalid-additional-operations", + "invalid-additional-method", + ]), + message: z.string(), +}); + +export const apiContractSchema = z.strictObject({ + contract_id: prefixedDigestSchema("api_contract"), + format: z.literal("openapi-json"), + openapi: z.string(), + artifact: apiContractArtifactSchema, + info: z.strictObject({ + title: z.string().nullable(), + version: z.string().nullable(), + }), + operations: z.array(apiContractOperationSchema), + path_item_references: z.array(apiContractPathReferenceSchema), + diagnostics: z.array(apiContractDiagnosticSchema), + summary: z.strictObject({ + operation_slots: z.int().nonnegative(), + declared: z.int().nonnegative(), + unknown: z.int().nonnegative(), + unresolved_path_items: z.int().nonnegative(), + diagnostics: z.int().nonnegative(), + }), + source_coverage: z.strictObject({ + scope: z.literal("inline-path-operations"), + status: z.enum(["complete-within-scope", "partial"]), + paths_state: z.enum(["object", "absent", "malformed"]), + path_entries_examined: z.int().nonnegative(), + reference_resolution: z.literal("not-attempted"), + schema_validation: z.literal("not-performed"), + duplicate_json_members: z.literal("last-value-wins"), + }), + limitations: z.array(z.string()), +}); +export type ApiContract = z.output; +export type ApiContractOperation = z.output; +export type ApiContractDiagnostic = z.output< + typeof apiContractDiagnosticSchema +>; +export type ApiContractArtifact = z.output; diff --git a/src/domain/apiContract/apiContractQuery.ts b/src/domain/apiContract/apiContractQuery.ts new file mode 100644 index 0000000000..07af27e9db --- /dev/null +++ b/src/domain/apiContract/apiContractQuery.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; +import { + API_CONTRACT_MAX_FILE_BYTES, + apiContractDiagnosticSchema, + apiContractOperationSchema, + apiContractPathReferenceSchema, +} from "./apiContract.js"; + +const page = { + kind: z.literal("api-contract"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), +}; +export const apiContractViewRequestSchema = z.discriminatedUnion("collection", [ + z.strictObject({ + ...page, + collection: z.literal("operations"), + operation_ids: z + .array(apiContractOperationSchema.shape.operation_id) + .optional(), + operation_keys: z + .array(apiContractOperationSchema.shape.operation_key) + .optional(), + methods: z.array(z.string()).optional(), + paths: z.array(z.string()).optional(), + declared_ids: z.array(z.string()).optional(), + states: z.array(apiContractOperationSchema.shape.state).optional(), + fields: z.array(apiContractOperationSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("path-item-references"), + reference_ids: z + .array(apiContractPathReferenceSchema.shape.reference_id) + .optional(), + fields: z.array(apiContractPathReferenceSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("diagnostics"), + codes: z.array(apiContractDiagnosticSchema.shape.code).optional(), + fields: z.array(apiContractDiagnosticSchema.keyof()).optional(), + }), +]); + +export const inspectApiContractInputSchema = z.strictObject({ + path: localPathStringSchema.describe( + "Absolute path to one local OpenAPI JSON document", + ), + format: z.literal("openapi-json").default("openapi-json"), + max_file_bytes: z + .int() + .positive() + .max(API_CONTRACT_MAX_FILE_BYTES) + .default(API_CONTRACT_MAX_FILE_BYTES) + .describe( + "Maximum input document bytes (up to 32 MiB); reply paging does not increase parser allowances", + ), + detail: z.enum(["summary", "complete"]).default("summary"), + view: apiContractViewRequestSchema + .optional() + .describe( + "Return this selected page immediately; complete Evidence is retained and view takes precedence over detail", + ), +}); +export type InspectApiContractInput = z.output< + typeof inspectApiContractInputSchema +>; diff --git a/src/domain/apiContract/parseApiContract.test.ts b/src/domain/apiContract/parseApiContract.test.ts new file mode 100644 index 0000000000..7bad1c079c --- /dev/null +++ b/src/domain/apiContract/parseApiContract.test.ts @@ -0,0 +1,314 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { expect, it } from "vitest"; +import { apiContractSchema, type ApiContractArtifact } from "./apiContract.js"; +import { parseApiContractSteps } from "./parseApiContract.js"; + +const artifact: ApiContractArtifact = { + path: "/contract.json", + sha256: "a".repeat(64), + bytes: 1, +}; +const document = (paths: unknown, openapi = "3.0.4") => ({ + openapi, + info: { title: "Owned API", version: "1" }, + paths, +}); +const parse = (value: unknown, source = artifact) => { + const steps = parseApiContractSteps(value, source); + for (;;) { + const step = steps.next(); + if (step.done) return step.value; + } +}; +const report = (value: unknown, source = artifact) => { + const result = parse(value, source); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + return apiContractSchema.parse(result.value); +}; + +it("reads a real published Petstore document without embedding its schemas", () => { + const bytes = readFileSync( + new URL( + "../../../tests/fixtures/openapi/petstore.json.txt", + import.meta.url, + ), + ); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + expect(sha256).toBe( + "00441c05add60f28da55eb4563d3fb6bbda364ba8eb7b381e2e2c98613005c82", + ); + const value = report(JSON.parse(bytes.toString("utf8")), { + ...artifact, + sha256, + bytes: bytes.length, + }); + expect(value.openapi).toBe("3.0.4"); + expect(value.info).toEqual({ + title: "Swagger Petstore - OpenAPI 3.0", + version: "1.0.27", + }); + expect(value.summary).toEqual({ + operation_slots: 19, + declared: 19, + unknown: 0, + unresolved_path_items: 0, + diagnostics: 0, + }); + expect(value.source_coverage).toMatchObject({ + status: "complete-within-scope", + path_entries_examined: 13, + reference_resolution: "not-attempted", + schema_validation: "not-performed", + }); + expect( + value.operations.find((row) => row.declared_id === "getPetById"), + ).toMatchObject({ + method: "GET", + path: "/pet/{petId}", + source_pointer: "/paths/~1pet~1{petId}/get", + state: "declared", + }); + expect(value).not.toHaveProperty("components"); + expect(value).not.toHaveProperty("servers"); +}); + +it.each(["3.0.4", "3.1.2", "3.2.1"])( + "accepts the explicit %s minor family", + (version) => { + expect( + report(document({ "/x": { get: {} } }, version)).summary.declared, + ).toBe(1); + }, +); + +it.each(["2.0", "3.3.0", "3.2", "3.01.0", "3.0.01", "3.2.0-beta.1"])( + "refuses an unsupported version %s explicitly", + (version) => { + const value = parse(document({}, version)); + expect(value.ok).toBe(false); + if (!value.ok) + expect(value.error._tag).toBe("AnalysisUnsupportedTargetError"); + }, +); + +it.each([null, [], {}, { openapi: 3 }])( + "rejects a malformed root %#", + (value) => { + const parsed = parse(value); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.error._tag).toBe("AnalysisInputError"); + }, +); + +it("keeps source-scoped identities separate from exact method/path keys and declared IDs", () => { + const input = document({ + "/a/~b": { get: { operationId: "same" } }, + "/b": { get: { operationId: "same" } }, + }); + const first = report(input); + const copied = report(input, { ...artifact, path: "/copy.json" }); + const revised = report(input, { ...artifact, sha256: "b".repeat(64) }); + expect(first.operations[0]?.source_pointer).toBe("/paths/~1a~1~0b/get"); + expect(copied.operations).toEqual(first.operations); + expect(revised.operations.map((row) => row.operation_key)).toEqual( + first.operations.map((row) => row.operation_key), + ); + expect(revised.operations[0]?.operation_id).not.toBe( + first.operations[0]?.operation_id, + ); + expect(new Set(first.operations.map((row) => row.operation_id)).size).toBe(2); + expect(first.operations.every((row) => row.declared_id === "same")).toBe( + true, + ); +}); + +it("preserves unresolved local, cyclic and remote Path Items without following them", () => { + const input = document({ + "/inline": { $ref: "#/paths/~1inline", get: { operationId: "inline" } }, + "/local": { $ref: "./sibling.json#/paths/~1other" }, + "/remote": { $ref: "http://127.0.0.1:9/reference.json" }, + }); + const value = report(input); + expect(value.path_item_references.map((row) => row.reference)).toEqual([ + "#/paths/~1inline", + "./sibling.json#/paths/~1other", + "http://127.0.0.1:9/reference.json", + ]); + expect(value.operations).toHaveLength(1); + expect(value.operations[0]).toMatchObject({ + state: "unknown", + reason: "unresolved-path-item-reference", + }); + expect(value.source_coverage.status).toBe("partial"); + expect(value.summary).toMatchObject({ + declared: 0, + unknown: 1, + unresolved_path_items: 3, + }); +}); + +it("keeps usable siblings, malformed slots and missing values distinct", () => { + const input = document({ + "/ok": { get: { operationId: "" }, post: { operationId: 0 } }, + "/broken": { get: null }, + "/bad-ref": { $ref: false, get: {} }, + "/bad-item": 0, + invalid: { get: {} }, + "x-doc": { get: {} }, + }); + const value = report(input); + expect(value.summary).toEqual({ + operation_slots: 4, + declared: 2, + unknown: 2, + unresolved_path_items: 1, + diagnostics: 5, + }); + expect(value.operations[0]?.declared_id).toBe(""); + expect(value.operations[1]).toMatchObject({ + declared_id: null, + state: "declared", + }); + expect(value.operations[2]).toMatchObject({ + state: "unknown", + reason: "malformed-operation", + }); + expect(value.path_item_references[0]).toMatchObject({ + state: "malformed", + reference: null, + }); + expect(value.source_coverage.status).toBe("partial"); + expect(value.diagnostics.map((row) => row.code)).toEqual([ + "invalid-operation-id", + "invalid-operation", + "invalid-path-reference", + "invalid-path-item", + "invalid-path-key", + ]); +}); + +it("excludes structured transport userinfo while preserving ordinary reference text", () => { + const value = report( + document({ + "/absolute": { + $ref: "https://name:credential@example.test/schema.json?token=caller-text#part", + }, + "/relative": { + $ref: "//name:credential@example.test/schema.json?ordinary=kept", + }, + "/file": { $ref: "./ordinary@name.json#/components/schemas/password" }, + }), + ); + expect( + value.path_item_references.map(({ reference, credential_redacted }) => ({ + reference, + credential_redacted, + })), + ).toEqual([ + { + reference: "https://example.test/schema.json?token=caller-text#part", + credential_redacted: true, + }, + { + reference: "//example.test/schema.json?ordinary=kept", + credential_redacted: true, + }, + { + reference: "./ordinary@name.json#/components/schemas/password", + credential_redacted: false, + }, + ]); + expect(JSON.stringify(value)).not.toContain("name:credential"); +}); + +it("keeps an ambiguous credential spelling unknown without normalizing its reference identity", () => { + const value = report( + document({ + "/ambiguous": { + $ref: "https:\\\\name:credential@example.test/schema.json", + }, + }), + ); + expect(value.path_item_references[0]).toMatchObject({ + reference: null, + credential_redacted: true, + state: "unresolved", + }); + expect(value.source_coverage.status).toBe("partial"); +}); + +it("reports absent and malformed paths without calling them a complete empty API", () => { + const value = document(undefined); + delete (value as { paths?: unknown }).paths; + expect(report(value).source_coverage).toMatchObject({ + status: "partial", + paths_state: "absent", + }); + expect( + report({ ...value, openapi: "3.1.2", webhooks: { name: { post: {} } } }) + .source_coverage, + ).toMatchObject({ + status: "complete-within-scope", + paths_state: "absent", + scope: "inline-path-operations", + }); + expect(report(document(null)).source_coverage).toMatchObject({ + status: "partial", + paths_state: "malformed", + }); +}); + +it("handles 3.2 QUERY and case-sensitive additional methods, diagnosing invalid fixed-method duplicates", () => { + const value = report( + document( + { + "/x": { + query: {}, + post: {}, + additionalOperations: { + COPY: {}, + post: {}, + POST: {}, + "bad method": {}, + }, + }, + }, + "3.2.1", + ), + ); + expect(value.operations.map((row) => row.method)).toEqual([ + "QUERY", + "POST", + "COPY", + "post", + ]); + expect(value.operations[2]?.source_pointer).toBe( + "/paths/~1x/additionalOperations/COPY", + ); + expect(value.diagnostics.map((row) => row.code)).toEqual([ + "invalid-additional-method", + "invalid-additional-method", + ]); + expect(value.source_coverage.status).toBe("partial"); + const older = report( + document( + { "/x": { query: {}, additionalOperations: { COPY: {} }, GET: {} } }, + "3.1.2", + ), + ); + expect(older.operations).toEqual([]); + expect(older.diagnostics.map((row) => row.code)).toEqual( + Array(3).fill("unknown-path-item-field"), + ); +}); + +it("bounds expanded identities and source pointers before building a giant row", () => { + const result = parse( + document({ [`/${"a".repeat(1_100_000)}`]: { get: {} } }), + ); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); +}); diff --git a/src/domain/apiContract/parseApiContract.ts b/src/domain/apiContract/parseApiContract.ts new file mode 100644 index 0000000000..22950d14e1 --- /dev/null +++ b/src/domain/apiContract/parseApiContract.ts @@ -0,0 +1,452 @@ +import { + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../analysisErrorCore.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { err, ok, type Result } from "../result.js"; +import { parseRecordedTransportUrl } from "../recordedTransportUrl.js"; +import { + API_CONTRACT_FACT_BYTES, + API_CONTRACT_OPERATION, + type ApiContract, + type ApiContractArtifact, + type ApiContractDiagnostic, + type ApiContractOperation, +} from "./apiContract.js"; + +const methods = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", +] as const; +const modernMethods = [...methods, "query"]; +const reservedAdditionalMethods = new Set( + modernMethods.map((method) => method.toUpperCase()), +); +const pathMetadata = new Set([ + "$ref", + "summary", + "description", + "servers", + "parameters", +]); +// HTTP methods are case-sensitive tokens; additionalOperations preserves that spelling. +const httpToken = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u; +const record = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +const pointerPart = (value: string): string => + value.replaceAll("~", "~0").replaceAll("/", "~1"); +const identity = (prefix: string, facts: unknown) => + `${prefix}_${digestCanonicalValue(facts)}`; + +interface OperationSlot { + readonly path: string; + readonly method: string; + readonly pointer: string; + readonly hasReference: boolean; +} + +/** Own compact declarations and account for repeated path/pointer serialization before allocation. */ +class ContractInventory { + readonly operations: ApiContract["operations"] = []; + readonly references: ApiContract["path_item_references"] = []; + readonly diagnostics: ApiContractDiagnostic[] = []; + #retainedBytes = 0; + + constructor(readonly artifact: ApiContractArtifact) {} + + reserve(bytes: number): Result { + // Includes object/index overhead and repeated escaped Evidence delivery, not just source bytes. + this.#retainedBytes += bytes; + return this.#retainedBytes <= API_CONTRACT_FACT_BYTES + ? ok(undefined) + : err( + new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "API contract declarations exceed the retained-facts representation allowance.", + { representation_bytes: API_CONTRACT_FACT_BYTES }, + { + retryable: false, + remediationAction: + "Select a smaller complete API document; paging the reply does not increase the parser's retained-facts allowance.", + }, + ), + ); + } + + diagnostic( + code: ApiContractDiagnostic["code"], + pointer: string, + message: string, + ): Result { + const admitted = this.reserve(1024 + pointer.length * 12); + if (!admitted.ok) return admitted; + this.diagnostics.push({ + diagnostic_id: identity("api_contract_diagnostic", [ + this.artifact.sha256, + pointer, + code, + ]), + source_pointer: pointer, + code, + message, + }); + return ok(undefined); + } + + reference( + path: string, + pointer: string, + value: unknown, + ): Result { + const admitted = this.reserve( + 1024 + + path.length * 12 + + (typeof value === "string" ? value.length * 12 : 0), + ); + if (!admitted.ok) return admitted; + const credentials = + typeof value === "string" + ? parseRecordedTransportUrl(value).credentials + : null; + const reference = + typeof value !== "string" || credentials === "ambiguous" + ? null + : credentials === null + ? value + : value.slice(0, credentials.start) + value.slice(credentials.end); + this.references.push({ + reference_id: identity("api_path_reference", [ + this.artifact.sha256, + pointer, + ]), + path, + source_pointer: pointer, + reference, + credential_redacted: credentials !== null, + state: typeof value === "string" ? "unresolved" : "malformed", + }); + return typeof value === "string" + ? ok(undefined) + : this.diagnostic( + "invalid-path-reference", + pointer, + "Path Item $ref must be a string; reference resolution was not attempted.", + ); + } + + operation(value: unknown, slot: OperationSlot): Result { + const object = record(value); + const declaredId = + typeof object?.operationId === "string" ? object.operationId : null; + const admitted = this.reserve( + 1536 + + slot.path.length * 32 + + slot.method.length * 24 + + (declaredId?.length ?? 0) * 12, + ); + if (!admitted.ok) return admitted; + const reason: ApiContractOperation["reason"] = + object === undefined + ? "malformed-operation" + : slot.hasReference + ? "unresolved-path-item-reference" + : null; + this.operations.push({ + operation_id: identity("api_operation", [ + this.artifact.sha256, + slot.pointer, + ]), + operation_key: identity("api_operation_key", [slot.path, slot.method]), + method: slot.method, + path: slot.path, + declared_id: declaredId, + source_pointer: slot.pointer, + state: reason === null ? "declared" : "unknown", + reason, + }); + if (object === undefined) + return this.diagnostic( + "invalid-operation", + slot.pointer, + "The method's value is not an Operation Object.", + ); + if (Object.hasOwn(object, "operationId") && declaredId === null) + return this.diagnostic( + "invalid-operation-id", + `${slot.pointer}/operationId`, + "operationId is present but is not a string; its identity remains unavailable.", + ); + return ok(undefined); + } +} + +const infoFacts = ( + root: Record, + inventory: ContractInventory, +): Result => { + const info = record(root.info); + const title = typeof info?.title === "string" ? info.title : null; + const version = typeof info?.version === "string" ? info.version : null; + const admitted = inventory.reserve( + 4096 + + inventory.artifact.path.length * 12 + + (title?.length ?? 0) * 12 + + (version?.length ?? 0) * 12, + ); + if (!admitted.ok) return admitted; + if (info === undefined || title === null || version === null) { + const diagnosed = inventory.diagnostic( + "invalid-info", + "/info", + "Info title/version are not both present strings; usable metadata and operation slots are retained without claiming a valid OpenAPI document.", + ); + if (!diagnosed.ok) return diagnosed; + } + return ok({ title, version }); +}; + +function* additionalOperations( + value: unknown, + context: { + inventory: ContractInventory; + path: string; + pointer: string; + hasReference: boolean; + }, +): Generator> { + const { inventory, path, pointer, hasReference } = context; + const operations = record(value); + if (operations === undefined) + return inventory.diagnostic( + "invalid-additional-operations", + pointer, + "additionalOperations must be an object mapping HTTP method tokens to Operation Objects.", + ); + let examined = 0; + for (const method in operations) { + if (!Object.hasOwn(operations, method)) continue; + if (examined++ % 128 === 0) yield; + const admitted = inventory.reserve(512 + method.length * 24); + if (!admitted.ok) return admitted; + const source = `${pointer}/${pointerPart(method)}`; + const reserved = reservedAdditionalMethods.has(method); + const added = + !httpToken.test(method) || reserved + ? inventory.diagnostic( + "invalid-additional-method", + source, + "The additional HTTP method is not a token or duplicates a fixed method; it is not treated as a valid operation.", + ) + : inventory.operation(operations[method], { + path, + method, + pointer: source, + hasReference, + }); + if (!added.ok) return added; + } + return ok(undefined); +} + +function* pathItem( + value: unknown, + context: { + inventory: ContractInventory; + path: string; + pointer: string; + modern: boolean; + }, +): Generator> { + const { inventory, path, pointer, modern } = context; + const item = record(value); + if (item === undefined) + return inventory.diagnostic( + "invalid-path-item", + pointer, + "Path Item must be an object; its operations are unknown.", + ); + const hasReference = Object.hasOwn(item, "$ref"); + if (hasReference) { + const added = inventory.reference(path, `${pointer}/$ref`, item.$ref); + if (!added.ok) return added; + } + const fixedMethods: readonly string[] = modern ? modernMethods : methods; + let examined = 0; + for (const key in item) { + if (!Object.hasOwn(item, key)) continue; + if (examined++ % 128 === 0) yield; + if (fixedMethods.includes(key)) { + const added = inventory.operation(item[key], { + path, + method: key.toUpperCase(), + pointer: `${pointer}/${key}`, + hasReference, + }); + if (!added.ok) return added; + } else if (key === "additionalOperations" && modern) { + const added = yield* additionalOperations(item[key], { + inventory, + path, + pointer: `${pointer}/${key}`, + hasReference, + }); + if (!added.ok) return added; + } else if (!pathMetadata.has(key) && !key.startsWith("x-")) { + const admitted = inventory.reserve(512 + key.length * 24); + if (!admitted.ok) return admitted; + const diagnosed = inventory.diagnostic( + "unknown-path-item-field", + `${pointer}/${pointerPart(key)}`, + "This field is not an operation or a recognized Path Item field in the selected OpenAPI minor version.", + ); + if (!diagnosed.ok) return diagnosed; + } + } + return ok(undefined); +} + +const completedInventory = ( + inventory: ContractInventory, + context: { + openapi: string; + info: ApiContract["info"]; + pathsState: ApiContract["source_coverage"]["paths_state"]; + examined: number; + }, +): ApiContract => { + const artifact = inventory.artifact; + let declared = 0; + for (const operation of inventory.operations) + if (operation.state === "declared") declared++; + return { + contract_id: `api_contract_${artifact.sha256}`, + format: "openapi-json", + openapi: context.openapi, + artifact, + info: context.info, + operations: inventory.operations, + path_item_references: inventory.references, + diagnostics: inventory.diagnostics, + summary: { + operation_slots: inventory.operations.length, + declared, + unknown: inventory.operations.length - declared, + unresolved_path_items: inventory.references.length, + diagnostics: inventory.diagnostics.length, + }, + source_coverage: { + scope: "inline-path-operations", + status: + inventory.references.length > 0 || inventory.diagnostics.length > 0 + ? "partial" + : "complete-within-scope", + paths_state: context.pathsState, + path_entries_examined: context.examined, + reference_resolution: "not-attempted", + schema_validation: "not-performed", + duplicate_json_members: "last-value-wins", + }, + limitations: [ + "Operations are document declarations, not evidence of runtime implementation, reachability or authorization.", + "Only inline operations under paths are inventoried; callbacks, webhooks, components, servers, parameters, schemas, request/response contracts and security are not analyzed.", + "No reference is resolved, including local fragments. A Path Item reference makes its effective operations unknown; adjacent inline slots are preserved without merging referenced definitions.", + "Transport userinfo credentials are excluded from reference URIs. credential_redacted marks the exclusion; an ambiguous credential span leaves the displayed reference unknown. Other reference text is preserved.", + "This is a structural inventory, not full OpenAPI schema validation. operationId values are preserved without assuming uniqueness; operation_key matches exact method/path spelling, not semantic equivalence across APIs.", + "Source JSON members use last-value-wins parsing. Original bytes remain in the caller-selected artifact, identified by SHA-256; this Evidence does not embed the complete source document.", + ], + }; +}; + +/** Enumerate declarations only; never resolve references, servers, callbacks or runtime expressions. */ +export function* parseApiContractSteps( + value: unknown, + artifact: ApiContractArtifact, +): Generator> { + const root = record(value); + if (root === undefined || typeof root.openapi !== "string") + return err( + new AnalysisInputError(API_CONTRACT_OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: + "Expected an OpenAPI JSON object with an openapi version string.", + }, + ]), + ); + const version = /^3\.([012])\.(?:0|[1-9][0-9]*)$/u.exec(root.openapi); + if (version === null) + return err( + new AnalysisUnsupportedTargetError( + API_CONTRACT_OPERATION, + artifact.path, + "Only OpenAPI 3.0, 3.1 and 3.2 JSON documents are supported.", + ), + ); + const inventory = new ContractInventory(artifact); + const versionAdmitted = inventory.reserve(root.openapi.length * 12); + if (!versionAdmitted.ok) return versionAdmitted; + const info = infoFacts(root, inventory); + if (!info.ok) return info; + const paths = record(root.paths); + const pathsState = + paths !== undefined + ? "object" + : Object.hasOwn(root, "paths") + ? "malformed" + : "absent"; + if ( + pathsState === "malformed" || + (pathsState === "absent" && version[1] === "0") + ) { + const diagnosed = inventory.diagnostic( + "invalid-paths", + "/paths", + "paths must be an object and is required for OpenAPI 3.0; a missing or malformed inventory is not proof of zero operations.", + ); + if (!diagnosed.ok) return diagnosed; + } + let examined = 0; + const sourcePaths = paths ?? {}; + for (const path in sourcePaths) { + if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue; + yield; + examined++; + // Reserve expanded pointer/path strings before escaping or building a row. + const admitted = inventory.reserve(512 + path.length * 16); + if (!admitted.ok) return admitted; + const pointer = `/paths/${pointerPart(path)}`; + const read = path.startsWith("/") + ? yield* pathItem(sourcePaths[path], { + inventory, + path, + pointer, + modern: version[1] === "2", + }) + : inventory.diagnostic( + "invalid-path-key", + pointer, + "A Paths entry must start with /; it was not interpreted as an endpoint.", + ); + if (!read.ok) return read; + } + return ok( + completedInventory(inventory, { + openapi: root.openapi, + info: info.value, + pathsState, + examined, + }), + ); +} diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 07ea24176c..60e3e6965c 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -34,6 +34,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_pe_certificates" | "inspect_elf_notes" | "inspect_devicetree" + | "inspect_api_contract" | "inspect_kernel_module" | "inspect_erlang_beam" | "extract_artifact" diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index ae392dc1ca..dcdfa96465 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -1,3 +1,4 @@ +import { registerApiContractTool } from "./registerApplicationTools/apiContract.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { registerTraceFeatureTool } from "./registerApplicationTools/traceFeature.js"; @@ -25,6 +26,7 @@ export const registerApplicationTools = ( registerTraceJavaScriptSemanticsTool(server, options); registerCompareApplicationVersionsTool(server, options); registerCompareEndpointsTool(server, options); + registerApiContractTool(server, options); registerCompareSourceToBundleTool(server, options); registerCompareJavaScriptExportShapesTool(server, options); registerReconstructionObligationLedgerTool(server, options); diff --git a/src/server/registerApplicationTools/apiContract.ts b/src/server/registerApplicationTools/apiContract.ts new file mode 100644 index 0000000000..2b77c017c1 --- /dev/null +++ b/src/server/registerApplicationTools/apiContract.ts @@ -0,0 +1,58 @@ +import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; +import { runAdmittedToolOperation } from "../admittedToolOperation.js"; +import { requireAnalysisViewRetention } from "../../application/analysisView/AnalysisViewService.js"; +import { inspectApiContractEvidence } from "../../application/apiContract/ApiContractService.js"; +import { toolContract } from "../../contracts/toolContracts.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { logToolExecution } from "../toolLogging.js"; +import { deliverApplicationEvidence } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = toolContract("inspect_api_contract"); + +/** Reads use the server's existing artifact resource owner and Evidence session. */ +export const registerApiContractTool = ( + server: EvidenceMcpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => + runAdmittedToolOperation( + server, + options.withAdmittedAnalysis, + contract.name, + context.mcpReq.signal, + async () => { + if (input.detail === "summary" || input.view !== undefined) { + const retention = requireAnalysisViewRetention( + contract.name, + options, + ); + if (!retention.ok) + return server.delivery.toCallToolResult(retention, contract); + } + const result = await logToolExecution( + options.logger, + contract.name, + () => + inspectApiContractEvidence(input, options.artifactResources, { + signal: context.mcpReq.signal, + }), + ); + if (!result.ok) + return server.delivery.toCallToolResult(result, contract); + return deliverApplicationEvidence( + server, + options, + contract, + { evidence: result.value, sources: [] }, + input.view ?? + (input.detail === "summary" ? { kind: "summary" } : undefined), + context.mcpReq.signal, + ); + }, + ), + ); +}; diff --git a/tests/acceptance/applications/apiContract.test.ts b/tests/acceptance/applications/apiContract.test.ts new file mode 100644 index 0000000000..3f67f1c393 --- /dev/null +++ b/tests/acceptance/applications/apiContract.test.ts @@ -0,0 +1,457 @@ +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, onTestFinished } from "vitest"; +import { z } from "zod"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { apiContractSchema } from "../../../src/domain/apiContract/apiContract.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; + +type McpCall = Awaited>["call"]; +const fixture = new URL( + "../../fixtures/openapi/petstore.json.txt", + import.meta.url, +); +const asView = (response: Awaited>) => { + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + return analysisViewResultSchema.parse( + parseEvidence(response.structuredContent).normalized_result, + ); +}; +const retained = (evidence: Evidence) => ({ + kind: "retained-evidence", + evidence_id: evidence.evidence_id, +}); + +const checkRetainedPages = async (call: McpCall, parent: Evidence) => { + const report = apiContractSchema.parse(parent.normalized_result); + const rows = []; + for (let offset = 0; offset < 19; offset += 5) { + const page = asView( + await call("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "api-contract", + collection: "operations", + offset, + limit: 5, + }, + }), + ); + expect(page.kind).toBe("api-contract"); + if (page.kind !== "api-contract") + throw new Error("Expected operation page"); + rows.push(...page.items); + expect(page.source_coverage).toEqual(report.source_coverage); + expect(page.coverage.exhausted).toBe(offset + 5 >= 19); + } + expect(rows).toEqual(report.operations); + const selected = asView( + await call("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "api-contract", + collection: "operations", + paths: ["/pet/{petId}"], + methods: ["GET"], + fields: [], + }, + }), + ); + expect(selected.kind).toBe("api-contract"); + if (selected.kind !== "api-contract") + throw new Error("Expected selected page"); + expect(selected.items).toHaveLength(1); + expect(Object.keys(selected.items[0]!).sort()).toEqual([ + "operation_id", + "operation_key", + "reason", + "source_pointer", + "state", + ]); + const wanted = report.operations.find( + (row) => row.declared_id === "getPetById", + )!; + expect(selected.items[0]).toMatchObject({ + operation_id: wanted.operation_id, + operation_key: wanted.operation_key, + state: "declared", + }); + for (const filter of [ + { operation_ids: [] }, + { methods: [] }, + { paths: [] }, + { declared_ids: [] }, + ]) { + const empty = asView( + await call("inspect_analysis_view", { + source: retained(parent), + view: { kind: "api-contract", collection: "operations", ...filter }, + }), + ); + expect(empty).toMatchObject({ + items: [], + coverage: { total: 0, exhausted: true }, + source_coverage: report.source_coverage, + }); + } + const batch = asView( + await call("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "api-contract", + collection: "operations", + operation_keys: report.operations + .slice(0, 2) + .map((row) => row.operation_key), + }, + }), + ); + expect(batch).toMatchObject({ + items: report.operations.slice(0, 2), + coverage: { total: 2 }, + }); +}; + +const checkCliPublication = async ( + cli: TestCli, + context: { + root: string; + path: string; + parent: Evidence; + summary: unknown; + selected: unknown; + }, +) => { + const { root, path, parent, summary, selected } = context; + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const complete = await run({ + arguments: ["inspect-api-contract", path, "--json"], + }); + expect(complete.exitCode, complete.stderr).toBe(0); + expect(parseEvidence(complete.json)).toEqual({ + ...parent, + caller_input_path: path, + }); + const bundlePath = join(root, "contract.bundle.json"); + const receipt = await run({ + arguments: [ + "inspect-api-contract", + path, + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--json", + ], + }); + expect(receipt.exitCode, receipt.stderr).toBe(0); + expect(receipt.json).toMatchObject({ + summary: { ...parseEvidence(summary), caller_input_path: path }, + }); + const bundle = evidenceBundleSchema.parse( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + expect(bundle.records).toEqual([{ ...parent, caller_input_path: path }]); + const request = { + kind: "api-contract", + collection: "operations", + methods: ["GET"], + limit: 2, + }; + const inline = await run({ + arguments: [ + "inspect-api-contract", + path, + "--detail", + "summary", + "--view", + JSON.stringify(request), + "--evidence_output", + join(root, "selected.bundle.json"), + "--json", + ], + }); + expect(inline.exitCode, inline.stderr).toBe(0); + expect(inline.json).toMatchObject({ + summary: { ...parseEvidence(selected), caller_input_path: path }, + }); + const viewInput = join(root, "retained-page.json"); + await writeFile( + viewInput, + JSON.stringify({ + source: { + kind: "evidence-file", + path: bundlePath, + evidence_id: parent.evidence_id, + }, + view: request, + }), + ); + await rm(path); + const fromBundle = await run({ + arguments: ["inspect-analysis-view", viewInput, "--json"], + }); + expect(fromBundle.exitCode, fromBundle.stderr).toBe(0); + expect(parseEvidence(fromBundle.json)).toEqual(parseEvidence(selected)); + const missingSink = await run({ + arguments: ["inspect-api-contract", path, "--detail", "summary", "--json"], + }); + expect(missingSink.json).toMatchObject({ code: "invalid_request" }); + const invalidView = await run({ + arguments: [ + "inspect-api-contract", + path, + "--view", + JSON.stringify(request), + "--json", + ], + }); + expect(invalidView.json).toMatchObject({ code: "invalid_request" }); +}; + +cliTest( + "imports the published OpenAPI document through public CLI/MCP with retained, selected and complete parity", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-public-api-contract-"); + const path = join(root, "openapi.json"); + const bytes = await readFile(fixture); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + "00441c05add60f28da55eb4563d3fb6bbda364ba8eb7b381e2e2c98613005c82", + ); + await writeFile(path, bytes); + const { client, call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_api_contract", + ); + expect(schemas.ajv.validate(schemas.inputSchema, { path })).toBe(true); + expect( + schemas.ajv.validate(schemas.inputSchema, { + path, + view: { + kind: "api-contract", + collection: "operations", + fields: ["not-a-field"], + }, + }), + ).toBe(false); + const summary = await call("inspect_api_contract", { path }); + const summaryView = asView(summary); + expect(summaryView).toMatchObject({ + kind: "summary", + summary: { summary: { operation_slots: 19, declared: 19, unknown: 0 } }, + }); + expect(JSON.stringify(summary.structuredContent)).not.toContain( + "getPetById", + ); + const bundle = await call("get_evidence_bundle", { detail: "complete" }); + expect(bundle.isError).not.toBe(true); + const records = z + .object({ result: evidenceBundleSchema }) + .parse(bundle.structuredContent).result.records; + const parent = records.find( + (record) => record.evidence_id === summaryView.parent_evidence_id, + )!; + expect(parent).toBeDefined(); + expect( + apiContractSchema.parse(parent.normalized_result).summary.declared, + ).toBe(19); + const full = await call("inspect_api_contract", { + path, + detail: "complete", + }); + expect(parseEvidence(full.structuredContent)).toEqual(parent); + const selected = await call("inspect_api_contract", { + path, + detail: "complete", + view: { + kind: "api-contract", + collection: "operations", + methods: ["GET"], + limit: 2, + }, + }); + expect(asView(selected)).toMatchObject({ + kind: "api-contract", + items: [{ method: "GET" }, { method: "GET" }], + }); + await rm(path); + await checkRetainedPages(call, parent); + await writeFile(path, bytes); + await checkCliPublication(cli, { + root, + path, + parent, + summary: summary.structuredContent, + selected: selected.structuredContent, + }); + expect((await readFile(fixture)).equals(bytes)).toBe(true); + }, +); + +cliTest( + "keeps unresolved references, malformed siblings and partial coverage offline", + async ({ cli }) => { + let requests = 0; + const server = createServer((_request, response) => { + requests++; + response.end("{}"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + onTestFinished( + () => + new Promise((resolve, reject) => + server.close((error) => + error === undefined ? resolve() : reject(error), + ), + ), + ); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected loopback listener"); + const reference = `http://127.0.0.1:${address.port}/never-requested.json`; + const root = await createTestTempDirectory("rea-partial-api-contract-"); + const path = join(root, "partial.json"); + const value = JSON.parse((await readFile(fixture)).toString("utf8")); + value.paths["/remote"] = { $ref: reference }; + value.paths["/pet"].$ref = "#/paths/~1pet"; + value.paths["/broken"] = { get: null }; + value.servers = [{ url: reference }]; + const bytes = JSON.stringify(value); + await writeFile(path, bytes); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const complete = await call("inspect_api_contract", { + path, + detail: "complete", + }); + expect(complete.isError).not.toBe(true); + const parent = parseEvidence(complete.structuredContent); + const report = apiContractSchema.parse(parent.normalized_result); + expect(report.summary).toEqual({ + operation_slots: 20, + declared: 17, + unknown: 3, + unresolved_path_items: 2, + diagnostics: 1, + }); + const source = retained(parent); + const refs = asView( + await call("inspect_analysis_view", { + source, + view: { + kind: "api-contract", + collection: "path-item-references", + limit: 64, + }, + }), + ); + expect(refs).toMatchObject({ + items: [{ reference: "#/paths/~1pet" }, { reference }], + coverage: { exhausted: true }, + source_coverage: { status: "partial" }, + }); + const diagnostics = asView( + await call("inspect_analysis_view", { + source, + view: { + kind: "api-contract", + collection: "diagnostics", + codes: ["invalid-operation"], + }, + }), + ); + expect(diagnostics).toMatchObject({ + items: [ + { code: "invalid-operation", source_pointer: "/paths/~1broken/get" }, + ], + source_coverage: { status: "partial" }, + }); + const unknown = asView( + await call("inspect_api_contract", { + path, + view: { + kind: "api-contract", + collection: "operations", + states: ["unknown"], + }, + }), + ); + expect(unknown).toMatchObject({ + coverage: { total: 3 }, + source_coverage: { status: "partial" }, + }); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const cliResult = await run({ + arguments: ["inspect-api-contract", path, "--json"], + }); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + expect(parseEvidence(cliResult.json)).toEqual({ + ...parent, + caller_input_path: path, + }); + const unsupported = join(root, "unsupported.json"); + await writeFile(unsupported, JSON.stringify({ ...value, openapi: "2.0" })); + const refused = parseMcpToolError( + await call("inspect_api_contract", { path: unsupported }), + ); + expect(refused.error.code).toBe("unsupported_target"); + expect(refused.error.details?.partial_observation).toMatchObject({ + kind: "file-snapshot-hash", + path: unsupported, + }); + expect(requests).toBe(0); + expect(await readFile(path, "utf8")).toBe(bytes); + }, +); + +cliTest( + "preserves verified failure identity without exposing malformed JSON source through CLI/MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-invalid-api-contract-"); + const path = join(root, "malformed.json"); + const bytes = '{"$ref":"https://u:p@h","":NaN}'; + await writeFile(path, bytes); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application", + }); + const response = await call("inspect_api_contract", { path }); + const error = parseMcpToolError(response); + const snapshot = { + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(bytes), + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + expect(error.error).toMatchObject({ + code: "invalid_request", + details: { partial_observation: snapshot }, + }); + expect(JSON.stringify(response)).not.toContain("u:p@h"); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const result = await run({ + arguments: ["inspect-api-contract", path, "--json"], + }); + expect(result.exitCode).not.toBe(0); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { partial_observation: snapshot }, + }); + expect(result.stdout + result.stderr).not.toContain("u:p@h"); + expect(await readFile(path, "utf8")).toBe(bytes); + }, +); diff --git a/tests/boundary/filesystem/cliJsonInput.test.ts b/tests/boundary/filesystem/cliJsonInput.test.ts index 443bcca113..855d79e546 100644 --- a/tests/boundary/filesystem/cliJsonInput.test.ts +++ b/tests/boundary/filesystem/cliJsonInput.test.ts @@ -164,6 +164,20 @@ describe("CLI JSON streamed input", () => { } }); + it("keeps malformed source excerpts out of native and streamed file diagnostics", async () => { + const root = await createTestTempDirectory("rea-json-input-excerpt-"); + const path = join(root, "input.json"); + for (const prefix of ["", " ".repeat(9 * 1024 * 1024)]) { + await writeFile(path, `${prefix}{"$ref":"https://u:p@h","":NaN}`); + const result = await parseCliJsonInput(path, "test-input"); + expect(result).toMatchObject({ + ok: false, + error: { input_reason: "invalid-json" }, + }); + expect(JSON.stringify(result)).not.toContain("u:p@h"); + } + }); + it("names a leading byte-order mark in small and streamed files", async () => { const root = await createTestTempDirectory("rea-json-input-bom-"); const path = join(root, "input.json"); diff --git a/tests/fixtures/openapi/LICENSE.petstore.txt b/tests/fixtures/openapi/LICENSE.petstore.txt new file mode 100644 index 0000000000..8dada3edaf --- /dev/null +++ b/tests/fixtures/openapi/LICENSE.petstore.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/openapi/README.md b/tests/fixtures/openapi/README.md new file mode 100644 index 0000000000..fa597b45b5 --- /dev/null +++ b/tests/fixtures/openapi/README.md @@ -0,0 +1,19 @@ +# Published OpenAPI fixture + +`petstore.json.txt` is the original UTF-8 JSON published by the running +[Swagger Petstore 3 application](https://petstore3.swagger.io/api/v3/openapi.json), +retrieved on 2026-11-06. Its API version is `1.0.27`, OpenAPI version is `3.0.4`, +and SHA-256 is `00441c05add60f28da55eb4563d3fb6bbda364ba8eb7b381e2e2c98613005c82`. +It declares 13 paths and 19 inline operations. This is a published application +contract, not a recording proving that any endpoint was exercised or reachable. + +The [producer repository](https://github.com/swagger-api/swagger-petstore) +licenses its definition under Apache-2.0; its license is retained in +`LICENSE.petstore.txt`. No authentication credentials were used to retrieve the +public definition. The document contains declared security schemes and example +values, which are source content rather than captured transport credentials. + +The `.txt` suffix keeps formatting tools from rewriting the original source. +Tests copy it to a temporary JSON file and inspect it offline. They neither +contact Petstore nor resolve the document's references. CI needs no network to +acquire this fixture and does not rewrite it. diff --git a/tests/fixtures/openapi/petstore.json.txt b/tests/fixtures/openapi/petstore.json.txt new file mode 100644 index 0000000000..a8781928cf --- /dev/null +++ b/tests/fixtures/openapi/petstore.json.txt @@ -0,0 +1 @@ +{"openapi":"3.0.4","info":{"title":"Swagger Petstore - OpenAPI 3.0","description":"This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)","termsOfService":"https://swagger.io/terms/","contact":{"email":"apiteam@swagger.io"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"},"version":"1.0.27"},"externalDocs":{"description":"Find out more about Swagger","url":"https://swagger.io"},"servers":[{"url":"/api/v3"}],"tags":[{"name":"pet","description":"Everything about your Pets","externalDocs":{"description":"Find out more","url":"https://swagger.io"}},{"name":"store","description":"Access to Petstore orders","externalDocs":{"description":"Find out more about our store","url":"https://swagger.io"}},{"name":"user","description":"Operations about user"}],"paths":{"/pet":{"put":{"tags":["pet"],"summary":"Update an existing pet.","description":"Update an existing pet by Id.","operationId":"updatePet","requestBody":{"description":"Update an existent pet in the store","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Pet"}}},"required":true},"responses":{"200":{"description":"Successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"},"422":{"description":"Validation exception"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"post":{"tags":["pet"],"summary":"Add a new pet to the store.","description":"Add a new pet to the store.","operationId":"addPet","requestBody":{"description":"Create a new pet in the store","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Pet"}}},"required":true},"responses":{"200":{"description":"Successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"400":{"description":"Invalid input"},"422":{"description":"Validation exception"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/findByStatus":{"get":{"tags":["pet"],"summary":"Finds Pets by status.","description":"Multiple status values can be provided with comma separated strings.","operationId":"findPetsByStatus","parameters":[{"name":"status","in":"query","description":"Status values that need to be considered for filter","required":true,"explode":true,"schema":{"type":"string","default":"available","enum":["available","pending","sold"]}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}},"application/xml":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}}}},"400":{"description":"Invalid status value"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/findByTags":{"get":{"tags":["pet"],"summary":"Finds Pets by tags.","description":"Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.","operationId":"findPetsByTags","parameters":[{"name":"tags","in":"query","description":"Tags to filter by","required":true,"explode":true,"schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}},"application/xml":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}}}},"400":{"description":"Invalid tag value"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/{petId}":{"get":{"tags":["pet"],"summary":"Find pet by ID.","description":"Returns a single pet.","operationId":"getPetById","parameters":[{"name":"petId","in":"path","description":"ID of pet to return","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"},"default":{"description":"Unexpected error"}},"security":[{"api_key":[]},{"petstore_auth":["write:pets","read:pets"]}]},"post":{"tags":["pet"],"summary":"Updates a pet in the store with form data.","description":"Updates a pet resource based on the form data.","operationId":"updatePetWithForm","parameters":[{"name":"petId","in":"path","description":"ID of pet that needs to be updated","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"name","in":"query","description":"Name of pet that needs to be updated","schema":{"type":"string"}},{"name":"status","in":"query","description":"Status of pet that needs to be updated","schema":{"type":"string"}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"400":{"description":"Invalid input"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"delete":{"tags":["pet"],"summary":"Deletes a pet.","description":"Delete a pet.","operationId":"deletePet","parameters":[{"name":"api_key","in":"header","description":"","required":false,"schema":{"type":"string"}},{"name":"petId","in":"path","description":"Pet id to delete","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Pet deleted"},"400":{"description":"Invalid pet value"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/{petId}/uploadImage":{"post":{"tags":["pet"],"summary":"Uploads an image.","description":"Upload image of the pet.","operationId":"uploadFile","parameters":[{"name":"petId","in":"path","description":"ID of pet to update","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"additionalMetadata","in":"query","description":"Additional Metadata","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse"}}}},"400":{"description":"No file uploaded"},"404":{"description":"Pet not found"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/store/inventory":{"get":{"tags":["store"],"summary":"Returns pet inventories by status.","description":"Returns a map of status codes to quantities.","operationId":"getInventory","responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"integer","format":"int32"}}}}},"default":{"description":"Unexpected error"}},"security":[{"api_key":[]}]}},"/store/order":{"post":{"tags":["store"],"summary":"Place an order for a pet.","description":"Place a new order in the store.","operationId":"placeOrder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Order"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Order"}}}},"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}}}},"400":{"description":"Invalid input"},"422":{"description":"Validation exception"},"default":{"description":"Unexpected error"}}}},"/store/order/{orderId}":{"get":{"tags":["store"],"summary":"Find purchase order by ID.","description":"For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.","operationId":"getOrderById","parameters":[{"name":"orderId","in":"path","description":"ID of order that needs to be fetched","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Order"}}}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Order not found"},"default":{"description":"Unexpected error"}}},"delete":{"tags":["store"],"summary":"Delete purchase order by identifier.","description":"For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.","operationId":"deleteOrder","parameters":[{"name":"orderId","in":"path","description":"ID of the order that needs to be deleted","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"order deleted"},"400":{"description":"Invalid ID supplied"},"404":{"description":"Order not found"},"default":{"description":"Unexpected error"}}}},"/user":{"post":{"tags":["user"],"summary":"Create user.","description":"This can only be done by the logged in user.","operationId":"createUser","requestBody":{"description":"Created user object","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/User"}}}},"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}}}},"default":{"description":"Unexpected error"}}}},"/user/createWithList":{"post":{"tags":["user"],"summary":"Creates list of users with given input array.","description":"Creates list of users with given input array.","operationId":"createUsersWithListInput","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/User"}}}}},"responses":{"200":{"description":"Successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}}}},"default":{"description":"Unexpected error"}}}},"/user/login":{"get":{"tags":["user"],"summary":"Logs user into the system.","description":"Log into the system.","operationId":"loginUser","parameters":[{"name":"username","in":"query","description":"The user name for login","required":false,"schema":{"type":"string"}},{"name":"password","in":"query","description":"The password for login in clear text","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"successful operation","headers":{"X-Rate-Limit":{"description":"calls per hour allowed by the user","schema":{"type":"integer","format":"int32"}},"X-Expires-After":{"description":"date in UTC when token expires","schema":{"type":"string","format":"date-time"}}},"content":{"application/xml":{"schema":{"type":"string"}},"application/json":{"schema":{"type":"string"}}}},"400":{"description":"Invalid username/password supplied"},"default":{"description":"Unexpected error"}}}},"/user/logout":{"get":{"tags":["user"],"summary":"Logs out current logged in user session.","description":"Log user out of the system.","operationId":"logoutUser","parameters":[],"responses":{"200":{"description":"successful operation"},"default":{"description":"Unexpected error"}}}},"/user/{username}":{"get":{"tags":["user"],"summary":"Get user by user name.","description":"Get user detail based on username.","operationId":"getUserByName","parameters":[{"name":"username","in":"path","description":"The name that needs to be fetched. Use user1 for testing","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"description":"Invalid username supplied"},"404":{"description":"User not found"},"default":{"description":"Unexpected error"}}},"put":{"tags":["user"],"summary":"Update user resource.","description":"This can only be done by the logged in user.","operationId":"updateUser","parameters":[{"name":"username","in":"path","description":"name that need to be deleted","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Update an existent user in the store","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/User"}}}},"responses":{"200":{"description":"successful operation"},"400":{"description":"bad request"},"404":{"description":"user not found"},"default":{"description":"Unexpected error"}}},"delete":{"tags":["user"],"summary":"Delete user resource.","description":"This can only be done by the logged in user.","operationId":"deleteUser","parameters":[{"name":"username","in":"path","description":"The name that needs to be deleted","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User deleted"},"400":{"description":"Invalid username supplied"},"404":{"description":"User not found"},"default":{"description":"Unexpected error"}}}}},"components":{"schemas":{"Order":{"type":"object","properties":{"id":{"type":"integer","format":"int64","example":10},"petId":{"type":"integer","format":"int64","example":198772},"quantity":{"type":"integer","format":"int32","example":7},"shipDate":{"type":"string","format":"date-time"},"status":{"type":"string","description":"Order Status","example":"approved","enum":["placed","approved","delivered"]},"complete":{"type":"boolean"}},"xml":{"name":"order"}},"Category":{"type":"object","properties":{"id":{"type":"integer","format":"int64","example":1},"name":{"type":"string","example":"Dogs"}},"xml":{"name":"category"}},"User":{"type":"object","properties":{"id":{"type":"integer","format":"int64","example":10},"username":{"type":"string","example":"theUser"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"James"},"email":{"type":"string","example":"john@email.com"},"password":{"type":"string","example":"12345"},"phone":{"type":"string","example":"12345"},"userStatus":{"type":"integer","description":"User Status","format":"int32","example":1}},"xml":{"name":"user"}},"Tag":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"tag"}},"Pet":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64","example":10},"name":{"type":"string","example":"doggie"},"category":{"$ref":"#/components/schemas/Category"},"photoUrls":{"type":"array","xml":{"wrapped":true},"items":{"type":"string","xml":{"name":"photoUrl"}}},"tags":{"type":"array","xml":{"wrapped":true},"items":{"$ref":"#/components/schemas/Tag"}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"pet"}},"ApiResponse":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}},"xml":{"name":"##default"}}},"requestBodies":{"Pet":{"description":"Pet object that needs to be added to the store","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"UserArray":{"description":"List of user object","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/User"}}}}}},"securitySchemes":{"petstore_auth":{"type":"oauth2","flows":{"implicit":{"authorizationUrl":"https://petstore3.swagger.io/oauth/authorize","scopes":{"write:pets":"modify pets in your account","read:pets":"read your pets"}}}},"api_key":{"type":"apiKey","name":"api_key","in":"header"}}}} \ No newline at end of file