diff --git a/package.json b/package.json index 2d36f0a..8d626ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eropple/fastify-openapi3", - "version": "1.0.1", + "version": "1.0.2", "author": "Ed Ropple", "license": "MIT", "repository": { diff --git a/src/spec-transforms/find.ts b/src/spec-transforms/find.ts index 39fbd09..a35cb1e 100644 --- a/src/spec-transforms/find.ts +++ b/src/spec-transforms/find.ts @@ -11,7 +11,6 @@ import type { ResponsesObject, SchemaObject, } from "openapi3-ts"; -import { Type } from "typebox"; import { isFalsy } from "utility-types"; import { @@ -42,13 +41,19 @@ function findTaggedSchemasInSchemas( return isTaggedSchema(s) ? [s] : []; } + // Note: We check s.type === 'array' directly instead of using Type.IsArray(s) + // because Fastify's schema processing strips TypeBox's internal ~kind property, + // which Type.IsArray relies on. By checking the JSON Schema type directly, + // we ensure this works even after Fastify processes the schema. + const isArraySchema = s.type === "array" && s.items !== undefined; + const ret = [ s.allOf ?? [], s.anyOf ?? [], s.oneOf ?? [], Object.values(s.properties ?? {}), s.additionalProperties, - Type.IsArray(s) && [s.items], + isArraySchema && [s.items], ] .flat() .filter(isNotPrimitive) diff --git a/src/test/plugin.spec.ts b/src/test/plugin.spec.ts index a2ffad8..f42d6cc 100644 --- a/src/test/plugin.spec.ts +++ b/src/test/plugin.spec.ts @@ -566,6 +566,214 @@ describe("plugin", () => { }); }); + describe("nested schemaType in arrays", () => { + test("schema symbols survive when passed to route handlers", async () => { + const Inner = schemaType("Inner", Type.Object({ foo: Type.String() })); + const Outer = schemaType( + "Outer", + Type.Object({ items: Type.Array(Inner) }), + ); + + const { SCHEMA_NAME_PROPERTY } = await import("../constants.js"); + const { isTaggedSchema } = await import("../util.js"); + + // Check our schemas before Fastify touches them + expect(isTaggedSchema(Inner)).toBe(true); + expect(isTaggedSchema(Outer)).toBe(true); + expect(isTaggedSchema(Outer.properties.items.items)).toBe(true); + + const fastify = Fastify(fastifyOpts); + + // Track what the schema looks like in onRoute + let capturedSchema: unknown; + + fastify.addHook("onRoute", (routeOptions) => { + if (routeOptions.url === "/test") { + capturedSchema = routeOptions.schema?.response?.[200]; + } + }); + + await fastify.register(oas3Plugin, { ...pluginOpts }); + + await fastify.register(async (fastify: FastifyInstance) => { + fastify.get("/test", { + schema: { + response: { + 200: Outer, + }, + }, + oas: {}, + handler: async () => ({ items: [{ foo: "bar" }] }), + }); + }); + + await fastify.ready(); + + // Check if the schema passed to onRoute still has the symbol + expect(capturedSchema).toBeDefined(); + // Check that the top-level schema retains its tag + expect(isTaggedSchema(capturedSchema)).toBe(true); + + // Check that the nested Inner schema within the array also retains its tag + const capturedInner = (capturedSchema as Record) + ?.properties as Record; + const capturedItems = capturedInner?.items as Record; + const capturedItemsItems = capturedItems?.items; + expect(isTaggedSchema(capturedItemsItems)).toBe(true); + }); + + test("handles deeply nested arrays (array of arrays with schemaType)", async () => { + const Inner = schemaType("Inner", Type.Object({ foo: Type.String() })); + // Array of arrays of Inner + const Outer = schemaType( + "Outer", + Type.Object({ matrix: Type.Array(Type.Array(Inner)) }), + ); + + const fastify = Fastify(fastifyOpts); + await fastify.register(oas3Plugin, { ...pluginOpts }); + + await fastify.register(async (fastify: FastifyInstance) => { + fastify.get("/test", { + schema: { + response: { + 200: Outer, + }, + }, + oas: {}, + handler: async () => ({ matrix: [[{ foo: "bar" }]] }), + }); + }); + + await fastify.ready(); + + const jsonResponse = await fastify.inject({ + method: "GET", + path: "/openapi.json", + }); + + const jsonDoc = JSON.parse(jsonResponse.body); + + // Both schemas should be in components/schemas + expect(Object.keys(jsonDoc.components?.schemas ?? {})).toContain("Inner"); + expect(Object.keys(jsonDoc.components?.schemas ?? {})).toContain("Outer"); + + // The deeply nested Inner should be referenced via $ref + const outerSchema = jsonDoc.components?.schemas?.Outer; + const matrixItems = outerSchema.properties.matrix.items; // the inner array + expect(matrixItems.type).toBe("array"); + expect(matrixItems.items).toEqual({ + $ref: "#/components/schemas/Inner", + }); + }); + + test("handles nullable arrays with schemaType via Type.Union", async () => { + const Inner = schemaType("Inner", Type.Object({ foo: Type.String() })); + // Nullable array: Type.Array(Inner) | null + const Outer = schemaType( + "Outer", + Type.Object({ + items: Type.Union([Type.Array(Inner), Type.Null()]), + }), + ); + + const fastify = Fastify(fastifyOpts); + await fastify.register(oas3Plugin, { ...pluginOpts }); + + await fastify.register(async (fastify: FastifyInstance) => { + fastify.get("/test", { + schema: { + response: { + 200: Outer, + }, + }, + oas: {}, + handler: async () => ({ items: [{ foo: "bar" }] }), + }); + }); + + await fastify.ready(); + + const jsonResponse = await fastify.inject({ + method: "GET", + path: "/openapi.json", + }); + + const jsonDoc = JSON.parse(jsonResponse.body); + + // Both schemas should be in components/schemas + expect(Object.keys(jsonDoc.components?.schemas ?? {})).toContain("Inner"); + expect(Object.keys(jsonDoc.components?.schemas ?? {})).toContain("Outer"); + + // The Inner schema inside the anyOf array branch should be a $ref + const outerSchema = jsonDoc.components?.schemas?.Outer; + const itemsProperty = outerSchema.properties.items; + + // TypeBox generates anyOf for Union + expect(itemsProperty.anyOf).toBeDefined(); + + // Find the array variant in anyOf + const arrayVariant = itemsProperty.anyOf.find( + (v: { type: string }) => v.type === "array", + ); + expect(arrayVariant).toBeDefined(); + expect(arrayVariant.items).toEqual({ + $ref: "#/components/schemas/Inner", + }); + }); + + test("correctly extracts and references schemaTypes nested in Type.Array", async () => { + const Inner = schemaType("Inner", Type.Object({ foo: Type.String() })); + const Outer = schemaType( + "Outer", + Type.Object({ items: Type.Array(Inner) }), + ); + + const fastify = Fastify(fastifyOpts); + await fastify.register(oas3Plugin, { ...pluginOpts }); + + await fastify.register(async (fastify: FastifyInstance) => { + fastify.get("/test", { + schema: { + response: { + 200: Outer, + }, + }, + oas: {}, + handler: async () => ({ items: [{ foo: "bar" }] }), + }); + }); + + await fastify.ready(); + + const jsonResponse = await fastify.inject({ + method: "GET", + path: "/openapi.json", + }); + + const jsonDoc = JSON.parse(jsonResponse.body); + + // Both schemas should be in components/schemas + expect(Object.keys(jsonDoc.components?.schemas ?? {})).toContain("Inner"); + expect(Object.keys(jsonDoc.components?.schemas ?? {})).toContain("Outer"); + + // The Outer schema's items property should reference Inner via $ref + const outerSchema = jsonDoc.components?.schemas?.Outer; + expect(outerSchema.properties.items.type).toBe("array"); + expect(outerSchema.properties.items.items).toEqual({ + $ref: "#/components/schemas/Inner", + }); + + // The response should reference Outer + const operation = jsonDoc.paths?.["/test"]?.get; + expect( + operation?.responses?.["200"]?.content?.[APPLICATION_JSON]?.schema, + ).toEqual({ + $ref: "#/components/schemas/Outer", + }); + }); + }); + describe("vendor extensions", () => { test("correctly handles vendorPrefixedFields in operations", async () => { const fastify = Fastify(fastifyOpts); diff --git a/src/test/spec-transforms.spec.ts b/src/test/spec-transforms.spec.ts index 9cc4acd..5ab1d31 100644 --- a/src/test/spec-transforms.spec.ts +++ b/src/test/spec-transforms.spec.ts @@ -309,6 +309,75 @@ describe("schema canonicalization", () => { }); describe("schema fixup", () => { + test("properly replaces array items with $ref for nested schemaType (from components)", () => { + const Inner = schemaType("Inner", Type.Object({ foo: Type.String() })); + const Outer = schemaType( + "Outer", + Type.Object({ items: Type.Array(Inner) }), + ); + + const oas: OpenAPIObject = { + ...baseOas, + components: { + schemas: { + Outer: Outer, + }, + }, + }; + + canonicalizeAnnotatedSchemas(oas); + + // Both schemas should be in components + expect(Object.keys(oas.components?.schemas ?? {})).toContain("Inner"); + expect(Object.keys(oas.components?.schemas ?? {})).toContain("Outer"); + + // The array's items should be a $ref, not the inline schema + const outerSchema = oas.components?.schemas?.Outer as SchemaObject; + const itemsProperty = outerSchema.properties?.items as SchemaObject; + expect(itemsProperty.type).toBe("array"); + expect(itemsProperty.items).toEqual({ $ref: "#/components/schemas/Inner" }); + }); + + test("properly replaces array items with $ref for nested schemaType (from path response)", () => { + const Inner = schemaType("Inner", Type.Object({ foo: Type.String() })); + const Outer = schemaType( + "Outer", + Type.Object({ items: Type.Array(Inner) }), + ); + + const oas: OpenAPIObject = { + ...baseOas, + paths: { + "/test": { + get: { + responses: { + 200: { + description: "OK", + content: { + [APPLICATION_JSON]: { + schema: Outer, + }, + }, + }, + }, + } as OperationObject, + }, + }, + }; + + canonicalizeAnnotatedSchemas(oas); + + // Both schemas should be in components + expect(Object.keys(oas.components?.schemas ?? {})).toContain("Inner"); + expect(Object.keys(oas.components?.schemas ?? {})).toContain("Outer"); + + // The array's items should be a $ref, not the inline schema + const outerSchema = oas.components?.schemas?.Outer as SchemaObject; + const itemsProperty = outerSchema.properties?.items as SchemaObject; + expect(itemsProperty.type).toBe("array"); + expect(itemsProperty.items).toEqual({ $ref: "#/components/schemas/Inner" }); + }); + test("properly canonicalizes schema with multiple uses", () => { const oas: OpenAPIObject = { ...baseOas,