From 6cf0cf1e46ee6a0ed2bd8ea5db657dcceae27ae2 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Mon, 29 Jun 2026 18:06:07 +0800 Subject: [PATCH 1/2] Fix schema validation compatibility warnings --- src/core/i18n/translations/de.json | 2 +- src/core/i18n/translations/en.json | 2 +- src/core/i18n/translations/fr.json | 2 +- src/core/i18n/translations/ja.json | 2 +- src/core/i18n/translations/ko.json | 2 +- src/core/i18n/translations/zh-CN.json | 2 +- src/core/i18n/translations/zh-TW.json | 2 +- .../top-templates.ts | 4 +- .../tools/json-schema-workbench/logic.test.ts | 26 +- .../tools/json-schema-workbench/logic.ts | 84 +++- .../tools/json-schema-workbench/manifest.ts | 4 +- .../tools/json-schema-workbench/types.ts | 8 + src/features/tools/openapi-diff/logic.test.ts | 222 +++++++++++ src/features/tools/openapi-diff/logic.ts | 361 +++++++++++++++++- src/features/tools/openapi-diff/types.ts | 21 + src/generated/client-tool-lookup.ts | 5 +- src/generated/command-search-index.ts | 5 +- src/generated/tool-index.json | 2 +- src/lib/sitemap-lastmod.json | 14 +- 19 files changed, 732 insertions(+), 38 deletions(-) diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index 7d32d65f..eba12b99 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -3021,7 +3021,7 @@ }, "json_schema_workbench": { "title": "JSON-Schema-Workbench", - "description": "Erzeuge Starter-Schemas aus JSON und validiere Payloads gegen eingefügte Schemas lokal im Browser.", + "description": "Erzeuge Starter-Schemas aus JSON und fuehre grundlegende unterstuetzte Schema-Pruefungen lokal im Browser aus.", "mode_label": "JSON-Schema-Modus", "mode_generate": "Schema erzeugen", "mode_validate": "Payload validieren", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 5d7ab57a..03aa5a81 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -3041,7 +3041,7 @@ }, "json_schema_workbench": { "title": "JSON Schema Workbench", - "description": "Generate starter schemas from JSON and validate payloads against pasted schemas locally in your browser.", + "description": "Generate starter schemas from JSON and run basic supported schema checks locally in your browser.", "mode_label": "JSON Schema mode", "mode_generate": "Generate schema", "mode_validate": "Validate payload", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 73745603..158ddac4 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -3021,7 +3021,7 @@ }, "json_schema_workbench": { "title": "Atelier JSON Schema", - "description": "Générez des schemas de départ depuis du JSON et validez des payloads avec des schemas collés localement dans le navigateur.", + "description": "Generez des schemas de depart depuis du JSON et executez des controles Schema de base pris en charge localement dans le navigateur.", "mode_label": "Mode JSON Schema", "mode_generate": "Générer le schema", "mode_validate": "Valider le payload", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 17d4911d..326ce9b1 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -3021,7 +3021,7 @@ }, "json_schema_workbench": { "title": "JSON Schema ワークベンチ", - "description": "ブラウザ内で JSON から初期 Schema を生成し、貼り付けた Schema で payload を検証します。", + "description": "ブラウザ内で JSON から初期 Schema を生成し、対応済みの基本 Schema チェックを実行します。", "mode_label": "JSON Schema モード", "mode_generate": "Schema を生成", "mode_validate": "payload を検証", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 443b20cc..e2063ba3 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -3021,7 +3021,7 @@ }, "json_schema_workbench": { "title": "JSON Schema 워크벤치", - "description": "브라우저에서 JSON으로 시작 Schema를 만들고 붙여 넣은 Schema로 payload를 검증합니다.", + "description": "브라우저에서 JSON으로 시작 Schema를 만들고 지원되는 기본 Schema 검사를 실행합니다.", "mode_label": "JSON Schema 모드", "mode_generate": "Schema 생성", "mode_validate": "payload 검증", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 34d62ad0..e62171bd 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -3021,7 +3021,7 @@ }, "json_schema_workbench": { "title": "JSON Schema 工作台", - "description": "在浏览器本地从 JSON 生成起始 Schema,并用粘贴的 Schema 校验 payload。", + "description": "在浏览器本地从 JSON 生成起始 Schema,并运行基础受支持的 Schema 检查。", "mode_label": "JSON Schema 模式", "mode_generate": "生成 Schema", "mode_validate": "校验 payload", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 83c657bf..c26c6fa5 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -3021,7 +3021,7 @@ }, "json_schema_workbench": { "title": "JSON Schema 工作台", - "description": "在瀏覽器本機從 JSON 產生起始 Schema,並用貼上的 Schema 驗證 payload。", + "description": "在瀏覽器本機從 JSON 產生起始 Schema,並執行基礎支援的 Schema 檢查。", "mode_label": "JSON Schema 模式", "mode_generate": "產生 Schema", "mode_validate": "驗證 payload", diff --git a/src/core/seo/components/tool-content-template-modules/top-templates.ts b/src/core/seo/components/tool-content-template-modules/top-templates.ts index e7590f52..b8493d6d 100644 --- a/src/core/seo/components/tool-content-template-modules/top-templates.ts +++ b/src/core/seo/components/tool-content-template-modules/top-templates.ts @@ -5106,10 +5106,10 @@ export const TOP_TOOL_CONTENT_TEMPLATES: Record }, "json-schema-workbench": { toolKey: "json_schema_workbench", - intro: "Generate starter JSON Schema documents from representative payloads and validate JSON against pasted schemas locally, with path-level errors that make API contract drift easier to review.", + intro: "Generate starter JSON Schema documents from representative payloads and run basic supported schema checks locally, with path-level errors and unsupported-keyword warnings that make API contract drift easier to review.", whatThisToolDoes: [ "It infers a practical Draft 2020-12 starter schema from objects, arrays, primitives, and nested payload samples.", - "It validates pasted JSON against a schema and reports each problem with a concrete JSON-style path and fix hint.", + "It runs basic supported schema checks against pasted JSON and warns when schemas use keywords this lightweight mode does not enforce.", "It supports both direct tool usage and Pipeline Builder steps for schema generation and validation handoffs.", "It keeps payloads and schemas in the browser, which is useful when contract samples include staging fields or private identifiers.", ], diff --git a/src/features/tools/json-schema-workbench/logic.test.ts b/src/features/tools/json-schema-workbench/logic.test.ts index b4a50cf7..4f38f75d 100644 --- a/src/features/tools/json-schema-workbench/logic.test.ts +++ b/src/features/tools/json-schema-workbench/logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { generateJsonSchema, validateJsonWithSchema } from "./logic" +import { formatValidationReport, generateJsonSchema, validateJsonWithSchema } from "./logic" describe("json-schema-workbench logic", () => { it("generates a starter schema from nested JSON", () => { @@ -20,4 +20,28 @@ describe("json-schema-workbench logic", () => { it("accepts valid payloads", () => { expect(validateJsonWithSchema('{"id":1}', '{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}').valid).toBe(true) }) + + it("warns when basic validation mode sees unsupported validation keywords", () => { + const report = validateJsonWithSchema( + '{"email":"not-an-email","slug":"INVALID SPACE","tags":[]}', + JSON.stringify({ + type: "object", + properties: { + email: { type: "string", format: "email" }, + slug: { type: "string", pattern: "^[a-z0-9-]+$" }, + tags: { type: "array", minItems: 1 }, + owner: { $ref: "#/definitions/User" }, + }, + definitions: { + User: { type: "object" }, + }, + }), + ) + + expect(report.valid).toBe(true) + expect(report.warnings.map((warning) => warning.keyword).sort()).toEqual(["$ref", "format", "minItems", "pattern"]) + expect(report.summary).toContain("supported schema checks") + expect(report.summary).toContain("4 warning") + expect(formatValidationReport(report)).toContain("Basic mode does not enforce") + }) }) diff --git a/src/features/tools/json-schema-workbench/logic.ts b/src/features/tools/json-schema-workbench/logic.ts index d628f14f..e83c49af 100644 --- a/src/features/tools/json-schema-workbench/logic.ts +++ b/src/features/tools/json-schema-workbench/logic.ts @@ -1,4 +1,26 @@ -import type { JsonSchemaValidationIssue, JsonSchemaValidationReport, JsonSchemaValue } from "./types" +import type { JsonSchemaValidationIssue, JsonSchemaValidationReport, JsonSchemaValidationWarning, JsonSchemaValue } from "./types" + +const SUPPORTED_SCHEMA_KEYWORDS = new Set([ + "$schema", + "additionalProperties", + "enum", + "items", + "maximum", + "maxLength", + "minimum", + "minLength", + "properties", + "required", + "type", +]) + +const NON_VALIDATION_CONTAINER_KEYWORDS = new Set([ + "$defs", + "definitions", + "description", + "examples", + "title", +]) function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value) @@ -70,6 +92,47 @@ function addIssue(issues: JsonSchemaValidationIssue[], path: string, message: st issues.push({ path: path || "$", message, fix }) } +function addWarning(warnings: JsonSchemaValidationWarning[], path: string, keyword: string) { + if (warnings.some((warning) => warning.path === path && warning.keyword === keyword)) return + warnings.push({ + path: path || "$", + keyword, + message: `Basic mode does not enforce "${keyword}" at ${path || "$"}.`, + }) +} + +function discoverUnsupportedKeywords(schema: unknown, path: string, warnings: JsonSchemaValidationWarning[]) { + if (!isPlainObject(schema)) return + + for (const [keyword, value] of Object.entries(schema)) { + if (!SUPPORTED_SCHEMA_KEYWORDS.has(keyword) && !NON_VALIDATION_CONTAINER_KEYWORDS.has(keyword)) { + addWarning(warnings, path, keyword) + } + + if (keyword === "properties" && isPlainObject(value)) { + for (const [propertyName, propertySchema] of Object.entries(value)) { + discoverUnsupportedKeywords(propertySchema, pathJoin(path, propertyName), warnings) + } + continue + } + + if (keyword === "items") { + if (Array.isArray(value)) { + value.forEach((itemSchema, index) => discoverUnsupportedKeywords(itemSchema, pathJoin(path, `[${index}]`), warnings)) + } else { + discoverUnsupportedKeywords(value, pathJoin(path, "[]"), warnings) + } + continue + } + + if ((keyword === "$defs" || keyword === "definitions") && isPlainObject(value)) { + for (const [definitionName, definitionSchema] of Object.entries(value)) { + discoverUnsupportedKeywords(definitionSchema, pathJoin(path, definitionName), warnings) + } + } + } +} + function validateAgainstSchema(value: unknown, schema: JsonSchemaValue, path: string, issues: JsonSchemaValidationIssue[]) { if (!acceptsType(schema, value)) { addIssue(issues, path, `Expected ${Array.isArray(schema.type) ? schema.type.join(" or ") : schema.type}, received ${inferType(value)}.`, "Change the payload value or update the schema type.") @@ -126,20 +189,31 @@ export function validateJsonWithSchema(payloadInput: string, schemaInput: string const payload = JSON.parse(payloadInput) const schema = JSON.parse(schemaInput) as JsonSchemaValue const issues: JsonSchemaValidationIssue[] = [] + const warnings: JsonSchemaValidationWarning[] = [] + discoverUnsupportedKeywords(schema, "$", warnings) validateAgainstSchema(payload, schema, "$", issues) return { valid: issues.length === 0, issues, - summary: issues.length === 0 ? "Payload matches the schema." : `${issues.length} schema issue(s) found.`, + warnings, + summary: issues.length === 0 + ? warnings.length === 0 + ? "Payload matches the supported schema checks." + : `Payload matches the supported schema checks, with ${warnings.length} warning(s) for unsupported keyword(s).` + : `${issues.length} schema issue(s) found${warnings.length > 0 ? `, with ${warnings.length} warning(s) for unsupported keyword(s)` : ""}.`, } } export function formatValidationReport(report: JsonSchemaValidationReport): string { - if (report.valid) return report.summary - return [report.summary, ...report.issues.map((issue) => `${issue.path}: ${issue.message} Fix: ${issue.fix}`)].join("\n") + const warningLines = report.warnings.map((warning) => `${warning.path}: ${warning.message}`) + if (report.valid) return [report.summary, ...warningLines].join("\n") + return [ + report.summary, + ...report.issues.map((issue) => `${issue.path}: ${issue.message} Fix: ${issue.fix}`), + ...warningLines, + ].join("\n") } export function runTool(input: string): string { return generateJsonSchema(input) } - diff --git a/src/features/tools/json-schema-workbench/manifest.ts b/src/features/tools/json-schema-workbench/manifest.ts index b8b85698..ed9a0e8f 100644 --- a/src/features/tools/json-schema-workbench/manifest.ts +++ b/src/features/tools/json-schema-workbench/manifest.ts @@ -16,8 +16,8 @@ export const toolManifest = { }, sampleInput: "{\"user\":{\"id\":1001,\"email\":\"alice@example.com\",\"active\":true}}", sampleMode: "generate-schema", - keywords: ["json schema generator", "json schema validator", "json schema workbench", "validate json schema"], - searchKeywords: ["schema", "json schema", "validator", "draft 7", "contract"], + keywords: ["json schema generator", "basic json schema checks", "json schema workbench", "validate json schema"], + searchKeywords: ["schema", "json schema", "basic validator", "contract"], persistInput: false, networkAccess: "none", } satisfies ToolMeta diff --git a/src/features/tools/json-schema-workbench/types.ts b/src/features/tools/json-schema-workbench/types.ts index 9b17d249..9fcf7357 100644 --- a/src/features/tools/json-schema-workbench/types.ts +++ b/src/features/tools/json-schema-workbench/types.ts @@ -1,4 +1,5 @@ export type JsonSchemaValue = { + $ref?: string type?: string | string[] properties?: Record required?: string[] @@ -9,6 +10,7 @@ export type JsonSchemaValue = { maximum?: number minLength?: number maxLength?: number + [keyword: string]: unknown } export type JsonSchemaValidationIssue = { @@ -20,6 +22,12 @@ export type JsonSchemaValidationIssue = { export type JsonSchemaValidationReport = { valid: boolean issues: JsonSchemaValidationIssue[] + warnings: JsonSchemaValidationWarning[] summary: string } +export type JsonSchemaValidationWarning = { + path: string + keyword: string + message: string +} diff --git a/src/features/tools/openapi-diff/logic.test.ts b/src/features/tools/openapi-diff/logic.test.ts index 0828de96..0691d861 100644 --- a/src/features/tools/openapi-diff/logic.test.ts +++ b/src/features/tools/openapi-diff/logic.test.ts @@ -182,4 +182,226 @@ describe("openapi-diff logic", () => { expect.objectContaining({ kind: "added", target: "Security scheme oauth" }), ])) }) + + it("flags request body requiredness and request media type removals", () => { + const before = JSON.stringify({ + paths: { + "/pets": { + post: { + requestBody: { + required: false, + content: { + "application/json": { schema: { type: "object" } }, + "application/xml": { schema: { type: "object" } }, + }, + }, + responses: { "201": {} }, + }, + put: { + responses: { "200": {} }, + }, + }, + }, + }) + const after = JSON.stringify({ + paths: { + "/pets": { + post: { + requestBody: { + required: true, + content: { + "application/json": { schema: { type: "object" } }, + }, + }, + responses: { "201": {} }, + }, + put: { + requestBody: { + required: true, + content: { + "application/json": { schema: { type: "object" } }, + }, + }, + responses: { "200": {} }, + }, + }, + }, + }) + + const report = diffOpenApiSpecs(before, after) + + expect(report.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "breaking", + target: "POST /pets", + message: "requestBody requiredness changed: optional -> required", + }), + expect.objectContaining({ + kind: "breaking", + target: "POST /pets", + message: "request media type removed: application/xml", + }), + expect.objectContaining({ + kind: "breaking", + target: "PUT /pets", + message: "required requestBody added.", + }), + ])) + }) + + it("flags response media type removals and schema compatibility risks", () => { + const before = JSON.stringify({ + paths: { + "/pets": { + get: { + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "integer" }, + status: { type: "string", enum: ["available", "sold"] }, + name: { type: "string" }, + }, + required: ["id"], + }, + }, + "application/xml": { schema: { type: "object" } }, + }, + }, + }, + }, + }, + }, + }) + const after = JSON.stringify({ + paths: { + "/pets": { + get: { + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string" }, + status: { type: "string", enum: ["available"] }, + }, + required: ["id", "name"], + }, + }, + }, + }, + }, + }, + }, + }, + }) + + const report = diffOpenApiSpecs(before, after) + + expect(report.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "breaking", + target: "GET /pets response 200", + message: "response media type removed: application/xml", + }), + expect.objectContaining({ + kind: "breaking", + target: "GET /pets response 200 application/json $.name", + message: "response schema property removed.", + }), + expect.objectContaining({ + kind: "breaking", + target: "GET /pets response 200 application/json $.id", + message: "schema type changed: integer -> string", + }), + expect.objectContaining({ + kind: "breaking", + target: "GET /pets response 200 application/json $.status", + message: "schema enum narrowed.", + }), + ])) + }) + + it("resolves local refs for parameters and request body schema compatibility", () => { + const before = JSON.stringify({ + paths: { + "/pets": { + get: { + parameters: [{ $ref: "#/components/parameters/TenantId" }], + responses: { "200": {} }, + }, + post: { + requestBody: { + required: true, + content: { + "application/json": { schema: { $ref: "#/components/schemas/PetInput" } }, + }, + }, + responses: { "201": {} }, + }, + }, + }, + components: { + parameters: { + TenantId: { in: "header", name: "X-Tenant", required: false }, + }, + schemas: { + PetInput: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + }, + }) + const after = JSON.stringify({ + paths: { + "/pets": { + get: { + parameters: [{ $ref: "#/components/parameters/TenantId" }], + responses: { "200": {} }, + }, + post: { + requestBody: { + required: true, + content: { + "application/json": { schema: { $ref: "#/components/schemas/PetInput" } }, + }, + }, + responses: { "201": {} }, + }, + }, + }, + components: { + parameters: { + TenantId: { in: "header", name: "X-Tenant", required: true }, + }, + schemas: { + PetInput: { + type: "object", + properties: { name: { type: "string" }, category: { type: "string" } }, + required: ["category"], + }, + }, + }, + }) + + const report = diffOpenApiSpecs(before, after) + + expect(report.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "breaking", + message: 'parameter requiredness changed: header parameter "X-Tenant" optional -> required', + }), + expect.objectContaining({ + kind: "breaking", + target: "POST /pets request application/json $.category", + message: "request schema added required property.", + }), + ])) + }) }) diff --git a/src/features/tools/openapi-diff/logic.ts b/src/features/tools/openapi-diff/logic.ts index b7487f38..931329df 100644 --- a/src/features/tools/openapi-diff/logic.ts +++ b/src/features/tools/openapi-diff/logic.ts @@ -1,7 +1,18 @@ import YAML from "yaml" -import type { OpenApiDiffItem, OpenApiDiffReport, OpenApiMethod, OpenApiOperationSnapshot, OpenApiParameterSnapshot, OpenApiSpecSnapshot } from "./types" +import type { + OpenApiDiffItem, + OpenApiDiffReport, + OpenApiMethod, + OpenApiOperationSnapshot, + OpenApiParameterSnapshot, + OpenApiRequestBodySnapshot, + OpenApiResponseContentSnapshot, + OpenApiSchemaSnapshot, + OpenApiSpecSnapshot, +} from "./types" const METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]) +type OpenApiDocument = Record function parseSpec(input: string): Record { const trimmed = input.trim() @@ -18,9 +29,32 @@ function objectEntries(value: unknown): [string, Record][] { return Object.entries(value as Record).filter(([, entry]) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) as [string, Record][] } -function snapshotParameter(parameter: unknown): OpenApiParameterSnapshot | undefined { - if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) return undefined - const param = parameter as Record +function readJsonPointer(document: OpenApiDocument, ref: string): unknown { + if (!ref.startsWith("#/")) return undefined + return ref + .slice(2) + .split("/") + .map((part) => decodeURIComponent(part).replace(/~1/g, "/").replace(/~0/g, "~")) + .reduce((current, key) => { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined + return (current as Record)[key] + }, document) +} + +function resolveLocalRef(document: OpenApiDocument, value: unknown, seen = new Set()): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return value + const ref = (value as Record).$ref + if (typeof ref !== "string") return value + if (!ref.startsWith("#/") || seen.has(ref)) return value + const resolved = readJsonPointer(document, ref) + if (!resolved) return value + return resolveLocalRef(document, resolved, new Set([...seen, ref])) +} + +function snapshotParameter(parameter: unknown, spec: OpenApiDocument): OpenApiParameterSnapshot | undefined { + const resolvedParameter = resolveLocalRef(spec, parameter) + if (!resolvedParameter || typeof resolvedParameter !== "object" || Array.isArray(resolvedParameter)) return undefined + const param = resolvedParameter as Record const location = typeof param.in === "string" && param.in ? param.in : "query" const name = typeof param.name === "string" && param.name ? param.name : "(unnamed)" return { @@ -31,12 +65,12 @@ function snapshotParameter(parameter: unknown): OpenApiParameterSnapshot | undef } } -function listParameters(pathItem: Record, operation: Record): OpenApiParameterSnapshot[] { +function listParameters(spec: OpenApiDocument, pathItem: Record, operation: Record): OpenApiParameterSnapshot[] { const parametersByKey = new Map() const pathParameters = Array.isArray(pathItem.parameters) ? pathItem.parameters : [] const operationParameters = Array.isArray(operation.parameters) ? operation.parameters : [] for (const parameter of [...pathParameters, ...operationParameters]) { - const snapshot = snapshotParameter(parameter) + const snapshot = snapshotParameter(parameter, spec) if (snapshot) parametersByKey.set(snapshot.key, snapshot) } return [...parametersByKey.values()].sort((left, right) => left.key.localeCompare(right.key)) @@ -60,10 +94,76 @@ function stableStringify(value: unknown): string { .join(",")}}` } +function schemaPathJoin(path: string, segment: string): string { + if (!path || path === "$") return `$.${segment}` + return `${path}.${segment}` +} + function snapshotNamedObjects(value: unknown): Map { return new Map(objectEntries(value).map(([name, entry]) => [name, stableStringify(entry)])) } +function normalizeType(type: unknown): string | undefined { + if (typeof type === "string") return type + if (Array.isArray(type) && type.every((entry) => typeof entry === "string")) return [...type].sort().join(" or ") + return undefined +} + +function snapshotSchema(schema: unknown, spec: OpenApiDocument, seen = new Set()): OpenApiSchemaSnapshot { + const unresolvedRef = schema && typeof schema === "object" && !Array.isArray(schema) && typeof (schema as Record).$ref === "string" + ? (schema as Record).$ref as string + : undefined + const resolved = resolveLocalRef(spec, schema, seen) + if (!resolved || typeof resolved !== "object" || Array.isArray(resolved)) { + return { required: [], properties: {}, fingerprint: stableStringify(resolved), unresolvedRef } + } + + const record = resolved as Record + const required = Array.isArray(record.required) + ? record.required.filter((entry): entry is string => typeof entry === "string").sort() + : [] + const properties = Object.fromEntries( + objectEntries(record.properties).map(([name, propertySchema]) => [name, snapshotSchema(propertySchema, spec, seen)]), + ) + return { + type: normalizeType(record.type), + enum: Array.isArray(record.enum) ? [...record.enum] : undefined, + required, + properties, + fingerprint: stableStringify(resolved), + unresolvedRef: resolved === schema ? unresolvedRef : undefined, + } +} + +function snapshotMediaSchemas(content: unknown, spec: OpenApiDocument): Record { + return Object.fromEntries( + objectEntries(content).map(([mediaType, mediaObject]) => [mediaType, snapshotSchema(mediaObject.schema, spec)]), + ) +} + +function snapshotRequestBody(operation: Record, spec: OpenApiDocument): OpenApiRequestBodySnapshot | undefined { + const requestBody = resolveLocalRef(spec, operation.requestBody) + if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return undefined + const record = requestBody as Record + return { + required: record.required === true, + mediaTypes: snapshotMediaSchemas(record.content, spec), + } +} + +function listResponseContent(operation: Record, spec: OpenApiDocument): OpenApiResponseContentSnapshot[] { + return objectEntries(operation.responses).map(([statusCode, response]) => { + const resolvedResponse = resolveLocalRef(spec, response) + const responseRecord = resolvedResponse && typeof resolvedResponse === "object" && !Array.isArray(resolvedResponse) + ? resolvedResponse as Record + : {} + return { + statusCode, + mediaTypes: snapshotMediaSchemas(responseRecord.content, spec), + } + }).sort((left, right) => left.statusCode.localeCompare(right.statusCode)) +} + function snapshotSpec(input: string): OpenApiSpecSnapshot { const spec = parseSpec(input) const operations = new Map() @@ -75,8 +175,10 @@ function snapshotSpec(input: string): OpenApiSpecSnapshot { method: method as OpenApiMethod, operationId: typeof operation.operationId === "string" ? operation.operationId : undefined, summary: typeof operation.summary === "string" ? operation.summary : undefined, - parameters: listParameters(pathItem, operation), + parameters: listParameters(spec, pathItem, operation), + requestBody: snapshotRequestBody(operation, spec), responses: listResponseCodes(operation), + responseContent: listResponseContent(operation, spec), security: listSecurity(operation), } operations.set(`${method.toUpperCase()} ${path}`, snapshot) @@ -162,6 +264,249 @@ function diffParameters(target: string, before: OpenApiParameterSnapshot[], afte return items } +function diffMediaTypes(target: string, label: string, before: Record, after: Record): OpenApiDiffItem[] { + const beforeTypes = Object.keys(before).sort() + const afterTypes = Object.keys(after).sort() + const afterSet = new Set(afterTypes) + const beforeSet = new Set(beforeTypes) + const items: OpenApiDiffItem[] = [] + + for (const mediaType of beforeTypes) { + if (!afterSet.has(mediaType)) { + items.push({ + kind: "breaking", + target, + message: `${label} media type removed: ${mediaType}`, + before: mediaType, + }) + } + } + + for (const mediaType of afterTypes) { + if (!beforeSet.has(mediaType)) { + items.push({ + kind: "changed", + target, + message: `${label} media type added: ${mediaType}`, + after: mediaType, + }) + } + } + + return items +} + +function enumNarrowed(beforeEnum: unknown[], afterEnum: unknown[]): boolean { + if (afterEnum.length >= beforeEnum.length) return false + const beforeValues = new Set(beforeEnum.map(stableStringify)) + return afterEnum.every((entry) => beforeValues.has(stableStringify(entry))) +} + +function enumWidened(beforeEnum: unknown[], afterEnum: unknown[]): boolean { + if (afterEnum.length <= beforeEnum.length) return false + const afterValues = new Set(afterEnum.map(stableStringify)) + return beforeEnum.every((entry) => afterValues.has(stableStringify(entry))) +} + +function diffSchemaCompatibility( + target: string, + before: OpenApiSchemaSnapshot, + after: OpenApiSchemaSnapshot, + context: "request" | "response", + path = "$", +): OpenApiDiffItem[] { + const items: OpenApiDiffItem[] = [] + + if (before.unresolvedRef || after.unresolvedRef) { + items.push({ + kind: "changed", + target, + message: `schema ref could not be fully resolved: ${before.unresolvedRef ?? after.unresolvedRef}`, + before: before.unresolvedRef, + after: after.unresolvedRef, + }) + return items + } + + if (before.type && after.type && before.type !== after.type) { + items.push({ + kind: "breaking", + target: `${target} ${path}`, + message: `schema type changed: ${before.type} -> ${after.type}`, + before: before.type, + after: after.type, + }) + } + + if (before.enum && after.enum) { + if (enumNarrowed(before.enum, after.enum)) { + items.push({ + kind: "breaking", + target: `${target} ${path}`, + message: "schema enum narrowed.", + before: stableStringify(before.enum), + after: stableStringify(after.enum), + }) + } else if (enumWidened(before.enum, after.enum)) { + items.push({ + kind: "changed", + target: `${target} ${path}`, + message: "schema enum widened.", + before: stableStringify(before.enum), + after: stableStringify(after.enum), + }) + } else if (stableStringify(before.enum) !== stableStringify(after.enum)) { + items.push({ + kind: "changed", + target: `${target} ${path}`, + message: "schema enum changed.", + before: stableStringify(before.enum), + after: stableStringify(after.enum), + }) + } + } + + const beforeRequired = new Set(before.required) + const afterRequired = new Set(after.required) + for (const requiredProperty of after.required) { + if (!beforeRequired.has(requiredProperty)) { + items.push({ + kind: context === "request" ? "breaking" : "changed", + target: `${target} ${schemaPathJoin(path, requiredProperty)}`, + message: `${context} schema added required property.`, + after: requiredProperty, + }) + } + } + for (const requiredProperty of before.required) { + if (!afterRequired.has(requiredProperty)) { + items.push({ + kind: "changed", + target: `${target} ${schemaPathJoin(path, requiredProperty)}`, + message: `${context} schema removed required property.`, + before: requiredProperty, + }) + } + } + + const beforePropertyNames = Object.keys(before.properties).sort() + const afterPropertyNames = Object.keys(after.properties).sort() + const afterPropertySet = new Set(afterPropertyNames) + const beforePropertySet = new Set(beforePropertyNames) + + for (const propertyName of beforePropertyNames) { + if (!afterPropertySet.has(propertyName)) { + items.push({ + kind: context === "response" ? "breaking" : "changed", + target: `${target} ${schemaPathJoin(path, propertyName)}`, + message: `${context} schema property removed.`, + before: propertyName, + }) + continue + } + items.push(...diffSchemaCompatibility( + target, + before.properties[propertyName], + after.properties[propertyName], + context, + schemaPathJoin(path, propertyName), + )) + } + + for (const propertyName of afterPropertyNames) { + if (!beforePropertySet.has(propertyName) && !afterRequired.has(propertyName)) { + items.push({ + kind: "changed", + target: `${target} ${schemaPathJoin(path, propertyName)}`, + message: `${context} schema property added.`, + after: propertyName, + }) + } + } + + if (items.length === 0 && before.fingerprint !== after.fingerprint) { + items.push({ + kind: "changed", + target, + message: `${context} schema changed.`, + before: before.fingerprint, + after: after.fingerprint, + }) + } + + return items +} + +function diffRequestBody(target: string, before: OpenApiRequestBodySnapshot | undefined, after: OpenApiRequestBodySnapshot | undefined): OpenApiDiffItem[] { + if (!before && !after) return [] + if (!before && after) { + const items: OpenApiDiffItem[] = [{ + kind: after.required ? "breaking" : "changed", + target, + message: after.required ? "required requestBody added." : "optional requestBody added.", + after: requiredLabel(after.required), + }] + return [ + ...items, + ...Object.entries(after.mediaTypes).map(([mediaType]) => ({ + kind: "changed" as const, + target, + message: `request media type added: ${mediaType}`, + after: mediaType, + })), + ] + } + if (before && !after) { + return [{ + kind: "changed", + target, + message: "requestBody removed.", + before: requiredLabel(before.required), + }] + } + + const beforeBody = before! + const afterBody = after! + const items: OpenApiDiffItem[] = [] + if (beforeBody.required !== afterBody.required) { + items.push({ + kind: afterBody.required ? "breaking" : "changed", + target, + message: `requestBody requiredness changed: ${requiredLabel(beforeBody.required)} -> ${requiredLabel(afterBody.required)}`, + before: requiredLabel(beforeBody.required), + after: requiredLabel(afterBody.required), + }) + } + + items.push(...diffMediaTypes(target, "request", beforeBody.mediaTypes, afterBody.mediaTypes)) + for (const mediaType of Object.keys(beforeBody.mediaTypes).sort()) { + if (afterBody.mediaTypes[mediaType]) { + items.push(...diffSchemaCompatibility(`${target} request ${mediaType}`, beforeBody.mediaTypes[mediaType], afterBody.mediaTypes[mediaType], "request")) + } + } + return items +} + +function diffResponseContent(target: string, before: OpenApiResponseContentSnapshot[], after: OpenApiResponseContentSnapshot[]): OpenApiDiffItem[] { + const beforeByStatus = new Map(before.map((response) => [response.statusCode, response])) + const afterByStatus = new Map(after.map((response) => [response.statusCode, response])) + const items: OpenApiDiffItem[] = [] + + for (const [statusCode, beforeResponse] of beforeByStatus) { + const afterResponse = afterByStatus.get(statusCode) + if (!afterResponse) continue + const responseTarget = `${target} response ${statusCode}` + items.push(...diffMediaTypes(responseTarget, "response", beforeResponse.mediaTypes, afterResponse.mediaTypes)) + for (const mediaType of Object.keys(beforeResponse.mediaTypes).sort()) { + if (afterResponse.mediaTypes[mediaType]) { + items.push(...diffSchemaCompatibility(`${responseTarget} ${mediaType}`, beforeResponse.mediaTypes[mediaType], afterResponse.mediaTypes[mediaType], "response")) + } + } + } + + return items +} + function diffNamedObjects(targetPrefix: string, before: Map, after: Map, breakingOnRemove: boolean): OpenApiDiffItem[] { const items: OpenApiDiffItem[] = [] for (const [name, beforeValue] of before) { @@ -220,7 +565,9 @@ export function diffOpenApiSpecs(beforeInput: string, afterInput: string): OpenA }) } items.push(...diffParameters(target, beforeOperation.parameters, afterOperation.parameters)) + items.push(...diffRequestBody(target, beforeOperation.requestBody, afterOperation.requestBody)) items.push(...diffList(target, "response", beforeOperation.responses, afterOperation.responses)) + items.push(...diffResponseContent(target, beforeOperation.responseContent, afterOperation.responseContent)) items.push(...diffList(target, "security", beforeOperation.security, afterOperation.security)) } diff --git a/src/features/tools/openapi-diff/types.ts b/src/features/tools/openapi-diff/types.ts index 16acb6f9..b9f7863a 100644 --- a/src/features/tools/openapi-diff/types.ts +++ b/src/features/tools/openapi-diff/types.ts @@ -6,7 +6,9 @@ export type OpenApiOperationSnapshot = { operationId?: string summary?: string parameters: OpenApiParameterSnapshot[] + requestBody?: OpenApiRequestBodySnapshot responses: string[] + responseContent: OpenApiResponseContentSnapshot[] security: string[] } @@ -23,6 +25,25 @@ export type OpenApiSpecSnapshot = { securitySchemes: Map } +export type OpenApiRequestBodySnapshot = { + required: boolean + mediaTypes: Record +} + +export type OpenApiResponseContentSnapshot = { + statusCode: string + mediaTypes: Record +} + +export type OpenApiSchemaSnapshot = { + type?: string + enum?: unknown[] + required: string[] + properties: Record + fingerprint: string + unresolvedRef?: string +} + export type OpenApiDiffItem = { kind: "added" | "removed" | "changed" | "breaking" target: string diff --git a/src/generated/client-tool-lookup.ts b/src/generated/client-tool-lookup.ts index 5868d98c..16170151 100644 --- a/src/generated/client-tool-lookup.ts +++ b/src/generated/client-tool-lookup.ts @@ -6472,7 +6472,7 @@ const CLIENT_TOOL_LOOKUP: Record = { "slug": "json-schema-workbench", "keywords": [ "json schema generator", - "json schema validator", + "basic json schema checks", "json schema workbench", "validate json schema" ], @@ -6517,8 +6517,7 @@ const CLIENT_TOOL_LOOKUP: Record = { "searchKeywords": [ "schema", "json schema", - "validator", - "draft 7", + "basic validator", "contract" ] }, diff --git a/src/generated/command-search-index.ts b/src/generated/command-search-index.ts index ae50bb33..e5365179 100644 --- a/src/generated/command-search-index.ts +++ b/src/generated/command-search-index.ts @@ -2927,7 +2927,7 @@ const COMMAND_SEARCH_INDEX: Record = { "slug": "json-schema-workbench", "keywords": [ "json schema generator", - "json schema validator", + "basic json schema checks", "json schema workbench", "validate json schema" ], @@ -2935,8 +2935,7 @@ const COMMAND_SEARCH_INDEX: Record = { "searchKeywords": [ "schema", "json schema", - "validator", - "draft 7", + "basic validator", "contract" ], "family": "formatters-validators", diff --git a/src/generated/tool-index.json b/src/generated/tool-index.json index 19b43f59..4f55fb25 100644 --- a/src/generated/tool-index.json +++ b/src/generated/tool-index.json @@ -5350,7 +5350,7 @@ "relatedWorkflows": [], "keywords": [ "json schema generator", - "json schema validator", + "basic json schema checks", "json schema workbench", "validate json schema" ], diff --git a/src/lib/sitemap-lastmod.json b/src/lib/sitemap-lastmod.json index c11eb81d..c78ab5c5 100644 --- a/src/lib/sitemap-lastmod.json +++ b/src/lib/sitemap-lastmod.json @@ -1869,13 +1869,13 @@ "fr": "2026-06-24T00:00:00.000Z" }, "json-schema-workbench": { - "en": "2026-06-24T00:00:00.000Z", - "zh-CN": "2026-06-24T00:00:00.000Z", - "zh-TW": "2026-06-24T00:00:00.000Z", - "ja": "2026-06-24T00:00:00.000Z", - "ko": "2026-06-24T00:00:00.000Z", - "de": "2026-06-24T00:00:00.000Z", - "fr": "2026-06-24T00:00:00.000Z" + "en": "2026-06-29T00:00:00.000Z", + "zh-CN": "2026-06-29T00:00:00.000Z", + "zh-TW": "2026-06-29T00:00:00.000Z", + "ja": "2026-06-29T00:00:00.000Z", + "ko": "2026-06-29T00:00:00.000Z", + "de": "2026-06-29T00:00:00.000Z", + "fr": "2026-06-29T00:00:00.000Z" }, "openapi-diff": { "en": "2026-06-24T00:00:00.000Z", From 6a400bb4491f07f799148dae69d7f2e0e59aeee7 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Mon, 29 Jun 2026 18:58:46 +0800 Subject: [PATCH 2/2] Stabilize mobile navigation smoke path --- scripts/e2e/run-playwright-smoke.js | 4 ++++ src/components/layout/deferred-mobile-nav-menu.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index 9776e48f..e263dc71 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -529,6 +529,10 @@ async function assertMobileNavigationKeyboardPath(browser, baseUrl) { const menuTrigger = page.getByRole("button", { name: /Open Navigation/i }).first(); await menuTrigger.waitFor({ state: "visible", timeout: 15_000 }); + await page.waitForFunction(() => { + const buttons = Array.from(document.querySelectorAll("button")); + return buttons.some((button) => /Open Navigation/i.test(button.textContent || button.getAttribute("aria-label") || "") && !button.disabled); + }, null, { timeout: 15_000 }); await menuTrigger.focus(); await page.keyboard.press("Enter"); diff --git a/src/components/layout/deferred-mobile-nav-menu.tsx b/src/components/layout/deferred-mobile-nav-menu.tsx index b382cd73..eadd3cae 100644 --- a/src/components/layout/deferred-mobile-nav-menu.tsx +++ b/src/components/layout/deferred-mobile-nav-menu.tsx @@ -2,6 +2,7 @@ import * as React from "react" import dynamic from "next/dynamic" +import { useHasMounted } from "@/hooks/use-has-mounted" const NavbarMobileMenu = dynamic( () => import("./navbar-mobile-menu").then((mod) => mod.NavbarMobileMenu), @@ -12,6 +13,7 @@ export function DeferredMobileNavMenu({ }: { menuLabel: string }) { + const hasMounted = useHasMounted() const [isMounted, setIsMounted] = React.useState(false) const [isOpen, setIsOpen] = React.useState(false) const triggerRef = React.useRef(null) @@ -24,11 +26,13 @@ export function DeferredMobileNavMenu({ }, []) const mountMenu = React.useCallback(() => { + if (!hasMounted) return + React.startTransition(() => { setIsMounted(true) }) setIsOpen(true) - }, []) + }, [hasMounted]) return ( <> @@ -38,6 +42,7 @@ export function DeferredMobileNavMenu({ className="inline-flex h-11 w-11 items-center justify-center rounded-lg text-sm font-medium transition-[color,background-color,border-color,box-shadow,opacity,transform] outline-none hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 lg:hidden dark:hover:bg-accent/50" onClick={isMounted ? () => setIsOpen(true) : mountMenu} aria-expanded={isMounted ? isOpen : undefined} + disabled={!hasMounted} >