From 6fbb4e2a2cddae5178db246ceab699efb3675352 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:37:28 +0100 Subject: [PATCH 01/12] feat(api-gen): generate php dtos from the schema --- packages/api-gen/README.md | 54 ++ packages/api-gen/src/cli.ts | 33 + packages/api-gen/src/commands/phpDto.ts | 117 ++++ packages/api-gen/src/index.ts | 1 + packages/api-gen/src/php-dto/generator.ts | 115 ++++ packages/api-gen/src/php-dto/openApiTypes.ts | 58 ++ packages/api-gen/src/php-dto/schemaParser.ts | 382 +++++++++++ packages/api-gen/src/php-dto/typeMapper.ts | 130 ++++ .../__snapshots__/snapshots.test.ts.snap | 622 ++++++++++++++++++ .../php-dto/fixtures/createReadEntity.json | 126 ++++ .../tests/php-dto/fixtures/nestedObjects.json | 171 +++++ .../tests/php-dto/fixtures/simpleSchema.json | 244 +++++++ .../api-gen/tests/php-dto/generator.test.ts | 524 +++++++++++++++ packages/api-gen/tests/php-dto/phpDto.test.ts | 200 ++++++ .../tests/php-dto/schemaParser.test.ts | 380 +++++++++++ .../api-gen/tests/php-dto/snapshots.test.ts | 48 ++ .../api-gen/tests/php-dto/typeMapper.test.ts | 264 ++++++++ 17 files changed, 3469 insertions(+) create mode 100644 packages/api-gen/src/commands/phpDto.ts create mode 100644 packages/api-gen/src/php-dto/generator.ts create mode 100644 packages/api-gen/src/php-dto/openApiTypes.ts create mode 100644 packages/api-gen/src/php-dto/schemaParser.ts create mode 100644 packages/api-gen/src/php-dto/typeMapper.ts create mode 100644 packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap create mode 100644 packages/api-gen/tests/php-dto/fixtures/createReadEntity.json create mode 100644 packages/api-gen/tests/php-dto/fixtures/nestedObjects.json create mode 100644 packages/api-gen/tests/php-dto/fixtures/simpleSchema.json create mode 100644 packages/api-gen/tests/php-dto/generator.test.ts create mode 100644 packages/api-gen/tests/php-dto/phpDto.test.ts create mode 100644 packages/api-gen/tests/php-dto/schemaParser.test.ts create mode 100644 packages/api-gen/tests/php-dto/snapshots.test.ts create mode 100644 packages/api-gen/tests/php-dto/typeMapper.test.ts diff --git a/packages/api-gen/README.md b/packages/api-gen/README.md index 98e56940e..37d50a7d9 100644 --- a/packages/api-gen/README.md +++ b/packages/api-gen/README.md @@ -302,6 +302,38 @@ Prepare your config file named **api-gen.config.json**: > [!NOTE] > The `rules` configuration is API-type specific. When running `validateJson --apiType=store`, only the rules defined in `store-api.rules` will be applied. +### `phpDto` + +Generate PHP DTO classes from an OpenAPI JSON schema. Each component schema and each request/response body produces a separate PHP class file with typed properties, validation attributes, and PHPDoc annotations. + +Two actions are available: + +- **`generate`** — cleans the output directory and writes all PHP DTO files from scratch. +- **`check`** — compares the output directory against what would be generated and exits with code 1 if anything is missing, extra, or different. Useful in CI to ensure generated files are committed and up to date. + +```bash +# Generate PHP DTOs from a Store API schema +pnpx @shopware/api-gen phpDto generate --schemaFile ./api-types/storeApiSchema.json --outputDir ./dto + +# Generate with a PHP namespace +pnpx @shopware/api-gen phpDto generate \ + --schemaFile ./api-types/storeApiSchema.json \ + --outputDir ./dto \ + --namespace "App\\DTO" + +# Check that generated files are up to date (CI usage) +pnpx @shopware/api-gen phpDto check \ + --schemaFile ./api-types/storeApiSchema.json \ + --outputDir ./dto \ + --namespace "App\\DTO" +``` + +flags: + +- `--schemaFile` / `-f` (required) — path to the OpenAPI JSON schema file +- `--outputDir` / `-o` (default: `./dto`) — output directory for generated PHP files +- `--namespace` / `-n` (optional) — PHP namespace added to every generated class + ### `split` - Experimental Split an OpenAPI schema into multiple files, organized by tags or paths. This is useful for breaking down a large schema into smaller, more manageable parts. @@ -373,6 +405,28 @@ await validateJson({ }); ``` +#### `phpDto` + +```ts +import { phpDto } from "@shopware/api-gen"; + +// Generate PHP DTO files +await phpDto({ + action: "generate", + schemaFile: "api-types/storeApiSchema.json", + outputDir: "./dto", + namespace: "App\\DTO", // optional +}); + +// Check that generated files are up to date +await phpDto({ + action: "check", + schemaFile: "api-types/storeApiSchema.json", + outputDir: "./dto", + namespace: "App\\DTO", +}); +``` + #### `split` ```ts diff --git a/packages/api-gen/src/cli.ts b/packages/api-gen/src/cli.ts index e06af31f9..14b34dfa2 100644 --- a/packages/api-gen/src/cli.ts +++ b/packages/api-gen/src/cli.ts @@ -5,6 +5,8 @@ import packageJson from "../package.json"; // import { version } from "../package.json"; import { generate } from "./commands/generate"; import { loadSchema } from "./commands/loadSchema"; +import { phpDto } from "./commands/phpDto"; +import type { PhpDtoOptions } from "./commands/phpDto"; import { split } from "./commands/split"; import type { SplitOptions } from "./commands/split"; import { validateJson } from "./commands/validateJson"; @@ -145,6 +147,37 @@ yargs(hideBin(process.argv)) }, async (args) => split(args as unknown as SplitOptions), ) + .command( + "phpDto ", + "Generate PHP DTO classes from an OpenAPI JSON schema", + (args) => { + return commonOptions(args) + .positional("action", { + type: "string", + choices: ["generate", "check"], + describe: + "'generate' cleans output dir and regenerates files; 'check' verifies existing files match", + }) + .option("schemaFile", { + alias: "f", + type: "string", + demandOption: true, + describe: "path to the OpenAPI JSON schema file", + }) + .option("outputDir", { + alias: "o", + type: "string", + default: "./dto", + describe: "output directory for generated PHP files", + }) + .option("namespace", { + alias: "n", + type: "string", + describe: "PHP namespace for generated classes", + }); + }, + async (args) => phpDto(args as unknown as PhpDtoOptions), + ) .showHelpOnFail(false) .alias("h", "help") .version("version", packageJson.version) diff --git a/packages/api-gen/src/commands/phpDto.ts b/packages/api-gen/src/commands/phpDto.ts new file mode 100644 index 000000000..9c7dfb2fc --- /dev/null +++ b/packages/api-gen/src/commands/phpDto.ts @@ -0,0 +1,117 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; +import pc from "picocolors"; +import { generateAllFiles } from "../php-dto/generator"; +import type { GeneratorOptions } from "../php-dto/generator"; +import { parseAllDtos } from "../php-dto/schemaParser"; +import { loadLocalJSONFile } from "../utils"; + +export interface PhpDtoOptions { + action: "generate" | "check"; + schemaFile: string; + outputDir: string; + namespace?: string; + cwd?: string; +} + +export async function phpDto(options: PhpDtoOptions): Promise { + const { action, schemaFile, outputDir, namespace } = options; + const cwd = options.cwd || process.cwd(); + const outputPath = resolve(cwd, outputDir); + + const schemaPath = resolve(cwd, schemaFile); + const schema = await loadLocalJSONFile>(schemaPath); + if (!schema) { + throw new Error(`Schema file not found: ${schemaPath}`); + } + const dtos = parseAllDtos(schema); + + if (dtos.length === 0) { + console.log(pc.yellow("No DTO definitions found in the schema.")); + return; + } + + const generatorOptions: GeneratorOptions = { namespace }; + const files = generateAllFiles(dtos, generatorOptions); + + if (action === "generate") { + await runGenerate(outputPath, files); + } else if (action === "check") { + await runCheck(outputPath, files); + } +} + +async function runGenerate( + outputPath: string, + files: { fileName: string; content: string }[], +): Promise { + if (existsSync(outputPath)) { + rmSync(outputPath, { recursive: true, force: true }); + } + mkdirSync(outputPath, { recursive: true }); + + for (const file of files) { + const filePath = resolve(outputPath, file.fileName); + writeFileSync(filePath, file.content, "utf-8"); + } + + console.log( + pc.green(`Generated ${files.length} PHP DTO files in ${outputPath}`), + ); +} + +async function runCheck( + outputPath: string, + files: { fileName: string; content: string }[], +): Promise { + const errors: string[] = []; + + if (!existsSync(outputPath)) { + console.error(pc.red(`Output directory does not exist: ${outputPath}`)); + process.exit(1); + } + + const existingFiles = new Set( + readdirSync(outputPath).filter((f) => f.endsWith(".php")), + ); + const expectedFiles = new Set(files.map((f) => f.fileName)); + + for (const fileName of expectedFiles) { + if (!existingFiles.has(fileName)) { + errors.push(`Missing file: ${fileName}`); + } + } + + for (const fileName of existingFiles) { + if (!expectedFiles.has(fileName)) { + errors.push(`Unexpected file: ${fileName}`); + } + } + + for (const file of files) { + const filePath = resolve(outputPath, file.fileName); + if (!existsSync(filePath)) continue; + + const existingContent = readFileSync(filePath, "utf-8"); + if (existingContent !== file.content) { + errors.push(`Content mismatch: ${file.fileName}`); + } + } + + if (errors.length > 0) { + console.error(pc.red("PHP DTO check failed:\n")); + for (const error of errors) { + console.error(pc.red(` - ${error}`)); + } + process.exit(1); + } + + console.log(pc.green(`All ${files.length} PHP DTO files are up to date.`)); +} diff --git a/packages/api-gen/src/index.ts b/packages/api-gen/src/index.ts index d70ff0c6d..4f5f6fe5a 100644 --- a/packages/api-gen/src/index.ts +++ b/packages/api-gen/src/index.ts @@ -1,3 +1,4 @@ export { generate } from "./commands/generate"; export { loadSchema } from "./commands/loadSchema"; +export { phpDto } from "./commands/phpDto"; export { validateJson } from "./commands/validateJson"; diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts new file mode 100644 index 000000000..c4ceec424 --- /dev/null +++ b/packages/api-gen/src/php-dto/generator.ts @@ -0,0 +1,115 @@ +import type { DtoDefinition, DtoProperty } from "./schemaParser"; + +export interface GeneratorOptions { + namespace?: string; +} + +function escapePhpDocComment(text: string): string { + return text.replace(/\*\//g, "* /"); +} + +function escapePhpSingleQuoted(text: string): string { + return text.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} + +function renderPropertyBlock(prop: DtoProperty): string { + const lines: string[] = []; + const hasTypedArray = prop.isArray && prop.arrayItemType; + + if (hasTypedArray) { + lines.push(" /**"); + lines.push( + ` * @var list<${prop.arrayItemType}>${prop.description ? ` ${escapePhpDocComment(prop.description)}` : ""}`, + ); + lines.push(" */"); + } else if (prop.description) { + lines.push(` /** ${escapePhpDocComment(prop.description)} */`); + } + + if (prop.required && !prop.nullable) { + lines.push(" #[Assert\\NotNull]"); + } + + if (prop.pattern) { + lines.push( + ` #[Assert\\Regex(pattern: '/${escapePhpSingleQuoted(prop.pattern)}/')]`, + ); + } + + if (prop.enum && prop.enum.length > 0) { + const choices = prop.enum + .map((v) => `'${escapePhpSingleQuoted(v)}'`) + .join(", "); + lines.push(` #[Assert\\Choice(choices: [${choices}])]`); + } + + const typePrefix = prop.nullable ? "?" : ""; + const defaultSuffix = prop.nullable ? " = null" : ""; + lines.push( + ` public ${typePrefix}${prop.phpType} $${prop.name}${defaultSuffix};`, + ); + + return lines.join("\n"); +} + +export function generatePhpClass( + dto: DtoDefinition, + options: GeneratorOptions = {}, +): string { + const lines: string[] = []; + const needsAssert = dto.properties.some( + (p) => + p.pattern || (p.enum && p.enum.length > 0) || (p.required && !p.nullable), + ); + + lines.push(" ({ + fileName: dtoToFileName(dto.name), + content: generatePhpClass(dto, options), + })); +} diff --git a/packages/api-gen/src/php-dto/openApiTypes.ts b/packages/api-gen/src/php-dto/openApiTypes.ts new file mode 100644 index 000000000..2e8a3de21 --- /dev/null +++ b/packages/api-gen/src/php-dto/openApiTypes.ts @@ -0,0 +1,58 @@ +export interface SchemaObject { + type?: string | string[]; + $ref?: string; + items?: SchemaObject; + oneOf?: SchemaObject[]; + anyOf?: SchemaObject[]; + allOf?: SchemaObject[]; + properties?: Record; + required?: string[]; + description?: string; + pattern?: string; + format?: string; + enum?: unknown[]; + additionalProperties?: boolean | SchemaObject; +} + +export interface ParameterObject { + name: string; + in: string; + description?: string; + required?: boolean; + schema?: SchemaObject; +} + +export interface OperationObject { + operationId?: string; + summary?: string; + description?: string; + parameters?: ParameterObject[]; + requestBody?: { + required?: boolean; + content?: Record< + string, + { + schema?: SchemaObject; + } + >; + }; + responses?: Record< + string, + { + description?: string; + content?: Record< + string, + { + schema?: SchemaObject; + } + >; + } + >; +} + +export interface OpenApiSchema { + components?: { + schemas?: Record; + }; + paths?: Record>; +} diff --git a/packages/api-gen/src/php-dto/schemaParser.ts b/packages/api-gen/src/php-dto/schemaParser.ts new file mode 100644 index 000000000..4d2aaead7 --- /dev/null +++ b/packages/api-gen/src/php-dto/schemaParser.ts @@ -0,0 +1,382 @@ +import type { + OpenApiSchema, + OperationObject, + SchemaObject, +} from "./openApiTypes"; +import { + type PhpTypeResult, + getSchemaType, + hasTypeNull, + mapOpenApiTypeToPhp, + resolveRefName, + toDtoClassName, +} from "./typeMapper"; + +export interface DtoProperty { + name: string; + phpType: string; + nullable: boolean; + required: boolean; + description?: string; + pattern?: string; + enum?: string[]; + isArray: boolean; + arrayItemType?: string; +} + +export interface DtoDefinition { + name: string; + description?: string; + properties: DtoProperty[]; +} + +type SchemaRegistry = Record; + +const HTTP_METHODS = [ + "get", + "post", + "put", + "patch", + "delete", + "options", + "head", + "trace", +]; + +function capitalizeFirst(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +function dereferenceSchema( + schema: SchemaObject, + registry: SchemaRegistry, +): SchemaObject { + if (schema.$ref) { + const refName = resolveRefName(schema.$ref); + const resolved = registry[refName]; + if (resolved) return resolved; + } + return schema; +} + +function isInlineObject(schema: SchemaObject): boolean { + return ( + getSchemaType(schema) === "object" && + schema.properties !== undefined && + Object.keys(schema.properties).length > 0 && + !schema.$ref + ); +} + +function stripDtoSuffix(name: string): string { + return name.endsWith("DTO") ? name.slice(0, -3) : name; +} + +function buildNestedDtoName(parentDtoName: string, propName: string): string { + return `${stripDtoSuffix(parentDtoName)}${capitalizeFirst(propName)}DTO`; +} + +interface ExtractResult { + properties: DtoProperty[]; + nestedDtos: DtoDefinition[]; +} + +function extractPropertiesFromSchema( + schema: SchemaObject, + requiredFields: string[], + parentDtoName: string, + registry: SchemaRegistry, +): ExtractResult { + const properties: DtoProperty[] = []; + const nestedDtos: DtoDefinition[] = []; + + if (!schema.properties) return { properties, nestedDtos }; + + for (const [propName, propSchema] of Object.entries(schema.properties)) { + const isRequired = requiredFields.includes(propName); + + const enumValues = propSchema.enum?.every((v) => typeof v === "string") + ? (propSchema.enum as string[]) + : undefined; + + if (isInlineObject(propSchema)) { + const nestedName = buildNestedDtoName(parentDtoName, propName); + const nestedResolved = resolveSchemaProperties(propSchema, registry); + const nested = extractPropertiesFromSchema( + { properties: nestedResolved.properties }, + nestedResolved.required, + nestedName, + registry, + ); + + nestedDtos.push({ + name: nestedName, + description: propSchema.description, + properties: nested.properties, + }); + nestedDtos.push(...nested.nestedDtos); + + properties.push({ + name: propName, + phpType: nestedName, + nullable: hasTypeNull(propSchema), + required: isRequired, + description: propSchema.description, + pattern: propSchema.pattern, + enum: enumValues, + isArray: false, + arrayItemType: undefined, + }); + continue; + } + + if ( + getSchemaType(propSchema) === "array" && + propSchema.items && + isInlineObject(propSchema.items) + ) { + const nestedName = buildNestedDtoName(parentDtoName, propName); + const nestedResolved = resolveSchemaProperties( + propSchema.items, + registry, + ); + const nested = extractPropertiesFromSchema( + { properties: nestedResolved.properties }, + nestedResolved.required, + nestedName, + registry, + ); + + nestedDtos.push({ + name: nestedName, + description: propSchema.items.description, + properties: nested.properties, + }); + nestedDtos.push(...nested.nestedDtos); + + properties.push({ + name: propName, + phpType: "array", + nullable: hasTypeNull(propSchema), + required: isRequired, + description: propSchema.description, + pattern: propSchema.pattern, + enum: enumValues, + isArray: true, + arrayItemType: nestedName, + }); + continue; + } + + const typeResult: PhpTypeResult = mapOpenApiTypeToPhp(propSchema); + + properties.push({ + name: propName, + phpType: typeResult.phpType, + nullable: typeResult.nullable, + required: isRequired, + description: propSchema.description, + pattern: propSchema.pattern, + enum: enumValues, + isArray: typeResult.isArray, + arrayItemType: typeResult.arrayItemType, + }); + } + + return { properties, nestedDtos }; +} + +function resolveSchemaProperties( + schema: SchemaObject, + registry: SchemaRegistry, +): { + properties: Record; + required: string[]; +} { + const deref = dereferenceSchema(schema, registry); + + if (deref.allOf) { + let mergedProperties: Record = {}; + let mergedRequired: string[] = []; + for (const sub of deref.allOf) { + const resolved = resolveSchemaProperties(sub, registry); + mergedProperties = { ...mergedProperties, ...resolved.properties }; + mergedRequired = [...mergedRequired, ...resolved.required]; + } + return { properties: mergedProperties, required: mergedRequired }; + } + + return { + properties: deref.properties || {}, + required: deref.required || [], + }; +} + +function extractDtoFromSchema( + name: string, + schema: SchemaObject, + registry: SchemaRegistry, + description?: string, +): DtoDefinition[] { + const resolved = resolveSchemaProperties(schema, registry); + + if (Object.keys(resolved.properties).length === 0) { + return []; + } + + const { properties, nestedDtos } = extractPropertiesFromSchema( + { properties: resolved.properties }, + resolved.required, + name, + registry, + ); + + return [ + { + name, + description: description || schema.description, + properties, + }, + ...nestedDtos, + ]; +} + +export function parseComponentSchemas(schema: OpenApiSchema): DtoDefinition[] { + const dtos: DtoDefinition[] = []; + const components = schema.components?.schemas; + const registry: SchemaRegistry = components || {}; + + if (!components) return dtos; + + for (const [schemaName, schemaObj] of Object.entries(components)) { + const extracted = extractDtoFromSchema( + toDtoClassName(schemaName), + schemaObj, + registry, + ); + dtos.push(...extracted); + } + + return dtos; +} + +export function parseRequestBodies(schema: OpenApiSchema): DtoDefinition[] { + const dtos: DtoDefinition[] = []; + const paths = schema.paths; + const registry: SchemaRegistry = schema.components?.schemas || {}; + + if (!paths) return dtos; + + for (const pathMethods of Object.values(paths)) { + for (const method of HTTP_METHODS) { + const operation = pathMethods[method] as OperationObject | undefined; + if (!operation?.operationId) continue; + + const dtoName = `${capitalizeFirst(operation.operationId)}RequestDTO`; + + const rawRequestSchema = + operation.requestBody?.content?.["application/json"]?.schema; + const requestSchema = rawRequestSchema + ? dereferenceSchema(rawRequestSchema, registry) + : undefined; + + const paramProperties: DtoProperty[] = []; + if (operation.parameters) { + for (const param of operation.parameters) { + if (param.in === "header") continue; + + if (param.schema) { + const typeResult = mapOpenApiTypeToPhp(param.schema); + paramProperties.push({ + name: param.name, + phpType: typeResult.phpType, + nullable: typeResult.nullable, + required: param.required === true, + description: param.description, + pattern: param.schema.pattern, + isArray: typeResult.isArray, + arrayItemType: typeResult.arrayItemType, + }); + } + } + } + + if (!requestSchema && paramProperties.length === 0) continue; + + let bodyProperties: DtoProperty[] = []; + let bodyNestedDtos: DtoDefinition[] = []; + let bodyDescription: string | undefined; + + if (requestSchema) { + const resolved = resolveSchemaProperties(requestSchema, registry); + const extracted = extractPropertiesFromSchema( + { properties: resolved.properties }, + resolved.required, + dtoName, + registry, + ); + bodyProperties = extracted.properties; + bodyNestedDtos = extracted.nestedDtos; + bodyDescription = requestSchema.description; + } + + const allProperties = [...bodyProperties, ...paramProperties]; + if (allProperties.length === 0) continue; + + dtos.push({ + name: dtoName, + description: bodyDescription || operation.description, + properties: allProperties, + }); + dtos.push(...bodyNestedDtos); + } + } + + return dtos; +} + +export function parseResponseBodies(schema: OpenApiSchema): DtoDefinition[] { + const dtos: DtoDefinition[] = []; + const paths = schema.paths; + const registry: SchemaRegistry = schema.components?.schemas || {}; + + if (!paths) return dtos; + + for (const pathMethods of Object.values(paths)) { + for (const method of HTTP_METHODS) { + const operation = pathMethods[method] as OperationObject | undefined; + if (!operation?.operationId) continue; + + const responses = operation.responses; + if (!responses) continue; + + const successResponse = responses["200"] || responses["201"]; + if (!successResponse?.content) continue; + + const responseSchema = + successResponse.content["application/json"]?.schema; + if (!responseSchema) continue; + + if (responseSchema.$ref) continue; + + const extracted = extractDtoFromSchema( + `${capitalizeFirst(operation.operationId)}ResponseDTO`, + responseSchema, + registry, + successResponse.description, + ); + + dtos.push(...extracted); + } + } + + return dtos; +} + +export function parseAllDtos(schema: OpenApiSchema): DtoDefinition[] { + const components = parseComponentSchemas(schema); + const requestBodies = parseRequestBodies(schema); + const responseBodies = parseResponseBodies(schema); + + return [...components, ...requestBodies, ...responseBodies]; +} diff --git a/packages/api-gen/src/php-dto/typeMapper.ts b/packages/api-gen/src/php-dto/typeMapper.ts new file mode 100644 index 000000000..2e26dd77c --- /dev/null +++ b/packages/api-gen/src/php-dto/typeMapper.ts @@ -0,0 +1,130 @@ +import type { SchemaObject } from "./openApiTypes"; + +export type { SchemaObject }; + +export interface PhpTypeResult { + phpType: string; + isArray: boolean; + arrayItemType?: string; + nullable: boolean; +} + +export function hasTypeNull(schema: SchemaObject): boolean { + return Array.isArray(schema.type) && schema.type.includes("null"); +} + +export function getSchemaType(schema: SchemaObject): string | undefined { + if (typeof schema.type === "string") return schema.type; + if (Array.isArray(schema.type)) { + const nonNull = schema.type.filter((t) => t !== "null"); + return nonNull.length === 1 ? nonNull[0] : undefined; + } + return undefined; +} + +const PRIMITIVE_TYPE_MAP: Record = { + string: "string", + integer: "int", + number: "float", + boolean: "bool", +}; + +export function resolveRefName(ref: string): string { + const parts = ref.split("/"); + return parts.at(-1) ?? ref; +} + +export function toDtoClassName(schemaName: string): string { + return `${schemaName}DTO`; +} + +export function mapOpenApiTypeToPhp(schema: SchemaObject): PhpTypeResult { + if (schema.$ref) { + const refName = resolveRefName(schema.$ref); + return { + phpType: toDtoClassName(refName), + isArray: false, + nullable: false, + }; + } + + if (schema.oneOf || schema.anyOf) { + const variants = schema.oneOf ?? schema.anyOf ?? []; + const nonNullVariants = variants.filter( + (v) => + v.type !== "null" && + !(Array.isArray(v.type) && v.type.includes("null")), + ); + const hasNull = variants.length > nonNullVariants.length; + + if (nonNullVariants.length === 1 && nonNullVariants[0]) { + const result = mapOpenApiTypeToPhp(nonNullVariants[0]); + return { ...result, nullable: hasNull || result.nullable }; + } + + return { phpType: "mixed", isArray: false, nullable: false }; + } + + if (schema.allOf) { + const first = schema.allOf[0]; + if (schema.allOf.length === 1 && first) { + return mapOpenApiTypeToPhp(first); + } + const refVariant = schema.allOf.find((s) => s.$ref); + if (refVariant) { + return mapOpenApiTypeToPhp(refVariant); + } + return { phpType: "mixed", isArray: false, nullable: false }; + } + + const nullable = hasTypeNull(schema); + const typeValue = getSchemaType(schema); + + if (!typeValue) { + if ( + Array.isArray(schema.type) && + schema.type.filter((t) => t !== "null").length > 1 + ) { + return { phpType: "mixed", isArray: false, nullable }; + } + return { phpType: "mixed", isArray: false, nullable: false }; + } + + if (typeValue === "array") { + if (schema.items) { + if (schema.items.$ref) { + const refName = resolveRefName(schema.items.$ref); + return { + phpType: "array", + isArray: true, + arrayItemType: toDtoClassName(refName), + nullable, + }; + } + const itemType = mapOpenApiTypeToPhp(schema.items); + if (itemType.phpType !== "mixed" && itemType.phpType !== "array") { + return { + phpType: "array", + isArray: true, + arrayItemType: itemType.phpType, + nullable, + }; + } + } + return { phpType: "array", isArray: true, nullable }; + } + + if (typeValue === "object") { + return { phpType: "array", isArray: false, nullable }; + } + + if (PRIMITIVE_TYPE_MAP[typeValue]) { + return { + phpType: PRIMITIVE_TYPE_MAP[typeValue], + isArray: false, + nullable, + }; + } + + return { phpType: "mixed", isArray: false, nullable: false }; +} diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap new file mode 100644 index 000000000..099ebbdac --- /dev/null +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -0,0 +1,622 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CartCreateDTO.php 1`] = ` +" Initial line items to add to the cart + */ + public array $lineItems; +} +" +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CartDTO.php 1`] = ` +" Initial line items to add to the cart + */ + public array $lineItems; + + /** Unique identifier of the cart */ + #[Assert\\NotNull] + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $id; + + /** Date and time the cart was created */ + #[Assert\\NotNull] + public string $createdAt; + + /** Date and time the cart was last modified */ + public string $updatedAt; +} +" +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CreateCartRequestDTO.php 1`] = ` +" Initial line items to add to the cart + */ + public array $lineItems; +} +" +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for LineItemDTO.php 1`] = ` +" createReadEntity.json > generates correct content with namespace 1`] = ` +" Initial line items to add to the cart + */ + public array $lineItems; +} +" +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates expected file names 1`] = ` +[ + "CartCreateDTO.php", + "CartDTO.php", + "CreateCartRequestDTO.php", + "LineItemDTO.php", +] +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `4`; + +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for OrderBillingAddressCountryDTO.php 1`] = ` +" nestedObjects.json > generates correct content for OrderBillingAddressDTO.php 1`] = ` +" nestedObjects.json > generates correct content for OrderDTO.php 1`] = ` +" nestedObjects.json > generates correct content for SalesChannelContextContextDTO.php 1`] = ` +" + */ + public array $languageIdChain; + + public string $scope; + + public SalesChannelContextContextSourceDTO $source; + + public string $taxState; + + public bool $useCache; +} +" +`; + +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for SalesChannelContextContextSourceDTO.php 1`] = ` +" nestedObjects.json > generates correct content for SalesChannelContextCurrentCustomerGroupDTO.php 1`] = ` +" nestedObjects.json > generates correct content for SalesChannelContextDTO.php 1`] = ` +" Active tax rules + */ + public array $taxRules; +} +" +`; + +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for SalesChannelContextItemRoundingDTO.php 1`] = ` +" nestedObjects.json > generates correct content for SalesChannelContextTaxRulesDTO.php 1`] = ` +" nestedObjects.json > generates correct content for SalesChannelDTO.php 1`] = ` +" nestedObjects.json > generates correct content with namespace 1`] = ` +" Active tax rules + */ + public array $taxRules; +} +" +`; + +exports[`php-dto snapshot tests > nestedObjects.json > generates expected file names 1`] = ` +[ + "OrderBillingAddressCountryDTO.php", + "OrderBillingAddressDTO.php", + "OrderDTO.php", + "SalesChannelContextContextDTO.php", + "SalesChannelContextContextSourceDTO.php", + "SalesChannelContextCurrentCustomerGroupDTO.php", + "SalesChannelContextDTO.php", + "SalesChannelContextItemRoundingDTO.php", + "SalesChannelContextTaxRulesDTO.php", + "SalesChannelDTO.php", +] +`; + +exports[`php-dto snapshot tests > nestedObjects.json > generates expected number of files 1`] = `10`; + +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for CalculatedPriceDTO.php 1`] = ` +" simpleSchema.json > generates correct content for CartDTO.php 1`] = ` +" All items within the cart + */ + public array $lineItems; + + public int $totalItems; + + public bool $active; + + public float $taxRate; +} +" +`; + +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for LineItemDTO.php 1`] = ` +" simpleSchema.json > generates correct content for NavigationTypeDTO.php 1`] = ` +" simpleSchema.json > generates correct content for NullableUnionDTO.php 1`] = ` +" simpleSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` +" simpleSchema.json > generates correct content for ReadProductResponseDTO.php 1`] = ` +" simpleSchema.json > generates correct content for SendContactMailRequestDTO.php 1`] = ` +" simpleSchema.json > generates correct content with namespace 1`] = ` +" All items within the cart + */ + public array $lineItems; + + public int $totalItems; + + public bool $active; + + public float $taxRate; +} +" +`; + +exports[`php-dto snapshot tests > simpleSchema.json > generates expected file names 1`] = ` +[ + "CalculatedPriceDTO.php", + "CartDTO.php", + "LineItemDTO.php", + "NavigationTypeDTO.php", + "NullableUnionDTO.php", + "ReadProductRequestDTO.php", + "ReadProductResponseDTO.php", + "SendContactMailRequestDTO.php", +] +`; + +exports[`php-dto snapshot tests > simpleSchema.json > generates expected number of files 1`] = `8`; diff --git a/packages/api-gen/tests/php-dto/fixtures/createReadEntity.json b/packages/api-gen/tests/php-dto/fixtures/createReadEntity.json new file mode 100644 index 000000000..b4ffeb052 --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/createReadEntity.json @@ -0,0 +1,126 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Create/Read Entity API", + "version": "1.0.0" + }, + "components": { + "schemas": { + "CartCreate": { + "type": "object", + "description": "Payload for creating a new cart", + "required": ["name"], + "properties": { + "name": { + "description": "Name of the cart, e.g. guest-cart", + "type": "string" + }, + "lineItems": { + "description": "Initial line items to add to the cart", + "type": "array", + "items": { + "$ref": "#/components/schemas/LineItem" + } + } + } + }, + "Cart": { + "description": "Full cart entity as returned by the API", + "allOf": [ + { + "$ref": "#/components/schemas/CartCreate" + }, + { + "type": "object", + "required": ["id", "createdAt"], + "properties": { + "id": { + "description": "Unique identifier of the cart", + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "createdAt": { + "description": "Date and time the cart was created", + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "description": "Date and time the cart was last modified", + "type": "string", + "format": "date-time" + } + } + } + ] + }, + "LineItem": { + "type": "object", + "description": "A single item in the cart", + "required": ["id", "quantity"], + "properties": { + "id": { + "description": "Product identifier", + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "quantity": { + "description": "Number of items", + "type": "integer" + }, + "label": { + "description": "Display label", + "type": "string" + } + } + } + } + }, + "paths": { + "/cart": { + "post": { + "operationId": "createCart", + "summary": "Create a new cart", + "description": "Creates a new cart with the given payload.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CartCreate" + } + } + } + }, + "responses": { + "200": { + "description": "The newly created cart", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Cart" + } + } + } + } + } + }, + "get": { + "operationId": "readCart", + "summary": "Get the current cart", + "description": "Returns the full cart entity.", + "responses": { + "200": { + "description": "Current cart", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Cart" + } + } + } + } + } + } + } + } +} diff --git a/packages/api-gen/tests/php-dto/fixtures/nestedObjects.json b/packages/api-gen/tests/php-dto/fixtures/nestedObjects.json new file mode 100644 index 000000000..a021a8ec6 --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/nestedObjects.json @@ -0,0 +1,171 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Nested Objects API", + "version": "1.0.0" + }, + "components": { + "schemas": { + "SalesChannelContext": { + "type": "object", + "description": "Sales channel context", + "required": ["salesChannel", "itemRounding"], + "properties": { + "token": { + "description": "Context token", + "type": "string" + }, + "salesChannel": { + "$ref": "#/components/schemas/SalesChannel" + }, + "context": { + "description": "Core context with general configuration values and state", + "type": "object", + "properties": { + "versionId": { + "type": "string" + }, + "currencyId": { + "type": "string" + }, + "currencyFactor": { + "type": "integer" + }, + "currencyPrecision": { + "type": "integer", + "format": "int32" + }, + "languageIdChain": { + "type": "array", + "items": { + "type": "string" + } + }, + "scope": { + "type": "string" + }, + "source": { + "type": "object", + "required": ["salesChannelId", "type"], + "properties": { + "type": { + "type": "string", + "enum": ["sales-channel", "shop-api"] + }, + "salesChannelId": { + "type": "string" + } + } + }, + "taxState": { + "type": "string" + }, + "useCache": { + "type": "boolean" + } + } + }, + "currentCustomerGroup": { + "description": "Customer group of the current user", + "type": "object", + "properties": { + "name": { + "description": "Name of the group", + "type": "string" + }, + "displayGross": { + "description": "Whether prices are displayed gross", + "type": "boolean" + } + } + }, + "itemRounding": { + "type": "object", + "required": ["decimals", "interval", "roundForNet"], + "properties": { + "decimals": { + "type": "integer" + }, + "interval": { + "type": "number" + }, + "roundForNet": { + "type": "boolean" + } + } + }, + "taxRules": { + "description": "Active tax rules", + "type": "array", + "items": { + "type": "object", + "required": ["taxRate"], + "properties": { + "taxRate": { + "type": "number" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "SalesChannel": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "name": { + "type": "string" + } + } + }, + "Order": { + "type": "object", + "description": "An order entity", + "required": ["id", "billingAddress"], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "billingAddress": { + "description": "The billing address", + "type": "object", + "required": ["street", "city", "zipcode"], + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "zipcode": { + "type": "string" + }, + "country": { + "description": "Country details", + "type": "object", + "properties": { + "iso": { + "description": "ISO 3166-1 alpha-2 code", + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "paths": {} +} diff --git a/packages/api-gen/tests/php-dto/fixtures/simpleSchema.json b/packages/api-gen/tests/php-dto/fixtures/simpleSchema.json new file mode 100644 index 000000000..25112f205 --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/simpleSchema.json @@ -0,0 +1,244 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "components": { + "schemas": { + "Cart": { + "type": "object", + "description": "Shopping cart", + "required": ["token"], + "properties": { + "token": { + "description": "Context token identifying the cart", + "type": "string" + }, + "name": { + "description": "Name of the cart", + "type": "string" + }, + "price": { + "$ref": "#/components/schemas/CalculatedPrice" + }, + "lineItems": { + "description": "All items within the cart", + "type": "array", + "items": { + "$ref": "#/components/schemas/LineItem" + } + }, + "totalItems": { + "type": "integer" + }, + "active": { + "type": "boolean" + }, + "taxRate": { + "type": "number" + } + } + }, + "CalculatedPrice": { + "type": "object", + "properties": { + "unitPrice": { + "type": "number" + }, + "totalPrice": { + "type": "number" + } + } + }, + "LineItem": { + "type": "object", + "required": ["id", "quantity"], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "quantity": { + "type": "integer" + }, + "label": { + "type": "string" + } + } + }, + "NavigationType": { + "type": "object", + "description": "Navigation entry type", + "required": ["type", "routeName"], + "properties": { + "type": { + "description": "Type of the navigation entry", + "type": "string", + "enum": ["page", "link", "folder"] + }, + "routeName": { + "description": "Route name for the navigation", + "type": "string", + "enum": [ + "frontend.navigation.page", + "frontend.landing.page", + "frontend.detail.page" + ] + }, + "linkType": { + "description": "Type of the link if type is link", + "type": "string", + "enum": ["external", "category", "product", "landing_page"] + } + } + }, + "EmptySchema": { + "type": "string" + }, + "NullableUnion": { + "type": "object", + "properties": { + "value": { + "oneOf": [{ "type": "string" }, { "type": "null" }] + }, + "count": { + "description": "Nullable count using type array", + "type": ["integer", "null"] + } + } + } + } + }, + "paths": { + "/contact-form": { + "post": { + "operationId": "sendContactMail", + "summary": "Submit a contact form message", + "description": "Used for submitting contact forms.", + "parameters": [ + { + "name": "sw-language-id", + "in": "header", + "description": "Language header", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": ["email", "subject", "comment"], + "properties": { + "email": { + "description": "Email address", + "type": "string" + }, + "subject": { + "description": "The subject of the contact form.", + "type": "string" + }, + "comment": { + "description": "The message of the contact form", + "type": "string" + }, + "salutationId": { + "description": "Identifier of the salutation.", + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Message sent successfully." + } + } + } + }, + "/checkout/cart": { + "get": { + "operationId": "readCart", + "summary": "Fetch or create a cart", + "description": "Used to fetch the current cart.", + "responses": { + "200": { + "description": "Cart", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Cart" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteCart", + "summary": "Delete a cart", + "responses": { + "200": { + "description": "Cart deleted" + } + } + } + }, + "/product/{productId}": { + "get": { + "operationId": "readProduct", + "summary": "Get a single product", + "parameters": [ + { + "name": "productId", + "in": "path", + "description": "Product ID", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + } + ], + "responses": { + "200": { + "description": "Product found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "price": { + "type": "number" + } + } + } + } + } + } + } + } + } + } +} diff --git a/packages/api-gen/tests/php-dto/generator.test.ts b/packages/api-gen/tests/php-dto/generator.test.ts new file mode 100644 index 000000000..d48b92407 --- /dev/null +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -0,0 +1,524 @@ +import { describe, expect, it } from "vitest"; +import { + dtoToFileName, + generateAllFiles, + generatePhpClass, +} from "../../src/php-dto/generator"; +import type { DtoDefinition } from "../../src/php-dto/schemaParser"; + +describe("generator", () => { + describe("dtoToFileName", () => { + it("appends .php extension", () => { + expect(dtoToFileName("ProductDTO")).toBe("ProductDTO.php"); + }); + }); + + describe("generatePhpClass", () => { + it("generates a basic class with required and optional properties", () => { + const dto: DtoDefinition = { + name: "ContactFormRequestDTO", + description: "Contact form request", + properties: [ + { + name: "email", + phpType: "string", + nullable: false, + required: true, + description: "Email address", + isArray: false, + }, + { + name: "firstName", + phpType: "string", + nullable: false, + required: false, + isArray: false, + }, + { + name: "nickname", + phpType: "string", + nullable: true, + required: false, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain(" { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto, { namespace: "App\\DTO" }); + + expect(result).toContain("namespace App\\DTO;"); + }); + + it("adds Assert\\NotNull for required non-nullable properties", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + { + name: "label", + phpType: "string", + nullable: false, + required: false, + isArray: false, + }, + { + name: "status", + phpType: "string", + nullable: true, + required: true, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "use Symfony\\Component\\Validator\\Constraints as Assert;", + ); + expect(result).toContain("#[Assert\\NotNull]\n public string $id;"); + expect(result).not.toContain( + "#[Assert\\NotNull]\n public string $label;", + ); + expect(result).not.toContain( + "#[Assert\\NotNull]\n public ?string $status", + ); + expect(result).toContain("public string $label;"); + expect(result).toContain("public ?string $status = null;"); + }); + + it("adds Assert import when patterns exist", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + pattern: "^[0-9a-f]{32}$", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "use Symfony\\Component\\Validator\\Constraints as Assert;", + ); + expect(result).toContain("#[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')]"); + }); + + it("escapes single quotes in regex patterns", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "code", + phpType: "string", + nullable: false, + required: true, + pattern: "^[a-z]+'[a-z]+$", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "#[Assert\\Regex(pattern: '/^[a-z]+\\'[a-z]+$/')]", + ); + }); + + it("escapes backslashes in regex patterns", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "path", + phpType: "string", + nullable: false, + required: true, + pattern: "^\\d{3}-\\d{4}$", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "#[Assert\\Regex(pattern: '/^\\\\d{3}-\\\\d{4}$/')]", + ); + }); + + it("adds Assert\\Choice for enum properties", () => { + const dto: DtoDefinition = { + name: "CategoryDTO", + properties: [ + { + name: "type", + phpType: "string", + nullable: false, + required: true, + description: "Type of the category", + enum: ["page", "link", "folder"], + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "use Symfony\\Component\\Validator\\Constraints as Assert;", + ); + expect(result).toContain( + "#[Assert\\Choice(choices: ['page', 'link', 'folder'])]", + ); + expect(result).toContain("public string $type;"); + }); + + it("adds Assert\\Choice for optional enum properties", () => { + const dto: DtoDefinition = { + name: "ProductDTO", + properties: [ + { + name: "productType", + phpType: "string", + nullable: true, + required: false, + enum: ["physical", "digital"], + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "#[Assert\\Choice(choices: ['physical', 'digital'])]", + ); + expect(result).toContain("public ?string $productType = null;"); + }); + + it("adds Assert import when only enums exist (no patterns)", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "status", + phpType: "string", + nullable: false, + required: true, + enum: ["active", "inactive"], + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "use Symfony\\Component\\Validator\\Constraints as Assert;", + ); + expect(result).not.toContain("Assert\\Regex"); + }); + + it("escapes single quotes in enum values", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "label", + phpType: "string", + nullable: false, + required: true, + enum: ["it's", "won't"], + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "#[Assert\\Choice(choices: ['it\\'s', 'won\\'t'])]", + ); + }); + + it("does not add Assert import when no constraints needed", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "name", + phpType: "string", + nullable: false, + required: false, + isArray: false, + }, + { + name: "label", + phpType: "string", + nullable: true, + required: false, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).not.toContain("use Symfony"); + expect(result).not.toContain("Assert"); + expect(result).toContain("public string $name;"); + expect(result).toContain("public ?string $label = null;"); + }); + + it("generates list PHPDoc for typed arrays with description", () => { + const dto: DtoDefinition = { + name: "CartDTO", + properties: [ + { + name: "lineItems", + phpType: "array", + nullable: false, + required: true, + description: "All items within the cart", + isArray: true, + arrayItemType: "LineItemDTO", + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain("/**"); + expect(result).toContain( + "* @var list All items within the cart", + ); + expect(result).toContain("*/"); + expect(result).toContain("public array $lineItems;"); + }); + + it("generates list PHPDoc for typed arrays without description", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "items", + phpType: "array", + nullable: false, + required: true, + isArray: true, + arrayItemType: "ProductDTO", + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain("/**"); + expect(result).toContain("* @var list"); + expect(result).toContain("*/"); + }); + + it("generates list PHPDoc for nullable typed arrays", () => { + const dto: DtoDefinition = { + name: "OrderDTO", + properties: [ + { + name: "items", + phpType: "array", + nullable: true, + required: false, + description: "Order line items", + isArray: true, + arrayItemType: "LineItemDTO", + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain("/**"); + expect(result).toContain("* @var list Order line items"); + expect(result).toContain("*/"); + expect(result).toContain("public ?array $items = null;"); + }); + + it("generates correct type hint for nested object DTO references", () => { + const dto: DtoDefinition = { + name: "SalesChannelContextDTO", + properties: [ + { + name: "itemRounding", + phpType: "SalesChannelContextItemRoundingDTO", + nullable: false, + required: true, + isArray: false, + }, + { + name: "currentCustomerGroup", + phpType: "SalesChannelContextCurrentCustomerGroupDTO", + nullable: true, + required: false, + description: "Customer group of the current user", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "public SalesChannelContextItemRoundingDTO $itemRounding;", + ); + expect(result).toContain( + "public ?SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup = null;", + ); + expect(result).toContain("/** Customer group of the current user */"); + }); + + it("generates list PHPDoc for arrays of nested object DTOs", () => { + const dto: DtoDefinition = { + name: "SalesChannelContextDTO", + properties: [ + { + name: "taxRules", + phpType: "array", + nullable: true, + required: false, + description: "Active tax rules", + isArray: true, + arrayItemType: "SalesChannelContextTaxRulesDTO", + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "* @var list Active tax rules", + ); + expect(result).toContain("public ?array $taxRules = null;"); + }); + + it("handles multiline description", () => { + const dto: DtoDefinition = { + name: "TestDTO", + description: "Line one\nLine two", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain(" * Line one"); + expect(result).toContain(" * Line two"); + }); + }); + + describe("generateAllFiles", () => { + it("generates one file per DTO", () => { + const dtos: DtoDefinition[] = [ + { + name: "ProductDTO", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + { + name: "CartDTO", + properties: [ + { + name: "token", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + ]; + + const files = generateAllFiles(dtos); + + expect(files).toHaveLength(2); + expect(files[0].fileName).toBe("ProductDTO.php"); + expect(files[1].fileName).toBe("CartDTO.php"); + expect(files[0].content).toContain("class ProductDTO"); + expect(files[1].content).toContain("class CartDTO"); + }); + + it("passes namespace to all generated files", () => { + const dtos: DtoDefinition[] = [ + { + name: "TestDTO", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + ]; + + const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); + + expect(files[0].content).toContain("namespace App\\DTO;"); + }); + }); +}); diff --git a/packages/api-gen/tests/php-dto/phpDto.test.ts b/packages/api-gen/tests/php-dto/phpDto.test.ts new file mode 100644 index 000000000..a9b765ab3 --- /dev/null +++ b/packages/api-gen/tests/php-dto/phpDto.test.ts @@ -0,0 +1,200 @@ +import { + existsSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { phpDto } from "../../src/commands/phpDto"; + +const TEST_OUTPUT_DIR = resolve(__dirname, "test-output-phpDto"); +const FIXTURE_SCHEMA = resolve(__dirname, "fixtures/simpleSchema.json"); + +describe("phpDto command", () => { + beforeAll(() => { + if (existsSync(TEST_OUTPUT_DIR)) { + rmSync(TEST_OUTPUT_DIR, { recursive: true, force: true }); + } + }); + + afterAll(() => { + rmSync(TEST_OUTPUT_DIR, { recursive: true, force: true }); + }); + + it("generate: creates PHP files in output directory", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "generate"); + + await phpDto({ + action: "generate", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }); + + expect(existsSync(outputDir)).toBe(true); + + const files = readdirSync(outputDir).filter((f) => f.endsWith(".php")); + expect(files.length).toBeGreaterThan(0); + expect(files).toContain("CartDTO.php"); + expect(files).toContain("SendContactMailRequestDTO.php"); + + const cartContent = readFileSync( + resolve(outputDir, "CartDTO.php"), + "utf-8", + ); + expect(cartContent).toContain("class CartDTO"); + expect(cartContent).toContain(" { + const outputDir = resolve(TEST_OUTPUT_DIR, "generate-ns"); + + await phpDto({ + action: "generate", + schemaFile: FIXTURE_SCHEMA, + outputDir, + namespace: "App\\DTO", + }); + + const cartContent = readFileSync( + resolve(outputDir, "CartDTO.php"), + "utf-8", + ); + expect(cartContent).toContain("namespace App\\DTO;"); + }); + + it("generate: cleans output directory on re-run", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "generate-clean"); + + await phpDto({ + action: "generate", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }); + + writeFileSync(resolve(outputDir, "stale.php"), " { + const outputDir = resolve(TEST_OUTPUT_DIR, "check-pass"); + + await phpDto({ + action: "generate", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }); + + const spy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + await phpDto({ + action: "check", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it("check: fails when files are missing", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "check-missing"); + + await phpDto({ + action: "generate", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }); + + rmSync(resolve(outputDir, "CartDTO.php")); + + const spy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + await expect( + phpDto({ + action: "check", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }), + ).rejects.toThrow("process.exit called"); + + expect(spy).toHaveBeenCalledWith(1); + spy.mockRestore(); + }); + + it("check: fails when content differs", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "check-diff"); + + await phpDto({ + action: "generate", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }); + + writeFileSync(resolve(outputDir, "CartDTO.php"), " { + throw new Error("process.exit called"); + }); + + await expect( + phpDto({ + action: "check", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }), + ).rejects.toThrow("process.exit called"); + + expect(spy).toHaveBeenCalledWith(1); + spy.mockRestore(); + }); + + it("check: fails when extra files exist", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "check-extra"); + + await phpDto({ + action: "generate", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }); + + writeFileSync(resolve(outputDir, "ExtraDTO.php"), " { + throw new Error("process.exit called"); + }); + + await expect( + phpDto({ + action: "check", + schemaFile: FIXTURE_SCHEMA, + outputDir, + }), + ).rejects.toThrow("process.exit called"); + + expect(spy).toHaveBeenCalledWith(1); + spy.mockRestore(); + }); + + it("throws when schema file does not exist", async () => { + await expect( + phpDto({ + action: "generate", + schemaFile: "/nonexistent/schema.json", + outputDir: TEST_OUTPUT_DIR, + }), + ).rejects.toThrow("Schema file not found"); + }); +}); diff --git a/packages/api-gen/tests/php-dto/schemaParser.test.ts b/packages/api-gen/tests/php-dto/schemaParser.test.ts new file mode 100644 index 000000000..4ae4792c3 --- /dev/null +++ b/packages/api-gen/tests/php-dto/schemaParser.test.ts @@ -0,0 +1,380 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + parseAllDtos, + parseComponentSchemas, + parseRequestBodies, + parseResponseBodies, +} from "../../src/php-dto/schemaParser"; + +const fixtureSchema = JSON.parse( + readFileSync(resolve(__dirname, "fixtures/simpleSchema.json"), "utf-8"), +); + +const nestedSchema = JSON.parse( + readFileSync(resolve(__dirname, "fixtures/nestedObjects.json"), "utf-8"), +); + +describe("schemaParser", () => { + describe("parseComponentSchemas", () => { + it("extracts DTO definitions from components/schemas", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const names = dtos.map((d) => d.name); + + expect(names).toContain("CartDTO"); + expect(names).toContain("CalculatedPriceDTO"); + expect(names).toContain("LineItemDTO"); + expect(names).toContain("NullableUnionDTO"); + }); + + it("skips schemas without object properties", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const names = dtos.map((d) => d.name); + + expect(names).not.toContain("EmptySchemaDTO"); + }); + + it("parses Cart component correctly", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const cart = dtos.find((d) => d.name === "CartDTO"); + + expect(cart).toBeDefined(); + expect(cart?.description).toBe("Shopping cart"); + expect(cart?.properties).toHaveLength(7); + + const token = cart?.properties.find((p) => p.name === "token"); + expect(token).toEqual({ + name: "token", + phpType: "string", + nullable: false, + required: true, + description: "Context token identifying the cart", + pattern: undefined, + isArray: false, + arrayItemType: undefined, + }); + + const name = cart?.properties.find((p) => p.name === "name"); + expect(name?.nullable).toBe(false); + expect(name?.required).toBe(false); + + const price = cart?.properties.find((p) => p.name === "price"); + expect(price?.phpType).toBe("CalculatedPriceDTO"); + + const lineItems = cart?.properties.find((p) => p.name === "lineItems"); + expect(lineItems?.phpType).toBe("array"); + expect(lineItems?.isArray).toBe(true); + expect(lineItems?.arrayItemType).toBe("LineItemDTO"); + }); + + it("parses LineItem with pattern", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const lineItem = dtos.find((d) => d.name === "LineItemDTO"); + const id = lineItem?.properties.find((p) => p.name === "id"); + + expect(id?.pattern).toBe("^[0-9a-f]{32}$"); + expect(id?.required).toBe(true); + }); + + it("parses enum values from properties", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const navType = dtos.find((d) => d.name === "NavigationTypeDTO"); + + expect(navType).toBeDefined(); + + const type = navType?.properties.find((p) => p.name === "type"); + expect(type?.enum).toEqual(["page", "link", "folder"]); + expect(type?.required).toBe(true); + + const routeName = navType?.properties.find((p) => p.name === "routeName"); + expect(routeName?.enum).toEqual([ + "frontend.navigation.page", + "frontend.landing.page", + "frontend.detail.page", + ]); + + const linkType = navType?.properties.find((p) => p.name === "linkType"); + expect(linkType?.enum).toEqual([ + "external", + "category", + "product", + "landing_page", + ]); + expect(linkType?.required).toBe(false); + expect(linkType?.nullable).toBe(false); + }); + + it("distinguishes explicit nullability from optionality", () => { + const dtos = parseComponentSchemas(fixtureSchema); + + const nullableUnion = dtos.find((d) => d.name === "NullableUnionDTO"); + const value = nullableUnion?.properties.find((p) => p.name === "value"); + expect(value?.nullable).toBe(true); + expect(value?.required).toBe(false); + + const count = nullableUnion?.properties.find((p) => p.name === "count"); + expect(count?.nullable).toBe(true); + expect(count?.phpType).toBe("int"); + + const cart = dtos.find((d) => d.name === "CartDTO"); + const name = cart?.properties.find((p) => p.name === "name"); + expect(name?.nullable).toBe(false); + expect(name?.required).toBe(false); + }); + + it("handles empty schema", () => { + const dtos = parseComponentSchemas({ components: { schemas: {} } }); + expect(dtos).toHaveLength(0); + }); + + it("handles missing components", () => { + const dtos = parseComponentSchemas({}); + expect(dtos).toHaveLength(0); + }); + + it("extracts nested inline objects as separate DTOs", () => { + const dtos = parseComponentSchemas(nestedSchema); + const names = dtos.map((d) => d.name); + + expect(names).toContain("SalesChannelContextDTO"); + expect(names).toContain("SalesChannelContextContextDTO"); + expect(names).toContain("SalesChannelContextContextSourceDTO"); + expect(names).toContain("SalesChannelContextCurrentCustomerGroupDTO"); + expect(names).toContain("SalesChannelContextItemRoundingDTO"); + expect(names).toContain("SalesChannelContextTaxRulesDTO"); + }); + + it("references nested DTO type in the parent property", () => { + const dtos = parseComponentSchemas(nestedSchema); + const salesChannelContext = dtos.find( + (d) => d.name === "SalesChannelContextDTO", + ); + expect(salesChannelContext).toBeDefined(); + + const contextProp = salesChannelContext?.properties.find( + (p) => p.name === "context", + ); + expect(contextProp?.phpType).toBe("SalesChannelContextContextDTO"); + expect(contextProp?.isArray).toBe(false); + expect(contextProp?.nullable).toBe(false); + + const customerGroup = salesChannelContext?.properties.find( + (p) => p.name === "currentCustomerGroup", + ); + expect(customerGroup?.phpType).toBe( + "SalesChannelContextCurrentCustomerGroupDTO", + ); + expect(customerGroup?.isArray).toBe(false); + expect(customerGroup?.nullable).toBe(false); + + const itemRounding = salesChannelContext?.properties.find( + (p) => p.name === "itemRounding", + ); + expect(itemRounding?.phpType).toBe("SalesChannelContextItemRoundingDTO"); + expect(itemRounding?.required).toBe(true); + expect(itemRounding?.nullable).toBe(false); + }); + + it("extracts deeply nested inline objects within inline objects", () => { + const dtos = parseComponentSchemas(nestedSchema); + + const contextDto = dtos.find( + (d) => d.name === "SalesChannelContextContextDTO", + ); + expect(contextDto).toBeDefined(); + expect(contextDto?.description).toBe( + "Core context with general configuration values and state", + ); + + const sourceProp = contextDto?.properties.find( + (p) => p.name === "source", + ); + expect(sourceProp?.phpType).toBe("SalesChannelContextContextSourceDTO"); + expect(sourceProp?.isArray).toBe(false); + + const sourceDto = dtos.find( + (d) => d.name === "SalesChannelContextContextSourceDTO", + ); + expect(sourceDto).toBeDefined(); + expect(sourceDto?.properties).toHaveLength(2); + + const typeProp = sourceDto?.properties.find((p) => p.name === "type"); + expect(typeProp?.required).toBe(true); + expect(typeProp?.enum).toEqual(["sales-channel", "shop-api"]); + + const salesChannelId = sourceDto?.properties.find( + (p) => p.name === "salesChannelId", + ); + expect(salesChannelId?.required).toBe(true); + expect(salesChannelId?.phpType).toBe("string"); + }); + + it("extracts properties of nested inline objects correctly", () => { + const dtos = parseComponentSchemas(nestedSchema); + + const rounding = dtos.find( + (d) => d.name === "SalesChannelContextItemRoundingDTO", + ); + expect(rounding).toBeDefined(); + expect(rounding?.properties).toHaveLength(3); + + const decimals = rounding?.properties.find((p) => p.name === "decimals"); + expect(decimals?.phpType).toBe("int"); + expect(decimals?.required).toBe(true); + + const roundForNet = rounding?.properties.find( + (p) => p.name === "roundForNet", + ); + expect(roundForNet?.phpType).toBe("bool"); + expect(roundForNet?.required).toBe(true); + }); + + it("handles arrays of inline objects", () => { + const dtos = parseComponentSchemas(nestedSchema); + const context = dtos.find((d) => d.name === "SalesChannelContextDTO"); + + const taxRules = context?.properties.find((p) => p.name === "taxRules"); + expect(taxRules?.phpType).toBe("array"); + expect(taxRules?.isArray).toBe(true); + expect(taxRules?.arrayItemType).toBe("SalesChannelContextTaxRulesDTO"); + + const taxRuleDto = dtos.find( + (d) => d.name === "SalesChannelContextTaxRulesDTO", + ); + expect(taxRuleDto).toBeDefined(); + expect(taxRuleDto?.properties).toHaveLength(2); + + const taxRate = taxRuleDto?.properties.find((p) => p.name === "taxRate"); + expect(taxRate?.required).toBe(true); + }); + + it("handles deeply nested inline objects", () => { + const dtos = parseComponentSchemas(nestedSchema); + const names = dtos.map((d) => d.name); + + expect(names).toContain("OrderBillingAddressDTO"); + expect(names).toContain("OrderBillingAddressCountryDTO"); + + const order = dtos.find((d) => d.name === "OrderDTO"); + const billing = order?.properties.find( + (p) => p.name === "billingAddress", + ); + expect(billing?.phpType).toBe("OrderBillingAddressDTO"); + + const billingDto = dtos.find((d) => d.name === "OrderBillingAddressDTO"); + const country = billingDto?.properties.find((p) => p.name === "country"); + expect(country?.phpType).toBe("OrderBillingAddressCountryDTO"); + + const countryDto = dtos.find( + (d) => d.name === "OrderBillingAddressCountryDTO", + ); + expect(countryDto).toBeDefined(); + expect(countryDto?.properties).toHaveLength(2); + }); + + it("keeps $ref properties unchanged for non-inline objects", () => { + const dtos = parseComponentSchemas(nestedSchema); + const context = dtos.find((d) => d.name === "SalesChannelContextDTO"); + + const salesChannel = context?.properties.find( + (p) => p.name === "salesChannel", + ); + expect(salesChannel?.phpType).toBe("SalesChannelDTO"); + expect(salesChannel?.isArray).toBe(false); + }); + }); + + describe("parseRequestBodies", () => { + it("extracts request body DTOs from paths", () => { + const dtos = parseRequestBodies(fixtureSchema); + const names = dtos.map((d) => d.name); + + expect(names).toContain("SendContactMailRequestDTO"); + }); + + it("parses sendContactMail request body correctly", () => { + const dtos = parseRequestBodies(fixtureSchema); + const dto = dtos.find((d) => d.name === "SendContactMailRequestDTO"); + + expect(dto).toBeDefined(); + expect(dto?.description).toBe("Used for submitting contact forms."); + expect(dto?.properties.length).toBeGreaterThanOrEqual(6); + + const email = dto?.properties.find((p) => p.name === "email"); + expect(email?.required).toBe(true); + expect(email?.nullable).toBe(false); + + const salutationId = dto?.properties.find( + (p) => p.name === "salutationId", + ); + expect(salutationId?.required).toBe(false); + expect(salutationId?.nullable).toBe(false); + expect(salutationId?.pattern).toBe("^[0-9a-f]{32}$"); + }); + + it("includes path parameters in request DTOs", () => { + const dtos = parseRequestBodies(fixtureSchema); + const dto = dtos.find((d) => d.name === "ReadProductRequestDTO"); + + expect(dto).toBeDefined(); + const productId = dto?.properties.find((p) => p.name === "productId"); + expect(productId).toBeDefined(); + expect(productId?.required).toBe(true); + expect(productId?.pattern).toBe("^[0-9a-f]{32}$"); + }); + + it("skips header parameters", () => { + const dtos = parseRequestBodies(fixtureSchema); + const dto = dtos.find((d) => d.name === "SendContactMailRequestDTO"); + const headerParam = dto?.properties.find( + (p) => p.name === "sw-language-id", + ); + expect(headerParam).toBeUndefined(); + }); + + it("skips operations without request body or parameters", () => { + const dtos = parseRequestBodies(fixtureSchema); + const names = dtos.map((d) => d.name); + + expect(names).not.toContain("DeleteCartRequestDTO"); + }); + + it("handles missing paths", () => { + const dtos = parseRequestBodies({}); + expect(dtos).toHaveLength(0); + }); + }); + + describe("parseResponseBodies", () => { + it("extracts response DTOs from inline response schemas", () => { + const dtos = parseResponseBodies(fixtureSchema); + const names = dtos.map((d) => d.name); + + expect(names).toContain("ReadProductResponseDTO"); + }); + + it("skips responses that are $ref only", () => { + const dtos = parseResponseBodies(fixtureSchema); + const names = dtos.map((d) => d.name); + + expect(names).not.toContain("ReadCartResponseDTO"); + }); + + it("handles missing paths", () => { + const dtos = parseResponseBodies({}); + expect(dtos).toHaveLength(0); + }); + }); + + describe("parseAllDtos", () => { + it("returns components, request bodies, and response bodies", () => { + const dtos = parseAllDtos(fixtureSchema); + const names = dtos.map((d) => d.name); + + expect(names).toContain("CartDTO"); + expect(names).toContain("SendContactMailRequestDTO"); + expect(names).toContain("ReadProductResponseDTO"); + }); + }); +}); diff --git a/packages/api-gen/tests/php-dto/snapshots.test.ts b/packages/api-gen/tests/php-dto/snapshots.test.ts new file mode 100644 index 000000000..2471f7586 --- /dev/null +++ b/packages/api-gen/tests/php-dto/snapshots.test.ts @@ -0,0 +1,48 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { generateAllFiles } from "../../src/php-dto/generator"; +import { parseAllDtos } from "../../src/php-dto/schemaParser"; + +const FIXTURES_DIR = resolve(__dirname, "fixtures"); + +const fixtureFiles = readdirSync(FIXTURES_DIR).filter((f) => + f.endsWith(".json"), +); + +describe("php-dto snapshot tests", () => { + for (const fixtureFile of fixtureFiles) { + describe(fixtureFile, () => { + const schema = JSON.parse( + readFileSync(resolve(FIXTURES_DIR, fixtureFile), "utf-8"), + ); + const dtos = parseAllDtos(schema); + const files = generateAllFiles(dtos); + const filesWithNamespace = generateAllFiles(dtos, { + namespace: "App\\DTO", + }); + + it("generates expected number of files", () => { + expect(files.length).toMatchSnapshot(); + }); + + it("generates expected file names", () => { + const names = files.map((f) => f.fileName).sort(); + expect(names).toMatchSnapshot(); + }); + + for (const file of files) { + it(`generates correct content for ${file.fileName}`, () => { + expect(file.content).toMatchSnapshot(); + }); + } + + it("generates correct content with namespace", () => { + const firstWithNs = filesWithNamespace[0]; + if (firstWithNs) { + expect(firstWithNs.content).toMatchSnapshot(); + } + }); + }); + } +}); diff --git a/packages/api-gen/tests/php-dto/typeMapper.test.ts b/packages/api-gen/tests/php-dto/typeMapper.test.ts new file mode 100644 index 000000000..41e069a5e --- /dev/null +++ b/packages/api-gen/tests/php-dto/typeMapper.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import { + getSchemaType, + hasTypeNull, + mapOpenApiTypeToPhp, + resolveRefName, + toDtoClassName, +} from "../../src/php-dto/typeMapper"; + +describe("typeMapper", () => { + describe("resolveRefName", () => { + it("extracts the last segment from a $ref path", () => { + expect(resolveRefName("#/components/schemas/Cart")).toBe("Cart"); + }); + + it("handles single-segment ref", () => { + expect(resolveRefName("Product")).toBe("Product"); + }); + }); + + describe("toDtoClassName", () => { + it("appends DTO suffix", () => { + expect(toDtoClassName("Product")).toBe("ProductDTO"); + expect(toDtoClassName("Cart")).toBe("CartDTO"); + }); + }); + + describe("mapOpenApiTypeToPhp", () => { + it("maps string to string", () => { + expect(mapOpenApiTypeToPhp({ type: "string" })).toEqual({ + phpType: "string", + isArray: false, + nullable: false, + }); + }); + + it("maps integer to int", () => { + expect(mapOpenApiTypeToPhp({ type: "integer" })).toEqual({ + phpType: "int", + isArray: false, + nullable: false, + }); + }); + + it("maps number to float", () => { + expect(mapOpenApiTypeToPhp({ type: "number" })).toEqual({ + phpType: "float", + isArray: false, + nullable: false, + }); + }); + + it("maps boolean to bool", () => { + expect(mapOpenApiTypeToPhp({ type: "boolean" })).toEqual({ + phpType: "bool", + isArray: false, + nullable: false, + }); + }); + + it("maps $ref to DTO class name", () => { + expect( + mapOpenApiTypeToPhp({ $ref: "#/components/schemas/Product" }), + ).toEqual({ + phpType: "ProductDTO", + isArray: false, + nullable: false, + }); + }); + + it("maps array with $ref items", () => { + expect( + mapOpenApiTypeToPhp({ + type: "array", + items: { $ref: "#/components/schemas/LineItem" }, + }), + ).toEqual({ + phpType: "array", + isArray: true, + arrayItemType: "LineItemDTO", + nullable: false, + }); + }); + + it("maps array with primitive items", () => { + expect( + mapOpenApiTypeToPhp({ + type: "array", + items: { type: "string" }, + }), + ).toEqual({ + phpType: "array", + isArray: true, + arrayItemType: "string", + nullable: false, + }); + }); + + it("maps array without items", () => { + expect(mapOpenApiTypeToPhp({ type: "array" })).toEqual({ + phpType: "array", + isArray: true, + nullable: false, + }); + }); + + it("maps oneOf with null to nullable type", () => { + expect( + mapOpenApiTypeToPhp({ + oneOf: [{ type: "string" }, { type: "null" }], + }), + ).toEqual({ + phpType: "string", + isArray: false, + nullable: true, + }); + }); + + it("maps oneOf with multiple non-null types to mixed", () => { + expect( + mapOpenApiTypeToPhp({ + oneOf: [{ type: "string" }, { type: "integer" }], + }), + ).toEqual({ + phpType: "mixed", + isArray: false, + nullable: false, + }); + }); + + it("maps anyOf with null to nullable", () => { + expect( + mapOpenApiTypeToPhp({ + anyOf: [{ type: "integer" }, { type: "null" }], + }), + ).toEqual({ + phpType: "int", + isArray: false, + nullable: true, + }); + }); + + it("maps allOf with single schema", () => { + expect( + mapOpenApiTypeToPhp({ + allOf: [{ $ref: "#/components/schemas/Media" }], + }), + ).toEqual({ + phpType: "MediaDTO", + isArray: false, + nullable: false, + }); + }); + + it("maps allOf with $ref among multiple schemas", () => { + expect( + mapOpenApiTypeToPhp({ + allOf: [ + { type: "object", properties: { extra: { type: "string" } } }, + { $ref: "#/components/schemas/Address" }, + ], + }), + ).toEqual({ + phpType: "AddressDTO", + isArray: false, + nullable: false, + }); + }); + + it("maps type array with null to nullable", () => { + expect(mapOpenApiTypeToPhp({ type: ["string", "null"] })).toEqual({ + phpType: "string", + isArray: false, + nullable: true, + }); + }); + + it("maps object type to array", () => { + expect(mapOpenApiTypeToPhp({ type: "object" })).toEqual({ + phpType: "array", + isArray: false, + nullable: false, + }); + }); + + it("maps unknown type to mixed", () => { + expect(mapOpenApiTypeToPhp({})).toEqual({ + phpType: "mixed", + isArray: false, + nullable: false, + }); + }); + + it("maps type: ['integer', 'null'] to nullable int", () => { + expect(mapOpenApiTypeToPhp({ type: ["integer", "null"] })).toEqual({ + phpType: "int", + isArray: false, + nullable: true, + }); + }); + + it("maps type: ['array', 'null'] with items to nullable array", () => { + expect( + mapOpenApiTypeToPhp({ + type: ["array", "null"], + items: { type: "string" }, + }), + ).toEqual({ + phpType: "array", + isArray: true, + arrayItemType: "string", + nullable: true, + }); + }); + + it("maps type: ['object', 'null'] to nullable array", () => { + expect(mapOpenApiTypeToPhp({ type: ["object", "null"] })).toEqual({ + phpType: "array", + isArray: false, + nullable: true, + }); + }); + + it("does not treat plain string type as nullable", () => { + expect(mapOpenApiTypeToPhp({ type: "string" }).nullable).toBe(false); + }); + }); + + describe("hasTypeNull", () => { + it("returns true for type array containing null", () => { + expect(hasTypeNull({ type: ["string", "null"] })).toBe(true); + }); + + it("returns false for plain string type", () => { + expect(hasTypeNull({ type: "string" })).toBe(false); + }); + + it("returns false for type array without null", () => { + expect(hasTypeNull({ type: ["string", "integer"] })).toBe(false); + }); + + it("returns false when type is undefined", () => { + expect(hasTypeNull({})).toBe(false); + }); + }); + + describe("getSchemaType", () => { + it("returns the type for a plain string type", () => { + expect(getSchemaType({ type: "string" })).toBe("string"); + }); + + it("returns the non-null type from a type array", () => { + expect(getSchemaType({ type: ["string", "null"] })).toBe("string"); + }); + + it("returns undefined for multiple non-null types", () => { + expect(getSchemaType({ type: ["string", "integer"] })).toBeUndefined(); + }); + + it("returns undefined when type is missing", () => { + expect(getSchemaType({})).toBeUndefined(); + }); + }); +}); From 7f0c58022be6f4c77584e1db4d4948a0dc815185 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:49:02 +0100 Subject: [PATCH 02/12] fix: typecheck --- packages/api-gen/tests/php-dto/generator.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api-gen/tests/php-dto/generator.test.ts b/packages/api-gen/tests/php-dto/generator.test.ts index d48b92407..7ace7a84f 100644 --- a/packages/api-gen/tests/php-dto/generator.test.ts +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -494,10 +494,10 @@ describe("generator", () => { const files = generateAllFiles(dtos); expect(files).toHaveLength(2); - expect(files[0].fileName).toBe("ProductDTO.php"); - expect(files[1].fileName).toBe("CartDTO.php"); - expect(files[0].content).toContain("class ProductDTO"); - expect(files[1].content).toContain("class CartDTO"); + expect(files[0]?.fileName).toBe("ProductDTO.php"); + expect(files[1]?.fileName).toBe("CartDTO.php"); + expect(files[0]?.content).toContain("class ProductDTO"); + expect(files[1]?.content).toContain("class CartDTO"); }); it("passes namespace to all generated files", () => { @@ -518,7 +518,7 @@ describe("generator", () => { const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); - expect(files[0].content).toContain("namespace App\\DTO;"); + expect(files[0]?.content).toContain("namespace App\\DTO;"); }); }); }); From 4cdadd20a6b2c4ac98eca8f2df2bb6b8dc37292c Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:25:45 +0100 Subject: [PATCH 03/12] feat: use proper naming in class generation --- packages/api-gen/src/cli.ts | 6 + packages/api-gen/src/commands/phpDto.ts | 55 ++++++++- packages/api-gen/src/php-dto/typeMapper.ts | 14 +++ .../__snapshots__/snapshots.test.ts.snap | 94 +++++++++++++++ .../tests/php-dto/fixtures/invalidNames.json | 81 +++++++++++++ packages/api-gen/tests/php-dto/phpDto.test.ts | 107 ++++++++++++++++++ .../api-gen/tests/php-dto/typeMapper.test.ts | 54 +++++++++ 7 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 packages/api-gen/tests/php-dto/fixtures/invalidNames.json diff --git a/packages/api-gen/src/cli.ts b/packages/api-gen/src/cli.ts index 14b34dfa2..7a429a03a 100644 --- a/packages/api-gen/src/cli.ts +++ b/packages/api-gen/src/cli.ts @@ -174,6 +174,12 @@ yargs(hideBin(process.argv)) alias: "n", type: "string", describe: "PHP namespace for generated classes", + }) + .option("rawNames", { + type: "boolean", + default: false, + describe: + "skip auto-converting class names to PascalCase; fail on invalid names instead", }); }, async (args) => phpDto(args as unknown as PhpDtoOptions), diff --git a/packages/api-gen/src/commands/phpDto.ts b/packages/api-gen/src/commands/phpDto.ts index 9c7dfb2fc..4fe319bb5 100644 --- a/packages/api-gen/src/commands/phpDto.ts +++ b/packages/api-gen/src/commands/phpDto.ts @@ -10,7 +10,9 @@ import { resolve } from "node:path"; import pc from "picocolors"; import { generateAllFiles } from "../php-dto/generator"; import type { GeneratorOptions } from "../php-dto/generator"; +import type { DtoDefinition } from "../php-dto/schemaParser"; import { parseAllDtos } from "../php-dto/schemaParser"; +import { isValidPhpClassName, toPascalCase } from "../php-dto/typeMapper"; import { loadLocalJSONFile } from "../utils"; export interface PhpDtoOptions { @@ -18,11 +20,50 @@ export interface PhpDtoOptions { schemaFile: string; outputDir: string; namespace?: string; + rawNames?: boolean; cwd?: string; } +function sanitizeDtoNames(dtos: DtoDefinition[]): DtoDefinition[] { + const renameMap = new Map(); + + for (const dto of dtos) { + const sanitized = toPascalCase(dto.name); + if (sanitized !== dto.name) { + renameMap.set(dto.name, sanitized); + } + } + + if (renameMap.size === 0) return dtos; + + return dtos.map((dto) => ({ + ...dto, + name: renameMap.get(dto.name) ?? dto.name, + properties: dto.properties.map((prop) => ({ + ...prop, + phpType: renameMap.get(prop.phpType) ?? prop.phpType, + arrayItemType: prop.arrayItemType + ? renameMap.get(prop.arrayItemType) ?? prop.arrayItemType + : prop.arrayItemType, + })), + })); +} + +function validateDtoNames(dtos: DtoDefinition[]): void { + const invalidNames = dtos + .map((d) => d.name) + .filter((name) => !isValidPhpClassName(name)); + + if (invalidNames.length > 0) { + const list = invalidNames.map((n) => ` - ${n}`).join("\n"); + throw new Error( + `Invalid PHP class names found:\n${list}\n\nRemove --rawNames to auto-convert names to valid PascalCase.`, + ); + } +} + export async function phpDto(options: PhpDtoOptions): Promise { - const { action, schemaFile, outputDir, namespace } = options; + const { action, schemaFile, outputDir, namespace, rawNames } = options; const cwd = options.cwd || process.cwd(); const outputPath = resolve(cwd, outputDir); @@ -31,13 +72,21 @@ export async function phpDto(options: PhpDtoOptions): Promise { if (!schema) { throw new Error(`Schema file not found: ${schemaPath}`); } - const dtos = parseAllDtos(schema); + const rawDtos = parseAllDtos(schema); - if (dtos.length === 0) { + if (rawDtos.length === 0) { console.log(pc.yellow("No DTO definitions found in the schema.")); return; } + let dtos: DtoDefinition[]; + if (rawNames) { + validateDtoNames(rawDtos); + dtos = rawDtos; + } else { + dtos = sanitizeDtoNames(rawDtos); + } + const generatorOptions: GeneratorOptions = { namespace }; const files = generateAllFiles(dtos, generatorOptions); diff --git a/packages/api-gen/src/php-dto/typeMapper.ts b/packages/api-gen/src/php-dto/typeMapper.ts index 2e26dd77c..eda4955ab 100644 --- a/packages/api-gen/src/php-dto/typeMapper.ts +++ b/packages/api-gen/src/php-dto/typeMapper.ts @@ -34,6 +34,20 @@ export function resolveRefName(ref: string): string { return parts.at(-1) ?? ref; } +export function toPascalCase(str: string): string { + return str + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(""); +} + +const PHP_CLASS_NAME_REGEX = /^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/; + +export function isValidPhpClassName(name: string): boolean { + return PHP_CLASS_NAME_REGEX.test(name); +} + export function toDtoClassName(schemaName: string): string { return `${schemaName}DTO`; } diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap index 099ebbdac..11125cc97 100644 --- a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -138,6 +138,100 @@ exports[`php-dto snapshot tests > createReadEntity.json > generates expected fil exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `4`; +exports[`php-dto snapshot tests > invalidNames.json > generates correct content for Api-infoRequestDTO.php 1`] = ` +" invalidNames.json > generates correct content for Api-infoResponseDTO.php 1`] = ` +" invalidNames.json > generates correct content for Simple-ProductDTO.php 1`] = ` +" invalidNames.json > generates correct content for errorDTO.php 1`] = ` +" invalidNames.json > generates correct content for errorResponseDTO.php 1`] = ` +" + */ + public array $errors; +} +" +`; + +exports[`php-dto snapshot tests > invalidNames.json > generates correct content with namespace 1`] = ` +" invalidNames.json > generates expected file names 1`] = ` +[ + "Api-infoRequestDTO.php", + "Api-infoResponseDTO.php", + "Simple-ProductDTO.php", + "errorDTO.php", + "errorResponseDTO.php", +] +`; + +exports[`php-dto snapshot tests > invalidNames.json > generates expected number of files 1`] = `5`; + exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for OrderBillingAddressCountryDTO.php 1`] = ` " { }), ).rejects.toThrow("Schema file not found"); }); + + describe("name handling", () => { + const INVALID_SCHEMA = resolve(__dirname, "fixtures/invalidNames.json"); + + it("default: converts hyphenated names to PascalCase", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "name-pascal"); + + await phpDto({ + action: "generate", + schemaFile: INVALID_SCHEMA, + outputDir, + }); + + const files = readdirSync(outputDir).filter((f) => f.endsWith(".php")); + expect(files).toContain("SimpleProductDTO.php"); + expect(files).toContain("ApiInfoRequestDTO.php"); + expect(files).not.toContain("Simple-ProductDTO.php"); + expect(files).not.toContain("Api-infoRequestDTO.php"); + + const content = readFileSync( + resolve(outputDir, "SimpleProductDTO.php"), + "utf-8", + ); + expect(content).toContain("class SimpleProductDTO"); + }); + + it("default: uppercases lowercase names in both file and class", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "name-lowercase"); + + await phpDto({ + action: "generate", + schemaFile: INVALID_SCHEMA, + outputDir, + }); + + const files = readdirSync(outputDir).filter((f) => f.endsWith(".php")); + expect(files).toContain("ErrorDTO.php"); + expect(files).not.toContain("errorDTO.php"); + + const content = readFileSync(resolve(outputDir, "ErrorDTO.php"), "utf-8"); + expect(content).toContain("class ErrorDTO"); + expect(content).not.toContain("class errorDTO"); + }); + + it("default: updates $ref type references to renamed DTOs", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "name-pascal-refs"); + + await phpDto({ + action: "generate", + schemaFile: INVALID_SCHEMA, + outputDir, + }); + + const content = readFileSync( + resolve(outputDir, "ErrorResponseDTO.php"), + "utf-8", + ); + expect(content).toContain("class ErrorResponseDTO"); + expect(content).toContain("@var list"); + expect(content).not.toContain("errorDTO"); + }); + + it("rawNames: throws listing invalid class names", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "name-error"); + + await expect( + phpDto({ + action: "generate", + schemaFile: INVALID_SCHEMA, + outputDir, + rawNames: true, + }), + ).rejects.toThrow("Invalid PHP class names found"); + }); + + it("rawNames: includes all invalid names in error message", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "name-error-list"); + + try { + await phpDto({ + action: "generate", + schemaFile: INVALID_SCHEMA, + outputDir, + rawNames: true, + }); + expect.unreachable("should have thrown"); + } catch (err) { + const message = (err as Error).message; + expect(message).toContain("Simple-ProductDTO"); + expect(message).toContain("Api-infoRequestDTO"); + expect(message).toContain("--rawNames"); + } + }); + + it("rawNames: passes for schemas with valid names", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "name-error-valid"); + + await phpDto({ + action: "generate", + schemaFile: FIXTURE_SCHEMA, + outputDir, + rawNames: true, + }); + + expect(existsSync(outputDir)).toBe(true); + }); + }); }); diff --git a/packages/api-gen/tests/php-dto/typeMapper.test.ts b/packages/api-gen/tests/php-dto/typeMapper.test.ts index 41e069a5e..873f47cca 100644 --- a/packages/api-gen/tests/php-dto/typeMapper.test.ts +++ b/packages/api-gen/tests/php-dto/typeMapper.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { getSchemaType, hasTypeNull, + isValidPhpClassName, mapOpenApiTypeToPhp, resolveRefName, toDtoClassName, + toPascalCase, } from "../../src/php-dto/typeMapper"; describe("typeMapper", () => { @@ -226,6 +228,58 @@ describe("typeMapper", () => { }); }); + describe("toPascalCase", () => { + it("converts hyphenated names", () => { + expect(toPascalCase("api-info")).toBe("ApiInfo"); + expect(toPascalCase("Api-info")).toBe("ApiInfo"); + }); + + it("converts underscored names", () => { + expect(toPascalCase("some_thing")).toBe("SomeThing"); + }); + + it("converts mixed separators", () => { + expect(toPascalCase("my-special_name.test")).toBe("MySpecialNameTest"); + }); + + it("preserves already PascalCase names", () => { + expect(toPascalCase("CartDTO")).toBe("CartDTO"); + expect(toPascalCase("ProductDTO")).toBe("ProductDTO"); + }); + + it("handles names with numbers", () => { + expect(toPascalCase("b2b-components")).toBe("B2bComponents"); + }); + + it("handles names ending with DTO suffix through separators", () => { + expect(toPascalCase("Api-infoRequestDTO")).toBe("ApiInfoRequestDTO"); + }); + }); + + describe("isValidPhpClassName", () => { + it("accepts valid class names", () => { + expect(isValidPhpClassName("CartDTO")).toBe(true); + expect(isValidPhpClassName("ProductDTO")).toBe(true); + expect(isValidPhpClassName("_Internal")).toBe(true); + }); + + it("rejects names with hyphens", () => { + expect(isValidPhpClassName("Api-infoDTO")).toBe(false); + }); + + it("rejects names with dots", () => { + expect(isValidPhpClassName("Some.ClassDTO")).toBe(false); + }); + + it("rejects names starting with a digit", () => { + expect(isValidPhpClassName("2FactorDTO")).toBe(false); + }); + + it("rejects empty string", () => { + expect(isValidPhpClassName("")).toBe(false); + }); + }); + describe("hasTypeNull", () => { it("returns true for type array containing null", () => { expect(hasTypeNull({ type: ["string", "null"] })).toBe(true); From 0683ba8e55c6382996dcd8449365b746ed6f3b23 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:01:56 +0100 Subject: [PATCH 04/12] feat: use default values from schema --- packages/api-gen/src/php-dto/generator.ts | 13 +- packages/api-gen/src/php-dto/openApiTypes.ts | 1 + packages/api-gen/src/php-dto/schemaParser.ts | 29 +++++ .../__snapshots__/snapshots.test.ts.snap | 30 ++++- .../tests/php-dto/fixtures/simpleSchema.json | 32 +++++ .../api-gen/tests/php-dto/generator.test.ts | 114 ++++++++++++++++++ .../tests/php-dto/schemaParser.test.ts | 40 ++++++ 7 files changed, 257 insertions(+), 2 deletions(-) diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts index c4ceec424..04e1c47f6 100644 --- a/packages/api-gen/src/php-dto/generator.ts +++ b/packages/api-gen/src/php-dto/generator.ts @@ -12,6 +12,12 @@ function escapePhpSingleQuoted(text: string): string { return text.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); } +function formatPhpDefault(value: string | number | boolean): string { + if (typeof value === "string") return `'${escapePhpSingleQuoted(value)}'`; + if (typeof value === "boolean") return value ? "true" : "false"; + return String(value); +} + function renderPropertyBlock(prop: DtoProperty): string { const lines: string[] = []; const hasTypedArray = prop.isArray && prop.arrayItemType; @@ -44,7 +50,12 @@ function renderPropertyBlock(prop: DtoProperty): string { } const typePrefix = prop.nullable ? "?" : ""; - const defaultSuffix = prop.nullable ? " = null" : ""; + let defaultSuffix = ""; + if (prop.defaultValue !== undefined) { + defaultSuffix = ` = ${formatPhpDefault(prop.defaultValue)}`; + } else if (prop.nullable) { + defaultSuffix = " = null"; + } lines.push( ` public ${typePrefix}${prop.phpType} $${prop.name}${defaultSuffix};`, ); diff --git a/packages/api-gen/src/php-dto/openApiTypes.ts b/packages/api-gen/src/php-dto/openApiTypes.ts index 2e8a3de21..6dea2c437 100644 --- a/packages/api-gen/src/php-dto/openApiTypes.ts +++ b/packages/api-gen/src/php-dto/openApiTypes.ts @@ -11,6 +11,7 @@ export interface SchemaObject { pattern?: string; format?: string; enum?: unknown[]; + default?: unknown; additionalProperties?: boolean | SchemaObject; } diff --git a/packages/api-gen/src/php-dto/schemaParser.ts b/packages/api-gen/src/php-dto/schemaParser.ts index 4d2aaead7..8327672a3 100644 --- a/packages/api-gen/src/php-dto/schemaParser.ts +++ b/packages/api-gen/src/php-dto/schemaParser.ts @@ -20,6 +20,7 @@ export interface DtoProperty { description?: string; pattern?: string; enum?: string[]; + defaultValue?: string | number | boolean; isArray: boolean; arrayItemType?: string; } @@ -59,6 +60,30 @@ function dereferenceSchema( return schema; } +function resolveDefaultValue( + schema: SchemaObject, +): string | number | boolean | undefined { + const raw = schema.default; + if ( + typeof raw === "string" || + typeof raw === "number" || + typeof raw === "boolean" + ) { + return raw; + } + if (raw === undefined && schema.enum && schema.enum.length === 1) { + const single = schema.enum[0]; + if ( + typeof single === "string" || + typeof single === "number" || + typeof single === "boolean" + ) { + return single; + } + } + return undefined; +} + function isInlineObject(schema: SchemaObject): boolean { return ( getSchemaType(schema) === "object" && @@ -124,6 +149,7 @@ function extractPropertiesFromSchema( description: propSchema.description, pattern: propSchema.pattern, enum: enumValues, + defaultValue: resolveDefaultValue(propSchema), isArray: false, arrayItemType: undefined, }); @@ -162,6 +188,7 @@ function extractPropertiesFromSchema( description: propSchema.description, pattern: propSchema.pattern, enum: enumValues, + defaultValue: resolveDefaultValue(propSchema), isArray: true, arrayItemType: nestedName, }); @@ -178,6 +205,7 @@ function extractPropertiesFromSchema( description: propSchema.description, pattern: propSchema.pattern, enum: enumValues, + defaultValue: resolveDefaultValue(propSchema), isArray: typeResult.isArray, arrayItemType: typeResult.arrayItemType, }); @@ -294,6 +322,7 @@ export function parseRequestBodies(schema: OpenApiSchema): DtoDefinition[] { required: param.required === true, description: param.description, pattern: param.schema.pattern, + defaultValue: resolveDefaultValue(param.schema), isArray: typeResult.isArray, arrayItemType: typeResult.arrayItemType, }); diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap index 11125cc97..d36891bd0 100644 --- a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -541,6 +541,33 @@ class CartDTO " `; +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for DefaultValuesDTO.php 1`] = ` +" simpleSchema.json > generates correct content for LineItemDTO.php 1`] = ` " simpleSchema.json > generates expected file na [ "CalculatedPriceDTO.php", "CartDTO.php", + "DefaultValuesDTO.php", "LineItemDTO.php", "NavigationTypeDTO.php", "NullableUnionDTO.php", @@ -713,4 +741,4 @@ exports[`php-dto snapshot tests > simpleSchema.json > generates expected file na ] `; -exports[`php-dto snapshot tests > simpleSchema.json > generates expected number of files 1`] = `8`; +exports[`php-dto snapshot tests > simpleSchema.json > generates expected number of files 1`] = `9`; diff --git a/packages/api-gen/tests/php-dto/fixtures/simpleSchema.json b/packages/api-gen/tests/php-dto/fixtures/simpleSchema.json index 25112f205..bac41c939 100644 --- a/packages/api-gen/tests/php-dto/fixtures/simpleSchema.json +++ b/packages/api-gen/tests/php-dto/fixtures/simpleSchema.json @@ -96,6 +96,38 @@ "EmptySchema": { "type": "string" }, + "DefaultValues": { + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string" + }, + "limit": { + "type": "integer", + "default": 10 + }, + "sortOrder": { + "type": "string", + "default": "relevance" + }, + "active": { + "type": "boolean", + "default": true + }, + "source": { + "type": "string", + "enum": ["storefront"], + "description": "Single-value enum, should default to the only value" + }, + "channel": { + "type": "string", + "enum": ["web", "api"], + "default": "web", + "description": "Multi-value enum with explicit default" + } + } + }, "NullableUnion": { "type": "object", "properties": { diff --git a/packages/api-gen/tests/php-dto/generator.test.ts b/packages/api-gen/tests/php-dto/generator.test.ts index 7ace7a84f..a71f6214b 100644 --- a/packages/api-gen/tests/php-dto/generator.test.ts +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -440,6 +440,120 @@ describe("generator", () => { expect(result).toContain("public ?array $taxRules = null;"); }); + it("renders string default value", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "sortOrder", + phpType: "string", + nullable: false, + required: false, + defaultValue: "relevance", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + expect(result).toContain("public string $sortOrder = 'relevance';"); + }); + + it("renders integer default value", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "limit", + phpType: "int", + nullable: false, + required: false, + defaultValue: 10, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + expect(result).toContain("public int $limit = 10;"); + }); + + it("renders boolean default value", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "active", + phpType: "bool", + nullable: false, + required: false, + defaultValue: true, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + expect(result).toContain("public bool $active = true;"); + }); + + it("renders default on nullable property instead of null", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "mode", + phpType: "string", + nullable: true, + required: false, + defaultValue: "auto", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + expect(result).toContain("public ?string $mode = 'auto';"); + expect(result).not.toContain("= null"); + }); + + it("renders nullable without default as = null", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "label", + phpType: "string", + nullable: true, + required: false, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + expect(result).toContain("public ?string $label = null;"); + }); + + it("escapes single quotes in string default values", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "greeting", + phpType: "string", + nullable: false, + required: false, + defaultValue: "it's", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + expect(result).toContain("public string $greeting = 'it\\'s';"); + }); + it("handles multiline description", () => { const dto: DtoDefinition = { name: "TestDTO", diff --git a/packages/api-gen/tests/php-dto/schemaParser.test.ts b/packages/api-gen/tests/php-dto/schemaParser.test.ts index 4ae4792c3..e39fbee54 100644 --- a/packages/api-gen/tests/php-dto/schemaParser.test.ts +++ b/packages/api-gen/tests/php-dto/schemaParser.test.ts @@ -123,6 +123,46 @@ describe("schemaParser", () => { expect(name?.required).toBe(false); }); + it("extracts explicit default values", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const defaults = dtos.find((d) => d.name === "DefaultValuesDTO"); + expect(defaults).toBeDefined(); + + const limit = defaults?.properties.find((p) => p.name === "limit"); + expect(limit?.defaultValue).toBe(10); + + const sortOrder = defaults?.properties.find( + (p) => p.name === "sortOrder", + ); + expect(sortOrder?.defaultValue).toBe("relevance"); + + const active = defaults?.properties.find((p) => p.name === "active"); + expect(active?.defaultValue).toBe(true); + }); + + it("uses single-enum value as default when no explicit default", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const defaults = dtos.find((d) => d.name === "DefaultValuesDTO"); + + const source = defaults?.properties.find((p) => p.name === "source"); + expect(source?.defaultValue).toBe("storefront"); + }); + + it("prefers explicit default over single-enum fallback", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const defaults = dtos.find((d) => d.name === "DefaultValuesDTO"); + + const channel = defaults?.properties.find((p) => p.name === "channel"); + expect(channel?.defaultValue).toBe("web"); + }); + + it("does not set default for multi-value enum without explicit default", () => { + const dtos = parseComponentSchemas(fixtureSchema); + const navType = dtos.find((d) => d.name === "NavigationTypeDTO"); + const type = navType?.properties.find((p) => p.name === "type"); + expect(type?.defaultValue).toBeUndefined(); + }); + it("handles empty schema", () => { const dtos = parseComponentSchemas({ components: { schemas: {} } }); expect(dtos).toHaveLength(0); From 2b9eb2dbdfd6b968012d0ff5edfb24b328107f3b Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:13:44 +0100 Subject: [PATCH 05/12] feat: wrap fields into constructor --- packages/api-gen/src/php-dto/generator.ts | 37 +- .../__snapshots__/snapshots.test.ts.snap | 611 +++++++++--------- .../api-gen/tests/php-dto/generator.test.ts | 52 +- 3 files changed, 372 insertions(+), 328 deletions(-) diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts index 04e1c47f6..edfa9bc12 100644 --- a/packages/api-gen/src/php-dto/generator.ts +++ b/packages/api-gen/src/php-dto/generator.ts @@ -18,27 +18,31 @@ function formatPhpDefault(value: string | number | boolean): string { return String(value); } -function renderPropertyBlock(prop: DtoProperty): string { +function hasDefault(prop: DtoProperty): boolean { + return prop.defaultValue !== undefined || prop.nullable; +} + +function renderConstructorParam(prop: DtoProperty): string { const lines: string[] = []; const hasTypedArray = prop.isArray && prop.arrayItemType; if (hasTypedArray) { - lines.push(" /**"); + lines.push(" /**"); lines.push( - ` * @var list<${prop.arrayItemType}>${prop.description ? ` ${escapePhpDocComment(prop.description)}` : ""}`, + ` * @var list<${prop.arrayItemType}>${prop.description ? ` ${escapePhpDocComment(prop.description)}` : ""}`, ); - lines.push(" */"); + lines.push(" */"); } else if (prop.description) { - lines.push(` /** ${escapePhpDocComment(prop.description)} */`); + lines.push(` /** ${escapePhpDocComment(prop.description)} */`); } if (prop.required && !prop.nullable) { - lines.push(" #[Assert\\NotNull]"); + lines.push(" #[Assert\\NotNull]"); } if (prop.pattern) { lines.push( - ` #[Assert\\Regex(pattern: '/${escapePhpSingleQuoted(prop.pattern)}/')]`, + ` #[Assert\\Regex(pattern: '/${escapePhpSingleQuoted(prop.pattern)}/')]`, ); } @@ -46,7 +50,7 @@ function renderPropertyBlock(prop: DtoProperty): string { const choices = prop.enum .map((v) => `'${escapePhpSingleQuoted(v)}'`) .join(", "); - lines.push(` #[Assert\\Choice(choices: [${choices}])]`); + lines.push(` #[Assert\\Choice(choices: [${choices}])]`); } const typePrefix = prop.nullable ? "?" : ""; @@ -57,7 +61,7 @@ function renderPropertyBlock(prop: DtoProperty): string { defaultSuffix = " = null"; } lines.push( - ` public ${typePrefix}${prop.phpType} $${prop.name}${defaultSuffix};`, + ` public ${typePrefix}${prop.phpType} $${prop.name}${defaultSuffix},`, ); return lines.join("\n"); @@ -97,8 +101,19 @@ export function generatePhpClass( lines.push(`class ${dto.name}`); lines.push("{"); - const propertyBlocks = dto.properties.map(renderPropertyBlock); - lines.push(propertyBlocks.join("\n\n")); + const sorted = [...dto.properties].sort((a, b) => { + const ad = hasDefault(a); + const bd = hasDefault(b); + if (ad === bd) return 0; + return ad ? 1 : -1; + }); + + const paramBlocks = sorted.map(renderConstructorParam); + + lines.push(" public function __construct("); + lines.push(paramBlocks.join("\n")); + lines.push(" ) {"); + lines.push(" }"); lines.push("}"); lines.push(""); diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap index d36891bd0..75eaa3810 100644 --- a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -10,14 +10,16 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class CartCreateDTO { - /** Name of the cart, e.g. guest-cart */ - #[Assert\\NotNull] - public string $name; - - /** - * @var list Initial line items to add to the cart - */ - public array $lineItems; + public function __construct( + /** Name of the cart, e.g. guest-cart */ + #[Assert\\NotNull] + public string $name, + /** + * @var list Initial line items to add to the cart + */ + public array $lineItems, + ) { + } } " `; @@ -32,26 +34,25 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class CartDTO { - /** Name of the cart, e.g. guest-cart */ - #[Assert\\NotNull] - public string $name; - - /** - * @var list Initial line items to add to the cart - */ - public array $lineItems; - - /** Unique identifier of the cart */ - #[Assert\\NotNull] - #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] - public string $id; - - /** Date and time the cart was created */ - #[Assert\\NotNull] - public string $createdAt; - - /** Date and time the cart was last modified */ - public string $updatedAt; + public function __construct( + /** Name of the cart, e.g. guest-cart */ + #[Assert\\NotNull] + public string $name, + /** + * @var list Initial line items to add to the cart + */ + public array $lineItems, + /** Unique identifier of the cart */ + #[Assert\\NotNull] + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $id, + /** Date and time the cart was created */ + #[Assert\\NotNull] + public string $createdAt, + /** Date and time the cart was last modified */ + public string $updatedAt, + ) { + } } " `; @@ -66,14 +67,16 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class CreateCartRequestDTO { - /** Name of the cart, e.g. guest-cart */ - #[Assert\\NotNull] - public string $name; - - /** - * @var list Initial line items to add to the cart - */ - public array $lineItems; + public function __construct( + /** Name of the cart, e.g. guest-cart */ + #[Assert\\NotNull] + public string $name, + /** + * @var list Initial line items to add to the cart + */ + public array $lineItems, + ) { + } } " `; @@ -88,17 +91,18 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class LineItemDTO { - /** Product identifier */ - #[Assert\\NotNull] - #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] - public string $id; - - /** Number of items */ - #[Assert\\NotNull] - public int $quantity; - - /** Display label */ - public string $label; + public function __construct( + /** Product identifier */ + #[Assert\\NotNull] + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $id, + /** Number of items */ + #[Assert\\NotNull] + public int $quantity, + /** Display label */ + public string $label, + ) { + } } " `; @@ -115,14 +119,16 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class CartCreateDTO { - /** Name of the cart, e.g. guest-cart */ - #[Assert\\NotNull] - public string $name; - - /** - * @var list Initial line items to add to the cart - */ - public array $lineItems; + public function __construct( + /** Name of the cart, e.g. guest-cart */ + #[Assert\\NotNull] + public string $name, + /** + * @var list Initial line items to add to the cart + */ + public array $lineItems, + ) { + } } " `; @@ -145,9 +151,12 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class Api-infoRequestDTO { - /** Type of the api */ - #[Assert\\NotNull] - public string $type; + public function __construct( + /** Type of the api */ + #[Assert\\NotNull] + public string $type, + ) { + } } " `; @@ -160,7 +169,10 @@ exports[`php-dto snapshot tests > invalidNames.json > generates correct content */ class Api-infoResponseDTO { - public string $version; + public function __construct( + public string $version, + ) { + } } " `; @@ -170,9 +182,11 @@ exports[`php-dto snapshot tests > invalidNames.json > generates correct content class Simple-ProductDTO { - public string $id; - - public string $name; + public function __construct( + public string $id, + public string $name, + ) { + } } " `; @@ -184,11 +198,13 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class errorDTO { - #[Assert\\NotNull] - public string $code; - - #[Assert\\NotNull] - public string $message; + public function __construct( + #[Assert\\NotNull] + public string $code, + #[Assert\\NotNull] + public string $message, + ) { + } } " `; @@ -198,10 +214,13 @@ exports[`php-dto snapshot tests > invalidNames.json > generates correct content class errorResponseDTO { - /** - * @var list - */ - public array $errors; + public function __construct( + /** + * @var list + */ + public array $errors, + ) { + } } " `; @@ -213,9 +232,11 @@ namespace App\\DTO; class Simple-ProductDTO { - public string $id; - - public string $name; + public function __construct( + public string $id, + public string $name, + ) { + } } " `; @@ -240,10 +261,12 @@ exports[`php-dto snapshot tests > nestedObjects.json > generates correct content */ class OrderBillingAddressCountryDTO { - /** ISO 3166-1 alpha-2 code */ - public string $iso; - - public string $name; + public function __construct( + /** ISO 3166-1 alpha-2 code */ + public string $iso, + public string $name, + ) { + } } " `; @@ -258,17 +281,17 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class OrderBillingAddressDTO { - #[Assert\\NotNull] - public string $street; - - #[Assert\\NotNull] - public string $city; - - #[Assert\\NotNull] - public string $zipcode; - - /** Country details */ - public OrderBillingAddressCountryDTO $country; + public function __construct( + #[Assert\\NotNull] + public string $street, + #[Assert\\NotNull] + public string $city, + #[Assert\\NotNull] + public string $zipcode, + /** Country details */ + public OrderBillingAddressCountryDTO $country, + ) { + } } " `; @@ -283,13 +306,15 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class OrderDTO { - #[Assert\\NotNull] - #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] - public string $id; - - /** The billing address */ - #[Assert\\NotNull] - public OrderBillingAddressDTO $billingAddress; + public function __construct( + #[Assert\\NotNull] + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $id, + /** The billing address */ + #[Assert\\NotNull] + public OrderBillingAddressDTO $billingAddress, + ) { + } } " `; @@ -302,26 +327,21 @@ exports[`php-dto snapshot tests > nestedObjects.json > generates correct content */ class SalesChannelContextContextDTO { - public string $versionId; - - public string $currencyId; - - public int $currencyFactor; - - public int $currencyPrecision; - - /** - * @var list - */ - public array $languageIdChain; - - public string $scope; - - public SalesChannelContextContextSourceDTO $source; - - public string $taxState; - - public bool $useCache; + public function __construct( + public string $versionId, + public string $currencyId, + public int $currencyFactor, + public int $currencyPrecision, + /** + * @var list + */ + public array $languageIdChain, + public string $scope, + public SalesChannelContextContextSourceDTO $source, + public string $taxState, + public bool $useCache, + ) { + } } " `; @@ -333,12 +353,14 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class SalesChannelContextContextSourceDTO { - #[Assert\\NotNull] - #[Assert\\Choice(choices: ['sales-channel', 'shop-api'])] - public string $type; - - #[Assert\\NotNull] - public string $salesChannelId; + public function __construct( + #[Assert\\NotNull] + #[Assert\\Choice(choices: ['sales-channel', 'shop-api'])] + public string $type, + #[Assert\\NotNull] + public string $salesChannelId, + ) { + } } " `; @@ -351,11 +373,13 @@ exports[`php-dto snapshot tests > nestedObjects.json > generates correct content */ class SalesChannelContextCurrentCustomerGroupDTO { - /** Name of the group */ - public string $name; - - /** Whether prices are displayed gross */ - public bool $displayGross; + public function __construct( + /** Name of the group */ + public string $name, + /** Whether prices are displayed gross */ + public bool $displayGross, + ) { + } } " `; @@ -370,25 +394,23 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class SalesChannelContextDTO { - /** Context token */ - public string $token; - - #[Assert\\NotNull] - public SalesChannelDTO $salesChannel; - - /** Core context with general configuration values and state */ - public SalesChannelContextContextDTO $context; - - /** Customer group of the current user */ - public SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup; - - #[Assert\\NotNull] - public SalesChannelContextItemRoundingDTO $itemRounding; - - /** - * @var list Active tax rules - */ - public array $taxRules; + public function __construct( + /** Context token */ + public string $token, + #[Assert\\NotNull] + public SalesChannelDTO $salesChannel, + /** Core context with general configuration values and state */ + public SalesChannelContextContextDTO $context, + /** Customer group of the current user */ + public SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup, + #[Assert\\NotNull] + public SalesChannelContextItemRoundingDTO $itemRounding, + /** + * @var list Active tax rules + */ + public array $taxRules, + ) { + } } " `; @@ -400,14 +422,15 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class SalesChannelContextItemRoundingDTO { - #[Assert\\NotNull] - public int $decimals; - - #[Assert\\NotNull] - public float $interval; - - #[Assert\\NotNull] - public bool $roundForNet; + public function __construct( + #[Assert\\NotNull] + public int $decimals, + #[Assert\\NotNull] + public float $interval, + #[Assert\\NotNull] + public bool $roundForNet, + ) { + } } " `; @@ -419,10 +442,12 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class SalesChannelContextTaxRulesDTO { - #[Assert\\NotNull] - public float $taxRate; - - public string $name; + public function __construct( + #[Assert\\NotNull] + public float $taxRate, + public string $name, + ) { + } } " `; @@ -434,12 +459,14 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class SalesChannelDTO { - #[Assert\\NotNull] - #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] - public string $id; - - #[Assert\\NotNull] - public string $name; + public function __construct( + #[Assert\\NotNull] + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $id, + #[Assert\\NotNull] + public string $name, + ) { + } } " `; @@ -456,25 +483,23 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class SalesChannelContextDTO { - /** Context token */ - public string $token; - - #[Assert\\NotNull] - public SalesChannelDTO $salesChannel; - - /** Core context with general configuration values and state */ - public SalesChannelContextContextDTO $context; - - /** Customer group of the current user */ - public SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup; - - #[Assert\\NotNull] - public SalesChannelContextItemRoundingDTO $itemRounding; - - /** - * @var list Active tax rules - */ - public array $taxRules; + public function __construct( + /** Context token */ + public string $token, + #[Assert\\NotNull] + public SalesChannelDTO $salesChannel, + /** Core context with general configuration values and state */ + public SalesChannelContextContextDTO $context, + /** Customer group of the current user */ + public SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup, + #[Assert\\NotNull] + public SalesChannelContextItemRoundingDTO $itemRounding, + /** + * @var list Active tax rules + */ + public array $taxRules, + ) { + } } " `; @@ -501,9 +526,11 @@ exports[`php-dto snapshot tests > simpleSchema.json > generates correct content class CalculatedPriceDTO { - public float $unitPrice; - - public float $totalPrice; + public function __construct( + public float $unitPrice, + public float $totalPrice, + ) { + } } " `; @@ -518,25 +545,22 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class CartDTO { - /** Context token identifying the cart */ - #[Assert\\NotNull] - public string $token; - - /** Name of the cart */ - public string $name; - - public CalculatedPriceDTO $price; - - /** - * @var list All items within the cart - */ - public array $lineItems; - - public int $totalItems; - - public bool $active; - - public float $taxRate; + public function __construct( + /** Context token identifying the cart */ + #[Assert\\NotNull] + public string $token, + /** Name of the cart */ + public string $name, + public CalculatedPriceDTO $price, + /** + * @var list All items within the cart + */ + public array $lineItems, + public int $totalItems, + public bool $active, + public float $taxRate, + ) { + } } " `; @@ -548,22 +572,20 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class DefaultValuesDTO { - #[Assert\\NotNull] - public string $query; - - public int $limit = 10; - - public string $sortOrder = 'relevance'; - - public bool $active = true; - - /** Single-value enum, should default to the only value */ - #[Assert\\Choice(choices: ['storefront'])] - public string $source = 'storefront'; - - /** Multi-value enum with explicit default */ - #[Assert\\Choice(choices: ['web', 'api'])] - public string $channel = 'web'; + public function __construct( + #[Assert\\NotNull] + public string $query, + public int $limit = 10, + public string $sortOrder = 'relevance', + public bool $active = true, + /** Single-value enum, should default to the only value */ + #[Assert\\Choice(choices: ['storefront'])] + public string $source = 'storefront', + /** Multi-value enum with explicit default */ + #[Assert\\Choice(choices: ['web', 'api'])] + public string $channel = 'web', + ) { + } } " `; @@ -575,14 +597,15 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class LineItemDTO { - #[Assert\\NotNull] - #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] - public string $id; - - #[Assert\\NotNull] - public int $quantity; - - public string $label; + public function __construct( + #[Assert\\NotNull] + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $id, + #[Assert\\NotNull] + public int $quantity, + public string $label, + ) { + } } " `; @@ -597,19 +620,20 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class NavigationTypeDTO { - /** Type of the navigation entry */ - #[Assert\\NotNull] - #[Assert\\Choice(choices: ['page', 'link', 'folder'])] - public string $type; - - /** Route name for the navigation */ - #[Assert\\NotNull] - #[Assert\\Choice(choices: ['frontend.navigation.page', 'frontend.landing.page', 'frontend.detail.page'])] - public string $routeName; - - /** Type of the link if type is link */ - #[Assert\\Choice(choices: ['external', 'category', 'product', 'landing_page'])] - public string $linkType; + public function __construct( + /** Type of the navigation entry */ + #[Assert\\NotNull] + #[Assert\\Choice(choices: ['page', 'link', 'folder'])] + public string $type, + /** Route name for the navigation */ + #[Assert\\NotNull] + #[Assert\\Choice(choices: ['frontend.navigation.page', 'frontend.landing.page', 'frontend.detail.page'])] + public string $routeName, + /** Type of the link if type is link */ + #[Assert\\Choice(choices: ['external', 'category', 'product', 'landing_page'])] + public string $linkType, + ) { + } } " `; @@ -619,10 +643,12 @@ exports[`php-dto snapshot tests > simpleSchema.json > generates correct content class NullableUnionDTO { - public ?string $value = null; - - /** Nullable count using type array */ - public ?int $count = null; + public function __construct( + public ?string $value = null, + /** Nullable count using type array */ + public ?int $count = null, + ) { + } } " `; @@ -634,10 +660,13 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class ReadProductRequestDTO { - /** Product ID */ - #[Assert\\NotNull] - #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] - public string $productId; + public function __construct( + /** Product ID */ + #[Assert\\NotNull] + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $productId, + ) { + } } " `; @@ -650,11 +679,12 @@ exports[`php-dto snapshot tests > simpleSchema.json > generates correct content */ class ReadProductResponseDTO { - public string $id; - - public string $name; - - public float $price; + public function __construct( + public string $id, + public string $name, + public float $price, + ) { + } } " `; @@ -669,25 +699,23 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class SendContactMailRequestDTO { - /** Email address */ - #[Assert\\NotNull] - public string $email; - - /** The subject of the contact form. */ - #[Assert\\NotNull] - public string $subject; - - /** The message of the contact form */ - #[Assert\\NotNull] - public string $comment; - - /** Identifier of the salutation. */ - #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] - public string $salutationId; - - public string $firstName; - - public string $lastName; + public function __construct( + /** Email address */ + #[Assert\\NotNull] + public string $email, + /** The subject of the contact form. */ + #[Assert\\NotNull] + public string $subject, + /** The message of the contact form */ + #[Assert\\NotNull] + public string $comment, + /** Identifier of the salutation. */ + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $salutationId, + public string $firstName, + public string $lastName, + ) { + } } " `; @@ -704,25 +732,22 @@ use Symfony\\Component\\Validator\\Constraints as Assert; */ class CartDTO { - /** Context token identifying the cart */ - #[Assert\\NotNull] - public string $token; - - /** Name of the cart */ - public string $name; - - public CalculatedPriceDTO $price; - - /** - * @var list All items within the cart - */ - public array $lineItems; - - public int $totalItems; - - public bool $active; - - public float $taxRate; + public function __construct( + /** Context token identifying the cart */ + #[Assert\\NotNull] + public string $token, + /** Name of the cart */ + public string $name, + public CalculatedPriceDTO $price, + /** + * @var list All items within the cart + */ + public array $lineItems, + public int $totalItems, + public bool $active, + public float $taxRate, + ) { + } } " `; diff --git a/packages/api-gen/tests/php-dto/generator.test.ts b/packages/api-gen/tests/php-dto/generator.test.ts index a71f6214b..2a763c4f0 100644 --- a/packages/api-gen/tests/php-dto/generator.test.ts +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -49,11 +49,13 @@ describe("generator", () => { expect(result).toContain(" { expect(result).toContain( "use Symfony\\Component\\Validator\\Constraints as Assert;", ); - expect(result).toContain("#[Assert\\NotNull]\n public string $id;"); + expect(result).toContain( + "#[Assert\\NotNull]\n public string $id,", + ); expect(result).not.toContain( - "#[Assert\\NotNull]\n public string $label;", + "#[Assert\\NotNull]\n public string $label,", ); expect(result).not.toContain( - "#[Assert\\NotNull]\n public ?string $status", + "#[Assert\\NotNull]\n public ?string $status", ); - expect(result).toContain("public string $label;"); - expect(result).toContain("public ?string $status = null;"); + expect(result).toContain("public string $label,"); + expect(result).toContain("public ?string $status = null,"); }); it("adds Assert import when patterns exist", () => { @@ -211,7 +215,7 @@ describe("generator", () => { expect(result).toContain( "#[Assert\\Choice(choices: ['page', 'link', 'folder'])]", ); - expect(result).toContain("public string $type;"); + expect(result).toContain("public string $type,"); }); it("adds Assert\\Choice for optional enum properties", () => { @@ -234,7 +238,7 @@ describe("generator", () => { expect(result).toContain( "#[Assert\\Choice(choices: ['physical', 'digital'])]", ); - expect(result).toContain("public ?string $productType = null;"); + expect(result).toContain("public ?string $productType = null,"); }); it("adds Assert import when only enums exist (no patterns)", () => { @@ -257,7 +261,7 @@ describe("generator", () => { expect(result).toContain( "use Symfony\\Component\\Validator\\Constraints as Assert;", ); - expect(result).not.toContain("Assert\\Regex"); + expect(result).not.toContain("#[Assert\\Regex"); }); it("escapes single quotes in enum values", () => { @@ -307,8 +311,8 @@ describe("generator", () => { expect(result).not.toContain("use Symfony"); expect(result).not.toContain("Assert"); - expect(result).toContain("public string $name;"); - expect(result).toContain("public ?string $label = null;"); + expect(result).toContain("public string $name,"); + expect(result).toContain("public ?string $label = null,"); }); it("generates list PHPDoc for typed arrays with description", () => { @@ -334,7 +338,7 @@ describe("generator", () => { "* @var list All items within the cart", ); expect(result).toContain("*/"); - expect(result).toContain("public array $lineItems;"); + expect(result).toContain("public array $lineItems,"); }); it("generates list PHPDoc for typed arrays without description", () => { @@ -380,7 +384,7 @@ describe("generator", () => { expect(result).toContain("/**"); expect(result).toContain("* @var list Order line items"); expect(result).toContain("*/"); - expect(result).toContain("public ?array $items = null;"); + expect(result).toContain("public ?array $items = null,"); }); it("generates correct type hint for nested object DTO references", () => { @@ -408,10 +412,10 @@ describe("generator", () => { const result = generatePhpClass(dto); expect(result).toContain( - "public SalesChannelContextItemRoundingDTO $itemRounding;", + "public SalesChannelContextItemRoundingDTO $itemRounding,", ); expect(result).toContain( - "public ?SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup = null;", + "public ?SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup = null,", ); expect(result).toContain("/** Customer group of the current user */"); }); @@ -437,7 +441,7 @@ describe("generator", () => { expect(result).toContain( "* @var list Active tax rules", ); - expect(result).toContain("public ?array $taxRules = null;"); + expect(result).toContain("public ?array $taxRules = null,"); }); it("renders string default value", () => { @@ -456,7 +460,7 @@ describe("generator", () => { }; const result = generatePhpClass(dto); - expect(result).toContain("public string $sortOrder = 'relevance';"); + expect(result).toContain("public string $sortOrder = 'relevance',"); }); it("renders integer default value", () => { @@ -475,7 +479,7 @@ describe("generator", () => { }; const result = generatePhpClass(dto); - expect(result).toContain("public int $limit = 10;"); + expect(result).toContain("public int $limit = 10,"); }); it("renders boolean default value", () => { @@ -494,7 +498,7 @@ describe("generator", () => { }; const result = generatePhpClass(dto); - expect(result).toContain("public bool $active = true;"); + expect(result).toContain("public bool $active = true,"); }); it("renders default on nullable property instead of null", () => { @@ -513,7 +517,7 @@ describe("generator", () => { }; const result = generatePhpClass(dto); - expect(result).toContain("public ?string $mode = 'auto';"); + expect(result).toContain("public ?string $mode = 'auto',"); expect(result).not.toContain("= null"); }); @@ -532,7 +536,7 @@ describe("generator", () => { }; const result = generatePhpClass(dto); - expect(result).toContain("public ?string $label = null;"); + expect(result).toContain("public ?string $label = null,"); }); it("escapes single quotes in string default values", () => { @@ -551,7 +555,7 @@ describe("generator", () => { }; const result = generatePhpClass(dto); - expect(result).toContain("public string $greeting = 'it\\'s';"); + expect(result).toContain("public string $greeting = 'it\\'s',"); }); it("handles multiline description", () => { From 71dbfc36e9081889d273fcc26a2281b106f04b10 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:15:28 +0100 Subject: [PATCH 06/12] feat: preserve null functionality --- packages/api-gen/src/php-dto/generator.ts | 47 ++- .../__snapshots__/snapshots.test.ts.snap | 281 +++++++++--------- .../api-gen/tests/php-dto/generator.test.ts | 114 ++++++- 3 files changed, 275 insertions(+), 167 deletions(-) diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts index edfa9bc12..4e26ea7cd 100644 --- a/packages/api-gen/src/php-dto/generator.ts +++ b/packages/api-gen/src/php-dto/generator.ts @@ -19,7 +19,7 @@ function formatPhpDefault(value: string | number | boolean): string { } function hasDefault(prop: DtoProperty): boolean { - return prop.defaultValue !== undefined || prop.nullable; + return prop.defaultValue !== undefined || prop.nullable || !prop.required; } function renderConstructorParam(prop: DtoProperty): string { @@ -37,7 +37,11 @@ function renderConstructorParam(prop: DtoProperty): string { } if (prop.required && !prop.nullable) { - lines.push(" #[Assert\\NotNull]"); + if (prop.phpType === "string") { + lines.push(" #[Assert\\NotBlank]"); + } else { + lines.push(" #[Assert\\NotNull]"); + } } if (prop.pattern) { @@ -46,6 +50,10 @@ function renderConstructorParam(prop: DtoProperty): string { ); } + if (prop.nullable) { + lines.push(" #[PreserveNull]"); + } + if (prop.enum && prop.enum.length > 0) { const choices = prop.enum .map((v) => `'${escapePhpSingleQuoted(v)}'`) @@ -53,11 +61,14 @@ function renderConstructorParam(prop: DtoProperty): string { lines.push(` #[Assert\\Choice(choices: [${choices}])]`); } - const typePrefix = prop.nullable ? "?" : ""; + const needsNullFallback = + !prop.required && !prop.nullable && prop.defaultValue === undefined; + const effectiveNullable = prop.nullable || needsNullFallback; + const typePrefix = effectiveNullable ? "?" : ""; let defaultSuffix = ""; if (prop.defaultValue !== undefined) { defaultSuffix = ` = ${formatPhpDefault(prop.defaultValue)}`; - } else if (prop.nullable) { + } else if (effectiveNullable) { defaultSuffix = " = null"; } lines.push( @@ -130,12 +141,38 @@ export interface GeneratedFile { content: string; } +export function generatePreserveNullAttribute( + options: GeneratorOptions = {}, +): string { + const lines: string[] = []; + lines.push(" ({ + const dtoFiles = dtos.map((dto) => ({ fileName: dtoToFileName(dto.name), content: generatePhpClass(dto, options), })); + + return [ + { + fileName: "PreserveNull.php", + content: generatePreserveNullAttribute(options), + }, + ...dtoFiles, + ]; } diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap index 75eaa3810..706c56845 100644 --- a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -12,12 +12,12 @@ class CartCreateDTO { public function __construct( /** Name of the cart, e.g. guest-cart */ - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $name, /** * @var list Initial line items to add to the cart */ - public array $lineItems, + public ?array $lineItems = null, ) { } } @@ -36,21 +36,21 @@ class CartDTO { public function __construct( /** Name of the cart, e.g. guest-cart */ - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $name, - /** - * @var list Initial line items to add to the cart - */ - public array $lineItems, /** Unique identifier of the cart */ - #[Assert\\NotNull] + #[Assert\\NotBlank] #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] public string $id, /** Date and time the cart was created */ - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $createdAt, + /** + * @var list Initial line items to add to the cart + */ + public ?array $lineItems = null, /** Date and time the cart was last modified */ - public string $updatedAt, + public ?string $updatedAt = null, ) { } } @@ -69,12 +69,12 @@ class CreateCartRequestDTO { public function __construct( /** Name of the cart, e.g. guest-cart */ - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $name, /** * @var list Initial line items to add to the cart */ - public array $lineItems, + public ?array $lineItems = null, ) { } } @@ -93,42 +93,38 @@ class LineItemDTO { public function __construct( /** Product identifier */ - #[Assert\\NotNull] + #[Assert\\NotBlank] #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] public string $id, /** Number of items */ #[Assert\\NotNull] public int $quantity, /** Display label */ - public string $label, + public ?string $label = null, ) { } } " `; +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for PreserveNull.php 1`] = ` +" createReadEntity.json > generates correct content with namespace 1`] = ` " Initial line items to add to the cart - */ - public array $lineItems, - ) { - } } " `; @@ -139,10 +135,11 @@ exports[`php-dto snapshot tests > createReadEntity.json > generates expected fil "CartDTO.php", "CreateCartRequestDTO.php", "LineItemDTO.php", + "PreserveNull.php", ] `; -exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `4`; +exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `5`; exports[`php-dto snapshot tests > invalidNames.json > generates correct content for Api-infoRequestDTO.php 1`] = ` " invalidNames.json > generates correct content class Api-infoResponseDTO { public function __construct( - public string $version, + public ?string $version = null, ) { } } " `; +exports[`php-dto snapshot tests > invalidNames.json > generates correct content for PreserveNull.php 1`] = ` +" invalidNames.json > generates correct content for Simple-ProductDTO.php 1`] = ` " */ - public array $errors, + public ?array $errors = null, ) { } } @@ -230,13 +237,9 @@ exports[`php-dto snapshot tests > invalidNames.json > generates correct content namespace App\\DTO; -class Simple-ProductDTO +#[\\Attribute(\\Attribute::TARGET_PROPERTY)] +class PreserveNull { - public function __construct( - public string $id, - public string $name, - ) { - } } " `; @@ -245,13 +248,14 @@ exports[`php-dto snapshot tests > invalidNames.json > generates expected file na [ "Api-infoRequestDTO.php", "Api-infoResponseDTO.php", + "PreserveNull.php", "Simple-ProductDTO.php", "errorDTO.php", "errorResponseDTO.php", ] `; -exports[`php-dto snapshot tests > invalidNames.json > generates expected number of files 1`] = `5`; +exports[`php-dto snapshot tests > invalidNames.json > generates expected number of files 1`] = `6`; exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for OrderBillingAddressCountryDTO.php 1`] = ` " nestedObjects.json > generates correct content for PreserveNull.php 1`] = ` +" nestedObjects.json > generates correct content for SalesChannelContextContextDTO.php 1`] = ` " nestedObjects.json > generates correct content class SalesChannelContextContextDTO { public function __construct( - public string $versionId, - public string $currencyId, - public int $currencyFactor, - public int $currencyPrecision, + public ?string $versionId = null, + public ?string $currencyId = null, + public ?int $currencyFactor = null, + public ?int $currencyPrecision = null, /** * @var list */ - public array $languageIdChain, - public string $scope, - public SalesChannelContextContextSourceDTO $source, - public string $taxState, - public bool $useCache, + public ?array $languageIdChain = null, + public ?string $scope = null, + public ?SalesChannelContextContextSourceDTO $source = null, + public ?string $taxState = null, + public ?bool $useCache = null, ) { } } @@ -354,10 +368,10 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class SalesChannelContextContextSourceDTO { public function __construct( - #[Assert\\NotNull] + #[Assert\\NotBlank] #[Assert\\Choice(choices: ['sales-channel', 'shop-api'])] public string $type, - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $salesChannelId, ) { } @@ -375,9 +389,9 @@ class SalesChannelContextCurrentCustomerGroupDTO { public function __construct( /** Name of the group */ - public string $name, + public ?string $name = null, /** Whether prices are displayed gross */ - public bool $displayGross, + public ?bool $displayGross = null, ) { } } @@ -395,20 +409,20 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class SalesChannelContextDTO { public function __construct( - /** Context token */ - public string $token, #[Assert\\NotNull] public SalesChannelDTO $salesChannel, - /** Core context with general configuration values and state */ - public SalesChannelContextContextDTO $context, - /** Customer group of the current user */ - public SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup, #[Assert\\NotNull] public SalesChannelContextItemRoundingDTO $itemRounding, + /** Context token */ + public ?string $token = null, + /** Core context with general configuration values and state */ + public ?SalesChannelContextContextDTO $context = null, + /** Customer group of the current user */ + public ?SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup = null, /** * @var list Active tax rules */ - public array $taxRules, + public ?array $taxRules = null, ) { } } @@ -445,7 +459,7 @@ class SalesChannelContextTaxRulesDTO public function __construct( #[Assert\\NotNull] public float $taxRate, - public string $name, + public ?string $name = null, ) { } } @@ -460,10 +474,10 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class SalesChannelDTO { public function __construct( - #[Assert\\NotNull] + #[Assert\\NotBlank] #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] public string $id, - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $name, ) { } @@ -476,30 +490,9 @@ exports[`php-dto snapshot tests > nestedObjects.json > generates correct content namespace App\\DTO; -use Symfony\\Component\\Validator\\Constraints as Assert; - -/** - * Sales channel context - */ -class SalesChannelContextDTO +#[\\Attribute(\\Attribute::TARGET_PROPERTY)] +class PreserveNull { - public function __construct( - /** Context token */ - public string $token, - #[Assert\\NotNull] - public SalesChannelDTO $salesChannel, - /** Core context with general configuration values and state */ - public SalesChannelContextContextDTO $context, - /** Customer group of the current user */ - public SalesChannelContextCurrentCustomerGroupDTO $currentCustomerGroup, - #[Assert\\NotNull] - public SalesChannelContextItemRoundingDTO $itemRounding, - /** - * @var list Active tax rules - */ - public array $taxRules, - ) { - } } " `; @@ -509,6 +502,7 @@ exports[`php-dto snapshot tests > nestedObjects.json > generates expected file n "OrderBillingAddressCountryDTO.php", "OrderBillingAddressDTO.php", "OrderDTO.php", + "PreserveNull.php", "SalesChannelContextContextDTO.php", "SalesChannelContextContextSourceDTO.php", "SalesChannelContextCurrentCustomerGroupDTO.php", @@ -519,7 +513,7 @@ exports[`php-dto snapshot tests > nestedObjects.json > generates expected file n ] `; -exports[`php-dto snapshot tests > nestedObjects.json > generates expected number of files 1`] = `10`; +exports[`php-dto snapshot tests > nestedObjects.json > generates expected number of files 1`] = `11`; exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for CalculatedPriceDTO.php 1`] = ` " simpleSchema.json > generates correct content class CalculatedPriceDTO { public function __construct( - public float $unitPrice, - public float $totalPrice, + public ?float $unitPrice = null, + public ?float $totalPrice = null, ) { } } @@ -547,18 +541,18 @@ class CartDTO { public function __construct( /** Context token identifying the cart */ - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $token, /** Name of the cart */ - public string $name, - public CalculatedPriceDTO $price, + public ?string $name = null, + public ?CalculatedPriceDTO $price = null, /** * @var list All items within the cart */ - public array $lineItems, - public int $totalItems, - public bool $active, - public float $taxRate, + public ?array $lineItems = null, + public ?int $totalItems = null, + public ?bool $active = null, + public ?float $taxRate = null, ) { } } @@ -573,7 +567,7 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class DefaultValuesDTO { public function __construct( - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $query, public int $limit = 10, public string $sortOrder = 'relevance', @@ -598,12 +592,12 @@ use Symfony\\Component\\Validator\\Constraints as Assert; class LineItemDTO { public function __construct( - #[Assert\\NotNull] + #[Assert\\NotBlank] #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] public string $id, #[Assert\\NotNull] public int $quantity, - public string $label, + public ?string $label = null, ) { } } @@ -622,16 +616,16 @@ class NavigationTypeDTO { public function __construct( /** Type of the navigation entry */ - #[Assert\\NotNull] + #[Assert\\NotBlank] #[Assert\\Choice(choices: ['page', 'link', 'folder'])] public string $type, /** Route name for the navigation */ - #[Assert\\NotNull] + #[Assert\\NotBlank] #[Assert\\Choice(choices: ['frontend.navigation.page', 'frontend.landing.page', 'frontend.detail.page'])] public string $routeName, /** Type of the link if type is link */ #[Assert\\Choice(choices: ['external', 'category', 'product', 'landing_page'])] - public string $linkType, + public ?string $linkType = null, ) { } } @@ -644,8 +638,10 @@ exports[`php-dto snapshot tests > simpleSchema.json > generates correct content class NullableUnionDTO { public function __construct( + #[PreserveNull] public ?string $value = null, /** Nullable count using type array */ + #[PreserveNull] public ?int $count = null, ) { } @@ -653,6 +649,16 @@ class NullableUnionDTO " `; +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for PreserveNull.php 1`] = ` +" simpleSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` " simpleSchema.json > generates correct content class ReadProductResponseDTO { public function __construct( - public string $id, - public string $name, - public float $price, + public ?string $id = null, + public ?string $name = null, + public ?float $price = null, ) { } } @@ -701,19 +707,19 @@ class SendContactMailRequestDTO { public function __construct( /** Email address */ - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $email, /** The subject of the contact form. */ - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $subject, /** The message of the contact form */ - #[Assert\\NotNull] + #[Assert\\NotBlank] public string $comment, /** Identifier of the salutation. */ #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] - public string $salutationId, - public string $firstName, - public string $lastName, + public ?string $salutationId = null, + public ?string $firstName = null, + public ?string $lastName = null, ) { } } @@ -725,29 +731,9 @@ exports[`php-dto snapshot tests > simpleSchema.json > generates correct content namespace App\\DTO; -use Symfony\\Component\\Validator\\Constraints as Assert; - -/** - * Shopping cart - */ -class CartDTO +#[\\Attribute(\\Attribute::TARGET_PROPERTY)] +class PreserveNull { - public function __construct( - /** Context token identifying the cart */ - #[Assert\\NotNull] - public string $token, - /** Name of the cart */ - public string $name, - public CalculatedPriceDTO $price, - /** - * @var list All items within the cart - */ - public array $lineItems, - public int $totalItems, - public bool $active, - public float $taxRate, - ) { - } } " `; @@ -760,10 +746,11 @@ exports[`php-dto snapshot tests > simpleSchema.json > generates expected file na "LineItemDTO.php", "NavigationTypeDTO.php", "NullableUnionDTO.php", + "PreserveNull.php", "ReadProductRequestDTO.php", "ReadProductResponseDTO.php", "SendContactMailRequestDTO.php", ] `; -exports[`php-dto snapshot tests > simpleSchema.json > generates expected number of files 1`] = `9`; +exports[`php-dto snapshot tests > simpleSchema.json > generates expected number of files 1`] = `10`; diff --git a/packages/api-gen/tests/php-dto/generator.test.ts b/packages/api-gen/tests/php-dto/generator.test.ts index 2a763c4f0..42aaa392b 100644 --- a/packages/api-gen/tests/php-dto/generator.test.ts +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -3,6 +3,7 @@ import { dtoToFileName, generateAllFiles, generatePhpClass, + generatePreserveNullAttribute, } from "../../src/php-dto/generator"; import type { DtoDefinition } from "../../src/php-dto/schemaParser"; @@ -51,9 +52,9 @@ describe("generator", () => { expect(result).toContain(" * Contact form request"); expect(result).toContain("public function __construct("); expect(result).toContain("/** Email address */"); - expect(result).toContain("#[Assert\\NotNull]"); + expect(result).toContain("#[Assert\\NotBlank]"); expect(result).toContain("public string $email,"); - expect(result).toContain("public string $firstName,"); + expect(result).toContain("public ?string $firstName = null,"); expect(result).toContain("public ?string $nickname = null,"); expect(result).toContain(" ) {"); expect(result).not.toContain("namespace"); @@ -78,7 +79,7 @@ describe("generator", () => { expect(result).toContain("namespace App\\DTO;"); }); - it("adds Assert\\NotNull for required non-nullable properties", () => { + it("uses NotBlank for required strings, NotNull for other required types", () => { const dto: DtoDefinition = { name: "TestDTO", properties: [ @@ -89,6 +90,13 @@ describe("generator", () => { required: true, isArray: false, }, + { + name: "count", + phpType: "int", + nullable: false, + required: true, + isArray: false, + }, { name: "label", phpType: "string", @@ -112,15 +120,18 @@ describe("generator", () => { "use Symfony\\Component\\Validator\\Constraints as Assert;", ); expect(result).toContain( - "#[Assert\\NotNull]\n public string $id,", + "#[Assert\\NotBlank]\n public string $id,", + ); + expect(result).toContain( + "#[Assert\\NotNull]\n public int $count,", ); expect(result).not.toContain( - "#[Assert\\NotNull]\n public string $label,", + "#[Assert\\NotBlank]\n public ?string $label", ); expect(result).not.toContain( - "#[Assert\\NotNull]\n public ?string $status", + "#[Assert\\NotBlank]\n public ?string $status", ); - expect(result).toContain("public string $label,"); + expect(result).toContain("public ?string $label = null,"); expect(result).toContain("public ?string $status = null,"); }); @@ -311,7 +322,7 @@ describe("generator", () => { expect(result).not.toContain("use Symfony"); expect(result).not.toContain("Assert"); - expect(result).toContain("public string $name,"); + expect(result).toContain("public ?string $name = null,"); expect(result).toContain("public ?string $label = null,"); }); @@ -558,6 +569,57 @@ describe("generator", () => { expect(result).toContain("public string $greeting = 'it\\'s',"); }); + it("adds PreserveNull for schema-nullable properties", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "title", + phpType: "string", + nullable: true, + required: false, + isArray: false, + }, + { + name: "label", + phpType: "string", + nullable: false, + required: false, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "#[PreserveNull]\n public ?string $title", + ); + expect(result).not.toContain( + "#[PreserveNull]\n public ?string $label", + ); + }); + + it("does not add PreserveNull for optional-only nullable fallback", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "name", + phpType: "string", + nullable: false, + required: false, + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain("public ?string $name = null,"); + expect(result).not.toContain("#[PreserveNull]"); + }); + it("handles multiline description", () => { const dto: DtoDefinition = { name: "TestDTO", @@ -580,8 +642,28 @@ describe("generator", () => { }); }); + describe("generatePreserveNullAttribute", () => { + it("generates the attribute class", () => { + const result = generatePreserveNullAttribute(); + + expect(result).toContain(" { + const result = generatePreserveNullAttribute({ + namespace: "App\\DTO", + }); + + expect(result).toContain("namespace App\\DTO;"); + expect(result).toContain("class PreserveNull"); + }); + }); + describe("generateAllFiles", () => { - it("generates one file per DTO", () => { + it("includes PreserveNull attribute file and one file per DTO", () => { const dtos: DtoDefinition[] = [ { name: "ProductDTO", @@ -611,14 +693,14 @@ describe("generator", () => { const files = generateAllFiles(dtos); - expect(files).toHaveLength(2); - expect(files[0]?.fileName).toBe("ProductDTO.php"); - expect(files[1]?.fileName).toBe("CartDTO.php"); - expect(files[0]?.content).toContain("class ProductDTO"); - expect(files[1]?.content).toContain("class CartDTO"); + expect(files).toHaveLength(3); + expect(files[0]?.fileName).toBe("PreserveNull.php"); + expect(files[0]?.content).toContain("class PreserveNull"); + expect(files[1]?.fileName).toBe("ProductDTO.php"); + expect(files[2]?.fileName).toBe("CartDTO.php"); }); - it("passes namespace to all generated files", () => { + it("passes namespace to all generated files including PreserveNull", () => { const dtos: DtoDefinition[] = [ { name: "TestDTO", @@ -636,7 +718,9 @@ describe("generator", () => { const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); + expect(files[0]?.fileName).toBe("PreserveNull.php"); expect(files[0]?.content).toContain("namespace App\\DTO;"); + expect(files[1]?.content).toContain("namespace App\\DTO;"); }); }); }); From 95648bf25ca376460fc592b0db5a243000b2c752 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:00:18 +0100 Subject: [PATCH 07/12] feat: generate by tag name and imports --- packages/api-gen/src/cli.ts | 6 + packages/api-gen/src/commands/phpDto.ts | 27 +- packages/api-gen/src/php-dto/generator.ts | 113 +++- packages/api-gen/src/php-dto/openApiTypes.ts | 26 +- packages/api-gen/src/php-dto/schemaParser.ts | 156 ++++- .../__snapshots__/snapshots.test.ts.snap | 619 +++++++++++++++--- .../tests/php-dto/fixtures/tagSchema.json | 168 +++++ .../api-gen/tests/php-dto/generator.test.ts | 196 +++++- packages/api-gen/tests/php-dto/phpDto.test.ts | 113 +++- .../tests/php-dto/schemaParser.test.ts | 74 ++- 10 files changed, 1341 insertions(+), 157 deletions(-) create mode 100644 packages/api-gen/tests/php-dto/fixtures/tagSchema.json diff --git a/packages/api-gen/src/cli.ts b/packages/api-gen/src/cli.ts index 7a429a03a..9fe9907dc 100644 --- a/packages/api-gen/src/cli.ts +++ b/packages/api-gen/src/cli.ts @@ -180,6 +180,12 @@ yargs(hideBin(process.argv)) default: false, describe: "skip auto-converting class names to PascalCase; fail on invalid names instead", + }) + .option("tag", { + alias: "t", + type: "string", + describe: + "only generate DTOs for endpoints with this tag (and their referenced schemas)", }); }, async (args) => phpDto(args as unknown as PhpDtoOptions), diff --git a/packages/api-gen/src/commands/phpDto.ts b/packages/api-gen/src/commands/phpDto.ts index 4fe319bb5..b83639b22 100644 --- a/packages/api-gen/src/commands/phpDto.ts +++ b/packages/api-gen/src/commands/phpDto.ts @@ -4,9 +4,10 @@ import { readFileSync, readdirSync, rmSync, + statSync, writeFileSync, } from "node:fs"; -import { resolve } from "node:path"; +import { dirname, relative, resolve } from "node:path"; import pc from "picocolors"; import { generateAllFiles } from "../php-dto/generator"; import type { GeneratorOptions } from "../php-dto/generator"; @@ -21,6 +22,7 @@ export interface PhpDtoOptions { outputDir: string; namespace?: string; rawNames?: boolean; + tag?: string; cwd?: string; } @@ -63,7 +65,7 @@ function validateDtoNames(dtos: DtoDefinition[]): void { } export async function phpDto(options: PhpDtoOptions): Promise { - const { action, schemaFile, outputDir, namespace, rawNames } = options; + const { action, schemaFile, outputDir, namespace, rawNames, tag } = options; const cwd = options.cwd || process.cwd(); const outputPath = resolve(cwd, outputDir); @@ -72,7 +74,7 @@ export async function phpDto(options: PhpDtoOptions): Promise { if (!schema) { throw new Error(`Schema file not found: ${schemaPath}`); } - const rawDtos = parseAllDtos(schema); + const rawDtos = parseAllDtos(schema, { tag }); if (rawDtos.length === 0) { console.log(pc.yellow("No DTO definitions found in the schema.")); @@ -108,6 +110,7 @@ async function runGenerate( for (const file of files) { const filePath = resolve(outputPath, file.fileName); + mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, file.content, "utf-8"); } @@ -116,6 +119,20 @@ async function runGenerate( ); } +function collectPhpFiles(dir: string, base: string): string[] { + const results: string[] = []; + if (!existsSync(dir)) return results; + for (const entry of readdirSync(dir)) { + const fullPath = resolve(dir, entry); + if (statSync(fullPath).isDirectory()) { + results.push(...collectPhpFiles(fullPath, base)); + } else if (entry.endsWith(".php")) { + results.push(relative(base, fullPath)); + } + } + return results; +} + async function runCheck( outputPath: string, files: { fileName: string; content: string }[], @@ -127,9 +144,7 @@ async function runCheck( process.exit(1); } - const existingFiles = new Set( - readdirSync(outputPath).filter((f) => f.endsWith(".php")), - ); + const existingFiles = new Set(collectPhpFiles(outputPath, outputPath)); const expectedFiles = new Set(files.map((f) => f.fileName)); for (const fileName of expectedFiles) { diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts index 4e26ea7cd..3744e9d92 100644 --- a/packages/api-gen/src/php-dto/generator.ts +++ b/packages/api-gen/src/php-dto/generator.ts @@ -1,7 +1,10 @@ -import type { DtoDefinition, DtoProperty } from "./schemaParser"; +import type { DtoDefinition, DtoProperty, DtoSource } from "./schemaParser"; export interface GeneratorOptions { namespace?: string; + /** Original base namespace before shared/ resolution, used for computing use statements */ + baseNamespace?: string; + dtoSourceMap?: Map; } function escapePhpDocComment(text: string): string { @@ -22,6 +25,65 @@ function hasDefault(prop: DtoProperty): boolean { return prop.defaultValue !== undefined || prop.nullable || !prop.required; } +const PHP_PRIMITIVE_TYPES = new Set([ + "string", + "int", + "float", + "bool", + "array", + "mixed", +]); + +function resolveNamespace( + baseNamespace: string | undefined, + source: DtoSource, +): string | undefined { + if (source === "component") { + return baseNamespace ? `${baseNamespace}\\Shared` : "Shared"; + } + return baseNamespace; +} + +function collectReferencedDtoNames(properties: DtoProperty[]): Set { + const refs = new Set(); + for (const prop of properties) { + if (!PHP_PRIMITIVE_TYPES.has(prop.phpType)) { + refs.add(prop.phpType); + } + if (prop.arrayItemType && !PHP_PRIMITIVE_TYPES.has(prop.arrayItemType)) { + refs.add(prop.arrayItemType); + } + } + return refs; +} + +function buildUseStatements( + baseNamespace: string | undefined, + referencedNames: Set, + dtoSourceMap: Map, + usesPreserveNull: boolean, +): string[] { + const imports: string[] = []; + + if (usesPreserveNull) { + const preserveNullNs = baseNamespace; + const fqcn = preserveNullNs + ? `${preserveNullNs}\\PreserveNull` + : "PreserveNull"; + imports.push(fqcn); + } + + for (const name of referencedNames) { + const refSource = dtoSourceMap.get(name) ?? "component"; + const refNs = resolveNamespace(baseNamespace, refSource); + const fqcn = refNs ? `${refNs}\\${name}` : name; + imports.push(fqcn); + } + + imports.sort(); + return imports; +} + function renderConstructorParam(prop: DtoProperty): string { const lines: string[] = []; const hasTypedArray = prop.isArray && prop.arrayItemType; @@ -96,8 +158,31 @@ export function generatePhpClass( lines.push(""); } + const useLines: string[] = []; + if (needsAssert) { - lines.push("use Symfony\\Component\\Validator\\Constraints as Assert;"); + useLines.push("use Symfony\\Component\\Validator\\Constraints as Assert;"); + } + + if (options.dtoSourceMap) { + const usesPreserveNull = dto.properties.some((p) => p.nullable); + const referencedNames = collectReferencedDtoNames(dto.properties); + const imports = buildUseStatements( + options.baseNamespace, + referencedNames, + options.dtoSourceMap, + usesPreserveNull, + ); + for (const fqcn of imports) { + useLines.push(`use ${fqcn};`); + } + } + + if (useLines.length > 0) { + useLines.sort(); + for (const line of useLines) { + lines.push(line); + } lines.push(""); } @@ -163,10 +248,26 @@ export function generateAllFiles( dtos: DtoDefinition[], options: GeneratorOptions = {}, ): GeneratedFile[] { - const dtoFiles = dtos.map((dto) => ({ - fileName: dtoToFileName(dto.name), - content: generatePhpClass(dto, options), - })); + const dtoSourceMap = new Map(); + for (const dto of dtos) { + dtoSourceMap.set(dto.name, dto.source ?? "component"); + } + + const dtoFiles = dtos.map((dto) => { + const source = dto.source ?? "component"; + const dir = source === "component" ? "shared/" : ""; + const effectiveNs = resolveNamespace(options.namespace, source); + const fileOptions: GeneratorOptions = { + ...options, + namespace: effectiveNs, + baseNamespace: options.namespace, + dtoSourceMap, + }; + return { + fileName: `${dir}${dtoToFileName(dto.name)}`, + content: generatePhpClass(dto, fileOptions), + }; + }); return [ { diff --git a/packages/api-gen/src/php-dto/openApiTypes.ts b/packages/api-gen/src/php-dto/openApiTypes.ts index 6dea2c437..c2b6d998b 100644 --- a/packages/api-gen/src/php-dto/openApiTypes.ts +++ b/packages/api-gen/src/php-dto/openApiTypes.ts @@ -23,7 +23,19 @@ export interface ParameterObject { schema?: SchemaObject; } +export interface ResponseObject { + $ref?: string; + description?: string; + content?: Record< + string, + { + schema?: SchemaObject; + } + >; +} + export interface OperationObject { + tags?: string[]; operationId?: string; summary?: string; description?: string; @@ -37,23 +49,13 @@ export interface OperationObject { } >; }; - responses?: Record< - string, - { - description?: string; - content?: Record< - string, - { - schema?: SchemaObject; - } - >; - } - >; + responses?: Record; } export interface OpenApiSchema { components?: { schemas?: Record; + responses?: Record; }; paths?: Record>; } diff --git a/packages/api-gen/src/php-dto/schemaParser.ts b/packages/api-gen/src/php-dto/schemaParser.ts index 8327672a3..f9f80b334 100644 --- a/packages/api-gen/src/php-dto/schemaParser.ts +++ b/packages/api-gen/src/php-dto/schemaParser.ts @@ -1,6 +1,7 @@ import type { OpenApiSchema, OperationObject, + ResponseObject, SchemaObject, } from "./openApiTypes"; import { @@ -25,10 +26,13 @@ export interface DtoProperty { arrayItemType?: string; } +export type DtoSource = "operation" | "component"; + export interface DtoDefinition { name: string; description?: string; properties: DtoProperty[]; + source?: DtoSource; } type SchemaRegistry = Record; @@ -60,6 +64,18 @@ function dereferenceSchema( return schema; } +function dereferenceResponse( + response: ResponseObject, + responseRegistry: Record, +): ResponseObject { + if (response.$ref) { + const refName = resolveRefName(response.$ref); + const resolved = responseRegistry[refName]; + if (resolved) return resolved; + } + return response; +} + function resolveDefaultValue( schema: SchemaObject, ): string | number | boolean | undefined { @@ -84,6 +100,38 @@ function resolveDefaultValue( return undefined; } +function collectReferencedSchemas( + schema: SchemaObject, + registry: SchemaRegistry, + visited: Set, +): void { + if (schema.$ref) { + const name = resolveRefName(schema.$ref); + if (visited.has(name)) return; + visited.add(name); + const resolved = registry[name]; + if (resolved) collectReferencedSchemas(resolved, registry, visited); + } + if (schema.properties) { + for (const prop of Object.values(schema.properties)) { + collectReferencedSchemas(prop, registry, visited); + } + } + if (schema.items) collectReferencedSchemas(schema.items, registry, visited); + if (schema.allOf) { + for (const s of schema.allOf) + collectReferencedSchemas(s, registry, visited); + } + if (schema.oneOf) { + for (const s of schema.oneOf) + collectReferencedSchemas(s, registry, visited); + } + if (schema.anyOf) { + for (const s of schema.anyOf) + collectReferencedSchemas(s, registry, visited); + } +} + function isInlineObject(schema: SchemaObject): boolean { return ( getSchemaType(schema) === "object" && @@ -138,6 +186,7 @@ function extractPropertiesFromSchema( name: nestedName, description: propSchema.description, properties: nested.properties, + source: "component", }); nestedDtos.push(...nested.nestedDtos); @@ -177,6 +226,7 @@ function extractPropertiesFromSchema( name: nestedName, description: propSchema.items.description, properties: nested.properties, + source: "component", }); nestedDtos.push(...nested.nestedDtos); @@ -245,6 +295,7 @@ function extractDtoFromSchema( schema: SchemaObject, registry: SchemaRegistry, description?: string, + source: DtoSource = "component", ): DtoDefinition[] { const resolved = resolveSchemaProperties(schema, registry); @@ -264,12 +315,16 @@ function extractDtoFromSchema( name, description: description || schema.description, properties, + source, }, ...nestedDtos, ]; } -export function parseComponentSchemas(schema: OpenApiSchema): DtoDefinition[] { +export function parseComponentSchemas( + schema: OpenApiSchema, + schemaFilter?: Set, +): DtoDefinition[] { const dtos: DtoDefinition[] = []; const components = schema.components?.schemas; const registry: SchemaRegistry = components || {}; @@ -277,6 +332,8 @@ export function parseComponentSchemas(schema: OpenApiSchema): DtoDefinition[] { if (!components) return dtos; for (const [schemaName, schemaObj] of Object.entries(components)) { + if (schemaFilter && !schemaFilter.has(schemaName)) continue; + const extracted = extractDtoFromSchema( toDtoClassName(schemaName), schemaObj, @@ -288,7 +345,10 @@ export function parseComponentSchemas(schema: OpenApiSchema): DtoDefinition[] { return dtos; } -export function parseRequestBodies(schema: OpenApiSchema): DtoDefinition[] { +export function parseRequestBodies( + schema: OpenApiSchema, + operationFilter?: Set, +): DtoDefinition[] { const dtos: DtoDefinition[] = []; const paths = schema.paths; const registry: SchemaRegistry = schema.components?.schemas || {}; @@ -299,6 +359,8 @@ export function parseRequestBodies(schema: OpenApiSchema): DtoDefinition[] { for (const method of HTTP_METHODS) { const operation = pathMethods[method] as OperationObject | undefined; if (!operation?.operationId) continue; + if (operationFilter && !operationFilter.has(operation.operationId)) + continue; const dtoName = `${capitalizeFirst(operation.operationId)}RequestDTO`; @@ -356,6 +418,7 @@ export function parseRequestBodies(schema: OpenApiSchema): DtoDefinition[] { name: dtoName, description: bodyDescription || operation.description, properties: allProperties, + source: "operation", }); dtos.push(...bodyNestedDtos); } @@ -364,10 +427,14 @@ export function parseRequestBodies(schema: OpenApiSchema): DtoDefinition[] { return dtos; } -export function parseResponseBodies(schema: OpenApiSchema): DtoDefinition[] { +export function parseResponseBodies( + schema: OpenApiSchema, + operationFilter?: Set, +): DtoDefinition[] { const dtos: DtoDefinition[] = []; const paths = schema.paths; const registry: SchemaRegistry = schema.components?.schemas || {}; + const responseRegistry = schema.components?.responses || {}; if (!paths) return dtos; @@ -375,24 +442,33 @@ export function parseResponseBodies(schema: OpenApiSchema): DtoDefinition[] { for (const method of HTTP_METHODS) { const operation = pathMethods[method] as OperationObject | undefined; if (!operation?.operationId) continue; + if (operationFilter && !operationFilter.has(operation.operationId)) + continue; const responses = operation.responses; if (!responses) continue; - const successResponse = responses["200"] || responses["201"]; - if (!successResponse?.content) continue; + const rawSuccessResponse = responses["200"] || responses["201"]; + if (!rawSuccessResponse) continue; + + const successResponse = dereferenceResponse( + rawSuccessResponse, + responseRegistry, + ); + if (!successResponse.content) continue; - const responseSchema = + const rawResponseSchema = successResponse.content["application/json"]?.schema; - if (!responseSchema) continue; + if (!rawResponseSchema) continue; - if (responseSchema.$ref) continue; + const responseSchema = dereferenceSchema(rawResponseSchema, registry); const extracted = extractDtoFromSchema( `${capitalizeFirst(operation.operationId)}ResponseDTO`, responseSchema, registry, successResponse.description, + "operation", ); dtos.push(...extracted); @@ -402,7 +478,69 @@ export function parseResponseBodies(schema: OpenApiSchema): DtoDefinition[] { return dtos; } -export function parseAllDtos(schema: OpenApiSchema): DtoDefinition[] { +export interface ParseOptions { + tag?: string; +} + +function collectTagDependencies( + schema: OpenApiSchema, + tag: string, +): { operationIds: Set; schemaNames: Set } { + const operationIds = new Set(); + const schemaNames = new Set(); + const registry: SchemaRegistry = schema.components?.schemas || {}; + const responseRegistry = schema.components?.responses || {}; + + for (const pathMethods of Object.values(schema.paths || {})) { + for (const method of HTTP_METHODS) { + const op = pathMethods[method] as OperationObject | undefined; + if (!op?.operationId || !op.tags?.includes(tag)) continue; + + operationIds.add(op.operationId); + + const reqSchema = op.requestBody?.content?.["application/json"]?.schema; + if (reqSchema) collectReferencedSchemas(reqSchema, registry, schemaNames); + + if (op.parameters) { + for (const param of op.parameters) { + if (param.schema) { + collectReferencedSchemas(param.schema, registry, schemaNames); + } + } + } + + const responses = op.responses; + if (responses) { + const rawSuccess = responses["200"] || responses["201"]; + if (rawSuccess) { + const success = dereferenceResponse(rawSuccess, responseRegistry); + const resSchema = success.content?.["application/json"]?.schema; + if (resSchema) { + collectReferencedSchemas(resSchema, registry, schemaNames); + } + } + } + } + } + + return { operationIds, schemaNames }; +} + +export function parseAllDtos( + schema: OpenApiSchema, + options?: ParseOptions, +): DtoDefinition[] { + if (options?.tag) { + const { operationIds, schemaNames } = collectTagDependencies( + schema, + options.tag, + ); + const components = parseComponentSchemas(schema, schemaNames); + const requestBodies = parseRequestBodies(schema, operationIds); + const responseBodies = parseResponseBodies(schema, operationIds); + return [...components, ...requestBodies, ...responseBodies]; + } + const components = parseComponentSchemas(schema); const requestBodies = parseRequestBodies(schema); const responseBodies = parseResponseBodies(schema); diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap index 706c56845..b5e0f3fdd 100644 --- a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -1,14 +1,15 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CartCreateDTO.php 1`] = ` +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CreateCartRequestDTO.php 1`] = ` " createReadEntity.json > generates correct content for CartDTO.php 1`] = ` +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CreateCartResponseDTO.php 1`] = ` " createReadEntity.json > generates correct content for CreateCartRequestDTO.php 1`] = ` +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for PreserveNull.php 1`] = ` +" createReadEntity.json > generates correct content for ReadCartResponseDTO.php 1`] = ` +" Initial line items to add to the cart + */ + public ?array $lineItems = null, + /** Date and time the cart was last modified */ + public ?string $updatedAt = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for shared/CartCreateDTO.php 1`] = ` " Initial line items to add to the cart + */ + public ?array $lineItems = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for shared/CartDTO.php 1`] = ` +" Initial line items to add to the cart */ public ?array $lineItems = null, + /** Date and time the cart was last modified */ + public ?string $updatedAt = null, ) { } } " `; -exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for LineItemDTO.php 1`] = ` +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for shared/LineItemDTO.php 1`] = ` " createReadEntity.json > generates correct content for PreserveNull.php 1`] = ` -" createReadEntity.json > generates correct content with namespace 1`] = ` " createReadEntity.json > generates expected file names 1`] = ` [ - "CartCreateDTO.php", - "CartDTO.php", "CreateCartRequestDTO.php", - "LineItemDTO.php", + "CreateCartResponseDTO.php", "PreserveNull.php", + "ReadCartResponseDTO.php", + "shared/CartCreateDTO.php", + "shared/CartDTO.php", + "shared/LineItemDTO.php", ] `; -exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `5`; +exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `7`; exports[`php-dto snapshot tests > invalidNames.json > generates correct content for Api-infoRequestDTO.php 1`] = ` " invalidNames.json > generates correct content for Simple-ProductDTO.php 1`] = ` +exports[`php-dto snapshot tests > invalidNames.json > generates correct content for shared/Simple-ProductDTO.php 1`] = ` " invalidNames.json > generates correct content for errorDTO.php 1`] = ` +exports[`php-dto snapshot tests > invalidNames.json > generates correct content for shared/errorDTO.php 1`] = ` " invalidNames.json > generates correct content for errorResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > invalidNames.json > generates correct content for shared/errorResponseDTO.php 1`] = ` " invalidNames.json > generates expected file na "Api-infoRequestDTO.php", "Api-infoResponseDTO.php", "PreserveNull.php", - "Simple-ProductDTO.php", - "errorDTO.php", - "errorResponseDTO.php", + "shared/Simple-ProductDTO.php", + "shared/errorDTO.php", + "shared/errorResponseDTO.php", ] `; exports[`php-dto snapshot tests > invalidNames.json > generates expected number of files 1`] = `6`; -exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for OrderBillingAddressCountryDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for PreserveNull.php 1`] = ` +" nestedObjects.json > generates correct content for shared/OrderBillingAddressCountryDTO.php 1`] = ` " nestedObjects.json > generates correct content for OrderBillingAddressDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/OrderBillingAddressDTO.php 1`] = ` " nestedObjects.json > generates correct content for OrderDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/OrderDTO.php 1`] = ` " nestedObjects.json > generates correct content for PreserveNull.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/SalesChannelContextContextDTO.php 1`] = ` " nestedObjects.json > generates correct content for SalesChannelContextContextDTO.php 1`] = ` -" nestedObjects.json > generates correct content for SalesChannelContextContextSourceDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/SalesChannelContextContextSourceDTO.php 1`] = ` " nestedObjects.json > generates correct content for SalesChannelContextCurrentCustomerGroupDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/SalesChannelContextCurrentCustomerGroupDTO.php 1`] = ` " nestedObjects.json > generates correct content for SalesChannelContextDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/SalesChannelContextDTO.php 1`] = ` " nestedObjects.json > generates correct content for SalesChannelContextItemRoundingDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/SalesChannelContextItemRoundingDTO.php 1`] = ` " nestedObjects.json > generates correct content for SalesChannelContextTaxRulesDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/SalesChannelContextTaxRulesDTO.php 1`] = ` " nestedObjects.json > generates correct content for SalesChannelDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/SalesChannelDTO.php 1`] = ` " nestedObjects.json > generates expected file names 1`] = ` [ - "OrderBillingAddressCountryDTO.php", - "OrderBillingAddressDTO.php", - "OrderDTO.php", "PreserveNull.php", - "SalesChannelContextContextDTO.php", - "SalesChannelContextContextSourceDTO.php", - "SalesChannelContextCurrentCustomerGroupDTO.php", - "SalesChannelContextDTO.php", - "SalesChannelContextItemRoundingDTO.php", - "SalesChannelContextTaxRulesDTO.php", - "SalesChannelDTO.php", + "shared/OrderBillingAddressCountryDTO.php", + "shared/OrderBillingAddressDTO.php", + "shared/OrderDTO.php", + "shared/SalesChannelContextContextDTO.php", + "shared/SalesChannelContextContextSourceDTO.php", + "shared/SalesChannelContextCurrentCustomerGroupDTO.php", + "shared/SalesChannelContextDTO.php", + "shared/SalesChannelContextItemRoundingDTO.php", + "shared/SalesChannelContextTaxRulesDTO.php", + "shared/SalesChannelDTO.php", ] `; exports[`php-dto snapshot tests > nestedObjects.json > generates expected number of files 1`] = `11`; -exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for CalculatedPriceDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for PreserveNull.php 1`] = ` +" simpleSchema.json > generates correct content for ReadCartResponseDTO.php 1`] = ` +" All items within the cart + */ + public ?array $lineItems = null, + public ?int $totalItems = null, + public ?bool $active = null, + public ?float $taxRate = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` +" simpleSchema.json > generates correct content for ReadProductResponseDTO.php 1`] = ` +" simpleSchema.json > generates correct content for SendContactMailRequestDTO.php 1`] = ` +" simpleSchema.json > generates correct content for shared/CalculatedPriceDTO.php 1`] = ` " simpleSchema.json > generates correct content for CartDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for shared/CartDTO.php 1`] = ` " simpleSchema.json > generates correct content for DefaultValuesDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for shared/DefaultValuesDTO.php 1`] = ` " simpleSchema.json > generates correct content for LineItemDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for shared/LineItemDTO.php 1`] = ` " simpleSchema.json > generates correct content for NavigationTypeDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for shared/NavigationTypeDTO.php 1`] = ` " simpleSchema.json > generates correct content for NullableUnionDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for shared/NullableUnionDTO.php 1`] = ` " simpleSchema.json > generates correct content for PreserveNull.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content with namespace 1`] = ` " simpleSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates expected file names 1`] = ` +[ + "PreserveNull.php", + "ReadCartResponseDTO.php", + "ReadProductRequestDTO.php", + "ReadProductResponseDTO.php", + "SendContactMailRequestDTO.php", + "shared/CalculatedPriceDTO.php", + "shared/CartDTO.php", + "shared/DefaultValuesDTO.php", + "shared/LineItemDTO.php", + "shared/NavigationTypeDTO.php", + "shared/NullableUnionDTO.php", +] +`; + +exports[`php-dto snapshot tests > simpleSchema.json > generates expected number of files 1`] = `11`; + +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for AddLineItemRequestDTO.php 1`] = ` " simpleSchema.json > generates correct content for ReadProductResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for AddLineItemResponseDTO.php 1`] = ` " + */ + public ?array $lineItems = null, ) { } } " `; -exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for SendContactMailRequestDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for PreserveNull.php 1`] = ` +" tagSchema.json > generates correct content for ReadCartResponseDTO.php 1`] = ` " + */ + public ?array $lineItems = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for ReadCategoriesResponseDTO.php 1`] = ` +" + */ + public ?array $elements = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` +" tagSchema.json > generates correct content for ReadProductResponseDTO.php 1`] = ` +" tagSchema.json > generates correct content for shared/CartDTO.php 1`] = ` +" + */ + public ?array $lineItems = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for shared/CategoryDTO.php 1`] = ` +" tagSchema.json > generates correct content for shared/LineItemDTO.php 1`] = ` +" simpleSchema.json > generates correct content with namespace 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for shared/MediaDTO.php 1`] = ` +" tagSchema.json > generates correct content for shared/ProductDTO.php 1`] = ` +" tagSchema.json > generates correct content with namespace 1`] = ` " simpleSchema.json > generates expected file names 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates expected file names 1`] = ` [ - "CalculatedPriceDTO.php", - "CartDTO.php", - "DefaultValuesDTO.php", - "LineItemDTO.php", - "NavigationTypeDTO.php", - "NullableUnionDTO.php", + "AddLineItemRequestDTO.php", + "AddLineItemResponseDTO.php", "PreserveNull.php", + "ReadCartResponseDTO.php", + "ReadCategoriesResponseDTO.php", "ReadProductRequestDTO.php", "ReadProductResponseDTO.php", - "SendContactMailRequestDTO.php", + "shared/CartDTO.php", + "shared/CategoryDTO.php", + "shared/LineItemDTO.php", + "shared/MediaDTO.php", + "shared/ProductDTO.php", ] `; -exports[`php-dto snapshot tests > simpleSchema.json > generates expected number of files 1`] = `10`; +exports[`php-dto snapshot tests > tagSchema.json > generates expected number of files 1`] = `12`; diff --git a/packages/api-gen/tests/php-dto/fixtures/tagSchema.json b/packages/api-gen/tests/php-dto/fixtures/tagSchema.json new file mode 100644 index 000000000..4b8aa2db8 --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/tagSchema.json @@ -0,0 +1,168 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Tag fixture", "version": "1.0.0" }, + "components": { + "schemas": { + "Product": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "name": { + "type": "string" + }, + "cover": { + "$ref": "#/components/schemas/Media" + } + } + }, + "Media": { + "type": "object", + "properties": { + "url": { "type": "string" }, + "alt": { "type": "string" } + } + }, + "Cart": { + "type": "object", + "required": ["token"], + "properties": { + "token": { "type": "string" }, + "lineItems": { + "type": "array", + "items": { "$ref": "#/components/schemas/LineItem" } + } + } + }, + "LineItem": { + "type": "object", + "required": ["id", "quantity"], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "quantity": { "type": "integer" }, + "product": { + "$ref": "#/components/schemas/Product" + } + } + }, + "Category": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + } + } + }, + "paths": { + "/product/{id}": { + "get": { + "tags": ["Product"], + "operationId": "readProduct", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "pattern": "^[0-9a-f]{32}$" } + } + ], + "responses": { + "200": { + "description": "Product detail", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "product": { "$ref": "#/components/schemas/Product" } + } + } + } + } + } + } + } + }, + "/cart": { + "get": { + "tags": ["Cart"], + "operationId": "readCart", + "responses": { + "200": { + "description": "Current cart", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Cart" } + } + } + } + } + }, + "post": { + "tags": ["Cart"], + "operationId": "addLineItem", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["productId"], + "properties": { + "productId": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "quantity": { + "type": "integer", + "default": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated cart", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Cart" } + } + } + } + } + } + }, + "/category": { + "get": { + "tags": ["Navigation"], + "operationId": "readCategories", + "responses": { + "200": { + "description": "Category list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "elements": { + "type": "array", + "items": { "$ref": "#/components/schemas/Category" } + } + } + } + } + } + } + } + } + } + } +} diff --git a/packages/api-gen/tests/php-dto/generator.test.ts b/packages/api-gen/tests/php-dto/generator.test.ts index 42aaa392b..ddf868232 100644 --- a/packages/api-gen/tests/php-dto/generator.test.ts +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -663,10 +663,11 @@ describe("generator", () => { }); describe("generateAllFiles", () => { - it("includes PreserveNull attribute file and one file per DTO", () => { + it("puts operation DTOs in root and component DTOs in shared/", () => { const dtos: DtoDefinition[] = [ { - name: "ProductDTO", + name: "ReadProductResponseDTO", + source: "operation", properties: [ { name: "id", @@ -678,10 +679,11 @@ describe("generator", () => { ], }, { - name: "CartDTO", + name: "ProductDTO", + source: "component", properties: [ { - name: "token", + name: "name", phpType: "string", nullable: false, required: true, @@ -696,14 +698,165 @@ describe("generator", () => { expect(files).toHaveLength(3); expect(files[0]?.fileName).toBe("PreserveNull.php"); expect(files[0]?.content).toContain("class PreserveNull"); - expect(files[1]?.fileName).toBe("ProductDTO.php"); - expect(files[2]?.fileName).toBe("CartDTO.php"); + expect(files[1]?.fileName).toBe("ReadProductResponseDTO.php"); + expect(files[2]?.fileName).toBe("shared/ProductDTO.php"); + }); + + it("defaults to shared/ when source is not set", () => { + const dtos: DtoDefinition[] = [ + { + name: "CartDTO", + properties: [ + { + name: "token", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + ]; + + const files = generateAllFiles(dtos); + + expect(files).toHaveLength(2); + expect(files[1]?.fileName).toBe("shared/CartDTO.php"); + }); + + it("without --namespace: shared DTOs get namespace Shared, root DTOs get use imports", () => { + const dtos: DtoDefinition[] = [ + { + name: "RegisterRequestDTO", + source: "operation", + properties: [ + { + name: "address", + phpType: "CustomerAddressDTO", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + { + name: "CustomerAddressDTO", + source: "component", + properties: [ + { + name: "country", + phpType: "CountryDTO", + nullable: false, + required: false, + isArray: false, + }, + ], + }, + { + name: "CountryDTO", + source: "component", + properties: [ + { + name: "name", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + ]; + + const files = generateAllFiles(dtos); + + const requestContent = files[1]?.content ?? ""; + expect(requestContent).not.toContain("namespace "); + expect(requestContent).toContain("use Shared\\CustomerAddressDTO;"); + + const addressContent = files[2]?.content ?? ""; + expect(addressContent).toContain("namespace Shared;"); + expect(addressContent).toContain("use Shared\\CountryDTO;"); + + const countryContent = files[3]?.content ?? ""; + expect(countryContent).toContain("namespace Shared;"); + }); + + it("adds namespace and use imports with --namespace", () => { + const dtos: DtoDefinition[] = [ + { + name: "RegisterRequestDTO", + source: "operation", + properties: [ + { + name: "address", + phpType: "CustomerAddressDTO", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + { + name: "CustomerAddressDTO", + source: "component", + properties: [ + { + name: "country", + phpType: "CountryDTO", + nullable: false, + required: false, + isArray: false, + }, + ], + }, + { + name: "CountryDTO", + source: "component", + properties: [ + { + name: "name", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + ]; + + const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); + + expect(files[0]?.content).toContain("namespace App\\DTO;"); + + const requestContent = files[1]?.content ?? ""; + expect(requestContent).toContain("namespace App\\DTO;"); + expect(requestContent).toContain( + "use App\\DTO\\Shared\\CustomerAddressDTO;", + ); + + const addressContent = files[2]?.content ?? ""; + expect(addressContent).toContain("namespace App\\DTO\\Shared;"); + expect(addressContent).toContain("use App\\DTO\\Shared\\CountryDTO;"); }); - it("passes namespace to all generated files including PreserveNull", () => { + it("operation DTOs keep base namespace, component DTOs get Shared suffix", () => { const dtos: DtoDefinition[] = [ + { + name: "TestRequestDTO", + source: "operation", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, { name: "TestDTO", + source: "component", properties: [ { name: "id", @@ -718,9 +871,34 @@ describe("generator", () => { const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); - expect(files[0]?.fileName).toBe("PreserveNull.php"); - expect(files[0]?.content).toContain("namespace App\\DTO;"); expect(files[1]?.content).toContain("namespace App\\DTO;"); + expect(files[1]?.content).not.toContain("Shared"); + expect(files[2]?.content).toContain("namespace App\\DTO\\Shared;"); + }); + + it("generates PreserveNull import for component DTOs with namespace", () => { + const dtos: DtoDefinition[] = [ + { + name: "ProductDTO", + source: "component", + properties: [ + { + name: "description", + phpType: "string", + nullable: true, + required: false, + isArray: false, + }, + ], + }, + ]; + + const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); + const productContent = files[1]?.content ?? ""; + + expect(productContent).toContain("namespace App\\DTO\\Shared;"); + expect(productContent).toContain("use App\\DTO\\PreserveNull;"); + expect(productContent).toContain("#[PreserveNull]"); }); }); }); diff --git a/packages/api-gen/tests/php-dto/phpDto.test.ts b/packages/api-gen/tests/php-dto/phpDto.test.ts index f4f035907..3b8b02f0e 100644 --- a/packages/api-gen/tests/php-dto/phpDto.test.ts +++ b/packages/api-gen/tests/php-dto/phpDto.test.ts @@ -3,12 +3,28 @@ import { readFileSync, readdirSync, rmSync, + statSync, writeFileSync, } from "node:fs"; -import { resolve } from "node:path"; +import { relative, resolve } from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { phpDto } from "../../src/commands/phpDto"; +function collectPhpFiles(dir: string, base?: string): string[] { + const root = base ?? dir; + const results: string[] = []; + if (!existsSync(dir)) return results; + for (const entry of readdirSync(dir)) { + const fullPath = resolve(dir, entry); + if (statSync(fullPath).isDirectory()) { + results.push(...collectPhpFiles(fullPath, root)); + } else if (entry.endsWith(".php")) { + results.push(relative(root, fullPath)); + } + } + return results; +} + const TEST_OUTPUT_DIR = resolve(__dirname, "test-output-phpDto"); const FIXTURE_SCHEMA = resolve(__dirname, "fixtures/simpleSchema.json"); @@ -23,7 +39,7 @@ describe("phpDto command", () => { rmSync(TEST_OUTPUT_DIR, { recursive: true, force: true }); }); - it("generate: creates PHP files in output directory", async () => { + it("generate: creates PHP files in output directory with shared/ for components", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "generate"); await phpDto({ @@ -34,13 +50,13 @@ describe("phpDto command", () => { expect(existsSync(outputDir)).toBe(true); - const files = readdirSync(outputDir).filter((f) => f.endsWith(".php")); + const files = collectPhpFiles(outputDir); expect(files.length).toBeGreaterThan(0); - expect(files).toContain("CartDTO.php"); + expect(files).toContain("shared/CartDTO.php"); expect(files).toContain("SendContactMailRequestDTO.php"); const cartContent = readFileSync( - resolve(outputDir, "CartDTO.php"), + resolve(outputDir, "shared/CartDTO.php"), "utf-8", ); expect(cartContent).toContain("class CartDTO"); @@ -58,10 +74,10 @@ describe("phpDto command", () => { }); const cartContent = readFileSync( - resolve(outputDir, "CartDTO.php"), + resolve(outputDir, "shared/CartDTO.php"), "utf-8", ); - expect(cartContent).toContain("namespace App\\DTO;"); + expect(cartContent).toContain("namespace App\\DTO\\Shared;"); }); it("generate: cleans output directory on re-run", async () => { @@ -116,7 +132,7 @@ describe("phpDto command", () => { outputDir, }); - rmSync(resolve(outputDir, "CartDTO.php")); + rmSync(resolve(outputDir, "shared/CartDTO.php")); const spy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit called"); @@ -143,7 +159,10 @@ describe("phpDto command", () => { outputDir, }); - writeFileSync(resolve(outputDir, "CartDTO.php"), " { throw new Error("process.exit called"); @@ -210,14 +229,14 @@ describe("phpDto command", () => { outputDir, }); - const files = readdirSync(outputDir).filter((f) => f.endsWith(".php")); - expect(files).toContain("SimpleProductDTO.php"); + const files = collectPhpFiles(outputDir); + expect(files).toContain("shared/SimpleProductDTO.php"); expect(files).toContain("ApiInfoRequestDTO.php"); - expect(files).not.toContain("Simple-ProductDTO.php"); + expect(files).not.toContain("shared/Simple-ProductDTO.php"); expect(files).not.toContain("Api-infoRequestDTO.php"); const content = readFileSync( - resolve(outputDir, "SimpleProductDTO.php"), + resolve(outputDir, "shared/SimpleProductDTO.php"), "utf-8", ); expect(content).toContain("class SimpleProductDTO"); @@ -232,11 +251,14 @@ describe("phpDto command", () => { outputDir, }); - const files = readdirSync(outputDir).filter((f) => f.endsWith(".php")); - expect(files).toContain("ErrorDTO.php"); - expect(files).not.toContain("errorDTO.php"); + const files = collectPhpFiles(outputDir); + expect(files).toContain("shared/ErrorDTO.php"); + expect(files).not.toContain("shared/errorDTO.php"); - const content = readFileSync(resolve(outputDir, "ErrorDTO.php"), "utf-8"); + const content = readFileSync( + resolve(outputDir, "shared/ErrorDTO.php"), + "utf-8", + ); expect(content).toContain("class ErrorDTO"); expect(content).not.toContain("class errorDTO"); }); @@ -251,7 +273,7 @@ describe("phpDto command", () => { }); const content = readFileSync( - resolve(outputDir, "ErrorResponseDTO.php"), + resolve(outputDir, "shared/ErrorResponseDTO.php"), "utf-8", ); expect(content).toContain("class ErrorResponseDTO"); @@ -304,4 +326,59 @@ describe("phpDto command", () => { expect(existsSync(outputDir)).toBe(true); }); }); + + describe("tag filtering", () => { + const TAG_SCHEMA = resolve(__dirname, "fixtures/tagSchema.json"); + + it("generates only DTOs for the specified tag and its dependencies", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "tag-cart"); + + await phpDto({ + action: "generate", + schemaFile: TAG_SCHEMA, + outputDir, + tag: "Cart", + }); + + const files = collectPhpFiles(outputDir); + + expect(files).toContain("PreserveNull.php"); + expect(files).toContain("shared/CartDTO.php"); + expect(files).toContain("shared/LineItemDTO.php"); + expect(files).toContain("shared/ProductDTO.php"); + expect(files).toContain("shared/MediaDTO.php"); + expect(files).toContain("AddLineItemRequestDTO.php"); + + expect(files).not.toContain("shared/CategoryDTO.php"); + expect(files).not.toContain("ReadCategoriesResponseDTO.php"); + }); + + it("without tag generates all DTOs", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "tag-none"); + + await phpDto({ + action: "generate", + schemaFile: TAG_SCHEMA, + outputDir, + }); + + const files = collectPhpFiles(outputDir); + + expect(files).toContain("shared/CartDTO.php"); + expect(files).toContain("shared/CategoryDTO.php"); + expect(files).toContain("shared/ProductDTO.php"); + expect(files).toContain("ReadCategoriesResponseDTO.php"); + }); + + it("with non-matching tag produces no DTOs", async () => { + await expect( + phpDto({ + action: "generate", + schemaFile: TAG_SCHEMA, + outputDir: resolve(TEST_OUTPUT_DIR, "tag-empty"), + tag: "NonExistent", + }), + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/packages/api-gen/tests/php-dto/schemaParser.test.ts b/packages/api-gen/tests/php-dto/schemaParser.test.ts index e39fbee54..e51f70c32 100644 --- a/packages/api-gen/tests/php-dto/schemaParser.test.ts +++ b/packages/api-gen/tests/php-dto/schemaParser.test.ts @@ -16,6 +16,10 @@ const nestedSchema = JSON.parse( readFileSync(resolve(__dirname, "fixtures/nestedObjects.json"), "utf-8"), ); +const tagSchema = JSON.parse( + readFileSync(resolve(__dirname, "fixtures/tagSchema.json"), "utf-8"), +); + describe("schemaParser", () => { describe("parseComponentSchemas", () => { it("extracts DTO definitions from components/schemas", () => { @@ -28,6 +32,13 @@ describe("schemaParser", () => { expect(names).toContain("NullableUnionDTO"); }); + it("sets source to component for component schemas", () => { + const dtos = parseComponentSchemas(fixtureSchema); + for (const dto of dtos) { + expect(dto.source).toBe("component"); + } + }); + it("skips schemas without object properties", () => { const dtos = parseComponentSchemas(fixtureSchema); const names = dtos.map((d) => d.name); @@ -333,6 +344,13 @@ describe("schemaParser", () => { expect(names).toContain("SendContactMailRequestDTO"); }); + it("sets source to operation for request DTOs", () => { + const dtos = parseRequestBodies(fixtureSchema); + for (const dto of dtos) { + expect(dto.source).toBe("operation"); + } + }); + it("parses sendContactMail request body correctly", () => { const dtos = parseRequestBodies(fixtureSchema); const dto = dtos.find((d) => d.name === "SendContactMailRequestDTO"); @@ -394,11 +412,18 @@ describe("schemaParser", () => { expect(names).toContain("ReadProductResponseDTO"); }); - it("skips responses that are $ref only", () => { + it("sets source to operation for response DTOs", () => { + const dtos = parseResponseBodies(fixtureSchema); + for (const dto of dtos) { + expect(dto.source).toBe("operation"); + } + }); + + it("resolves $ref responses to component schemas", () => { const dtos = parseResponseBodies(fixtureSchema); const names = dtos.map((d) => d.name); - expect(names).not.toContain("ReadCartResponseDTO"); + expect(names).toContain("ReadCartResponseDTO"); }); it("handles missing paths", () => { @@ -416,5 +441,50 @@ describe("schemaParser", () => { expect(names).toContain("SendContactMailRequestDTO"); expect(names).toContain("ReadProductResponseDTO"); }); + + it("without tag returns all DTOs", () => { + const all = parseAllDtos(tagSchema); + const names = all.map((d) => d.name); + + expect(names).toContain("ProductDTO"); + expect(names).toContain("CartDTO"); + expect(names).toContain("CategoryDTO"); + expect(names).toContain("AddLineItemRequestDTO"); + expect(names).toContain("ReadCategoriesResponseDTO"); + }); + + it("with tag filters to matching operations and referenced schemas", () => { + const dtos = parseAllDtos(tagSchema, { tag: "Cart" }); + const names = dtos.map((d) => d.name); + + expect(names).toContain("AddLineItemRequestDTO"); + expect(names).toContain("CartDTO"); + expect(names).toContain("LineItemDTO"); + expect(names).toContain("ProductDTO"); + expect(names).toContain("MediaDTO"); + + expect(names).not.toContain("CategoryDTO"); + expect(names).not.toContain("ReadCategoriesResponseDTO"); + expect(names).not.toContain("ReadProductRequestDTO"); + }); + + it("with tag includes transitively referenced schemas", () => { + const dtos = parseAllDtos(tagSchema, { tag: "Product" }); + const names = dtos.map((d) => d.name); + + expect(names).toContain("ProductDTO"); + expect(names).toContain("MediaDTO"); + expect(names).toContain("ReadProductRequestDTO"); + expect(names).toContain("ReadProductResponseDTO"); + + expect(names).not.toContain("CartDTO"); + expect(names).not.toContain("LineItemDTO"); + expect(names).not.toContain("CategoryDTO"); + }); + + it("with non-matching tag returns empty", () => { + const dtos = parseAllDtos(tagSchema, { tag: "NonExistent" }); + expect(dtos).toHaveLength(0); + }); }); }); From 179141dba8de51f20345f7c9db55a5ed04d148f6 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:34:33 +0100 Subject: [PATCH 08/12] feat: add format assertions --- packages/api-gen/src/php-dto/generator.ts | 18 +- packages/api-gen/src/php-dto/schemaParser.ts | 5 + .../__snapshots__/snapshots.test.ts.snap | 78 +++++++++ .../php-dto/fixtures/formatAssertions.json | 59 +++++++ .../api-gen/tests/php-dto/generator.test.ts | 159 ++++++++++++++++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 packages/api-gen/tests/php-dto/fixtures/formatAssertions.json diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts index 3744e9d92..17fd5f41d 100644 --- a/packages/api-gen/src/php-dto/generator.ts +++ b/packages/api-gen/src/php-dto/generator.ts @@ -25,6 +25,15 @@ function hasDefault(prop: DtoProperty): boolean { return prop.defaultValue !== undefined || prop.nullable || !prop.required; } +const FORMAT_ASSERT_MAP: Record = { + email: "#[Assert\\Email]", + uuid: "#[Assert\\Uuid]", + uri: "#[Assert\\Url]", + "date-time": + "#[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)]", + date: "#[Assert\\Date]", +}; + const PHP_PRIMITIVE_TYPES = new Set([ "string", "int", @@ -106,6 +115,10 @@ function renderConstructorParam(prop: DtoProperty): string { } } + if (prop.format && FORMAT_ASSERT_MAP[prop.format]) { + lines.push(` ${FORMAT_ASSERT_MAP[prop.format]}`); + } + if (prop.pattern) { lines.push( ` #[Assert\\Regex(pattern: '/${escapePhpSingleQuoted(prop.pattern)}/')]`, @@ -147,7 +160,10 @@ export function generatePhpClass( const lines: string[] = []; const needsAssert = dto.properties.some( (p) => - p.pattern || (p.enum && p.enum.length > 0) || (p.required && !p.nullable), + p.pattern || + (p.format && FORMAT_ASSERT_MAP[p.format]) || + (p.enum && p.enum.length > 0) || + (p.required && !p.nullable), ); lines.push(" Initial line items to add to the cart */ public ?array $lineItems = null, /** Date and time the cart was last modified */ + #[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)] public ?string $updatedAt = null, ) { } @@ -90,12 +92,14 @@ class ReadCartResponseDTO public string $id, /** Date and time the cart was created */ #[Assert\\NotBlank] + #[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)] public string $createdAt, /** * @var list Initial line items to add to the cart */ public ?array $lineItems = null, /** Date and time the cart was last modified */ + #[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)] public ?string $updatedAt = null, ) { } @@ -153,12 +157,14 @@ class CartDTO public string $id, /** Date and time the cart was created */ #[Assert\\NotBlank] + #[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)] public string $createdAt, /** * @var list Initial line items to add to the cart */ public ?array $lineItems = null, /** Date and time the cart was last modified */ + #[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)] public ?string $updatedAt = null, ) { } @@ -220,6 +226,78 @@ exports[`php-dto snapshot tests > createReadEntity.json > generates expected fil exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `7`; +exports[`php-dto snapshot tests > formatAssertions.json > generates correct content for PreserveNull.php 1`] = ` +" formatAssertions.json > generates correct content for shared/UserProfileDTO.php 1`] = ` +" formatAssertions.json > generates correct content with namespace 1`] = ` +" formatAssertions.json > generates expected file names 1`] = ` +[ + "PreserveNull.php", + "shared/UserProfileDTO.php", +] +`; + +exports[`php-dto snapshot tests > formatAssertions.json > generates expected number of files 1`] = `2`; + exports[`php-dto snapshot tests > invalidNames.json > generates correct content for Api-infoRequestDTO.php 1`] = ` " { expect(result).not.toContain("#[PreserveNull]"); }); + it("adds Assert\\Email for format: email", () => { + const dto: DtoDefinition = { + name: "UserDTO", + properties: [ + { + name: "email", + phpType: "string", + nullable: false, + required: true, + format: "email", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "use Symfony\\Component\\Validator\\Constraints as Assert;", + ); + expect(result).toContain("#[Assert\\Email]"); + expect(result).toContain("#[Assert\\NotBlank]"); + expect(result).toContain("public string $email,"); + }); + + it("adds Assert\\Uuid for format: uuid", () => { + const dto: DtoDefinition = { + name: "EntityDTO", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + format: "uuid", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain("#[Assert\\Uuid]"); + }); + + it("adds Assert\\Url for format: uri", () => { + const dto: DtoDefinition = { + name: "LinkDTO", + properties: [ + { + name: "website", + phpType: "string", + nullable: false, + required: false, + format: "uri", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain("#[Assert\\Url]"); + }); + + it("adds Assert\\DateTime for format: date-time", () => { + const dto: DtoDefinition = { + name: "EventDTO", + properties: [ + { + name: "createdAt", + phpType: "string", + nullable: false, + required: true, + format: "date-time", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain( + "#[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)]", + ); + }); + + it("adds Assert\\Date for format: date", () => { + const dto: DtoDefinition = { + name: "ProfileDTO", + properties: [ + { + name: "birthday", + phpType: "string", + nullable: false, + required: false, + format: "date", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain("#[Assert\\Date]"); + }); + + it("does not add format assert for unknown formats like int64 or uri-reference", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "fileSize", + phpType: "int", + nullable: false, + required: false, + format: "int64", + isArray: false, + }, + { + name: "avatar", + phpType: "string", + nullable: false, + required: false, + format: "uri-reference", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).not.toContain("use Symfony"); + expect(result).not.toContain("Assert"); + }); + + it("combines format assert with pattern and required asserts", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "email", + phpType: "string", + nullable: false, + required: true, + format: "email", + pattern: "^.+@.+$", + isArray: false, + }, + ], + }; + + const result = generatePhpClass(dto); + + expect(result).toContain("#[Assert\\NotBlank]"); + expect(result).toContain("#[Assert\\Email]"); + expect(result).toContain("#[Assert\\Regex(pattern: '/^.+@.+$/')]"); + }); + it("handles multiline description", () => { const dto: DtoDefinition = { name: "TestDTO", From e447200438499018060f88727e62cab504862cba Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:59:03 +0100 Subject: [PATCH 09/12] chore: add optional PreserveNull attribute --- packages/api-gen/README.md | 22 ++ packages/api-gen/src/php-dto/generator.ts | 40 ++-- .../__snapshots__/snapshots.test.ts.snap | 208 ++++++++++-------- .../api-gen/tests/php-dto/generator.test.ts | 88 ++++++-- packages/api-gen/tests/php-dto/phpDto.test.ts | 1 - 5 files changed, 233 insertions(+), 126 deletions(-) diff --git a/packages/api-gen/README.md b/packages/api-gen/README.md index 37d50a7d9..8ae6d9f40 100644 --- a/packages/api-gen/README.md +++ b/packages/api-gen/README.md @@ -333,6 +333,28 @@ flags: - `--schemaFile` / `-f` (required) — path to the OpenAPI JSON schema file - `--outputDir` / `-o` (default: `./dto`) — output directory for generated PHP files - `--namespace` / `-n` (optional) — PHP namespace added to every generated class +- `--tag` / `-t` (optional) — generate only DTOs for endpoints tagged with the given value (and all transitively referenced schemas) +- `--rawNames` (optional) — disable automatic PascalCase conversion for class/file names; errors on invalid PHP class names instead + +#### Generated file structure + +- **Root directory** — request, response, and parameter DTOs derived from API operations +- **`shared/` subdirectory** — component schema DTOs referenced by the operation-level DTOs + +#### `PreserveNull` attribute + +Every generated batch includes a `PreserveNull.php` file containing a custom PHP attribute: + +```php +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class PreserveNull +{ +} +``` + +This attribute is added to every constructor parameter where the OpenAPI schema **explicitly declares `null` as a possible type** (e.g. `type: ["string", "null"]` or `oneOf`/`anyOf` containing a null variant). It distinguishes properties that are *intentionally nullable* from properties that default to `null` only for runtime safety (optional, non-required fields without an explicit default). + +Use `PreserveNull` in your deserialization/serialization layer to decide whether a `null` value should be preserved and sent to the API, or stripped from the payload. For example, a Symfony serializer normalizer can check for this attribute and keep `null` values in the output only for marked properties. ### `split` - Experimental diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts index 17fd5f41d..c63287726 100644 --- a/packages/api-gen/src/php-dto/generator.ts +++ b/packages/api-gen/src/php-dto/generator.ts @@ -75,11 +75,10 @@ function buildUseStatements( const imports: string[] = []; if (usesPreserveNull) { - const preserveNullNs = baseNamespace; - const fqcn = preserveNullNs - ? `${preserveNullNs}\\PreserveNull` - : "PreserveNull"; - imports.push(fqcn); + const attrNs = baseNamespace + ? `${baseNamespace}\\Attributes` + : "Attributes"; + imports.push(`${attrNs}\\PreserveNull`); } for (const name of referencedNames) { @@ -245,13 +244,14 @@ export interface GeneratedFile { export function generatePreserveNullAttribute( options: GeneratorOptions = {}, ): string { + const ns = options.namespace + ? `${options.namespace}\\Attributes` + : "Attributes"; const lines: string[] = []; lines.push(" + dto.properties.some((p) => p.nullable), + ); + + if (usesPreserveNull) { + return [ + { + fileName: "attributes/PreserveNull.php", + content: generatePreserveNullAttribute(options), + }, + ...dtoFiles, + ]; + } + + return dtoFiles; } diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap index cf03812fc..6daba7f07 100644 --- a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -61,16 +61,6 @@ class CreateCartResponseDTO " `; -exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for PreserveNull.php 1`] = ` -" createReadEntity.json > generates correct content for ReadCartResponseDTO.php 1`] = ` " createReadEntity.json > generates correct content with namespace 1`] = ` " Initial line items to add to the cart + */ + public ?array $lineItems = null, + ) { + } } " `; @@ -216,7 +221,6 @@ exports[`php-dto snapshot tests > createReadEntity.json > generates expected fil [ "CreateCartRequestDTO.php", "CreateCartResponseDTO.php", - "PreserveNull.php", "ReadCartResponseDTO.php", "shared/CartCreateDTO.php", "shared/CartDTO.php", @@ -224,17 +228,7 @@ exports[`php-dto snapshot tests > createReadEntity.json > generates expected fil ] `; -exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `7`; - -exports[`php-dto snapshot tests > formatAssertions.json > generates correct content for PreserveNull.php 1`] = ` -" createReadEntity.json > generates expected number of files 1`] = `6`; exports[`php-dto snapshot tests > formatAssertions.json > generates correct content for shared/UserProfileDTO.php 1`] = ` " formatAssertions.json > generates correct content with namespace 1`] = ` " formatAssertions.json > generates expected file names 1`] = ` [ - "PreserveNull.php", "shared/UserProfileDTO.php", ] `; -exports[`php-dto snapshot tests > formatAssertions.json > generates expected number of files 1`] = `2`; +exports[`php-dto snapshot tests > formatAssertions.json > generates expected number of files 1`] = `1`; exports[`php-dto snapshot tests > invalidNames.json > generates correct content for Api-infoRequestDTO.php 1`] = ` " invalidNames.json > generates correct content for PreserveNull.php 1`] = ` -" invalidNames.json > generates correct content for shared/Simple-ProductDTO.php 1`] = ` " invalidNames.json > generates correct content with namespace 1`] = ` " invalidNames.json > generates expected file na [ "Api-infoRequestDTO.php", "Api-infoResponseDTO.php", - "PreserveNull.php", "shared/Simple-ProductDTO.php", "shared/errorDTO.php", "shared/errorResponseDTO.php", ] `; -exports[`php-dto snapshot tests > invalidNames.json > generates expected number of files 1`] = `6`; - -exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for PreserveNull.php 1`] = ` -" invalidNames.json > generates expected number of files 1`] = `5`; exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/OrderBillingAddressCountryDTO.php 1`] = ` " nestedObjects.json > generates correct content with namespace 1`] = ` " Active tax rules + */ + public ?array $taxRules = null, + ) { + } } " `; exports[`php-dto snapshot tests > nestedObjects.json > generates expected file names 1`] = ` [ - "PreserveNull.php", "shared/OrderBillingAddressCountryDTO.php", "shared/OrderBillingAddressDTO.php", "shared/OrderDTO.php", @@ -707,17 +737,7 @@ exports[`php-dto snapshot tests > nestedObjects.json > generates expected file n ] `; -exports[`php-dto snapshot tests > nestedObjects.json > generates expected number of files 1`] = `11`; - -exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for PreserveNull.php 1`] = ` -" nestedObjects.json > generates expected number of files 1`] = `10`; exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for ReadCartResponseDTO.php 1`] = ` " simpleSchema.json > generates correct content for attributes/PreserveNull.php 1`] = ` +" simpleSchema.json > generates correct content for shared/CalculatedPriceDTO.php 1`] = ` " simpleSchema.json > generates correct content namespace Shared; -use PreserveNull; +use Attributes\\PreserveNull; class NullableUnionDTO { @@ -971,7 +1003,7 @@ class NullableUnionDTO exports[`php-dto snapshot tests > simpleSchema.json > generates correct content with namespace 1`] = ` " simpleSchema.json > generates expected file names 1`] = ` [ - "PreserveNull.php", "ReadCartResponseDTO.php", "ReadProductRequestDTO.php", "ReadProductResponseDTO.php", "SendContactMailRequestDTO.php", + "attributes/PreserveNull.php", "shared/CalculatedPriceDTO.php", "shared/CartDTO.php", "shared/DefaultValuesDTO.php", @@ -1040,16 +1072,6 @@ class AddLineItemResponseDTO " `; -exports[`php-dto snapshot tests > tagSchema.json > generates correct content for PreserveNull.php 1`] = ` -" tagSchema.json > generates correct content for ReadCartResponseDTO.php 1`] = ` " tagSchema.json > generates correct content with namespace 1`] = ` " tagSchema.json > generates expected file names [ "AddLineItemRequestDTO.php", "AddLineItemResponseDTO.php", - "PreserveNull.php", "ReadCartResponseDTO.php", "ReadCategoriesResponseDTO.php", "ReadProductRequestDTO.php", @@ -1260,4 +1292,4 @@ exports[`php-dto snapshot tests > tagSchema.json > generates expected file names ] `; -exports[`php-dto snapshot tests > tagSchema.json > generates expected number of files 1`] = `12`; +exports[`php-dto snapshot tests > tagSchema.json > generates expected number of files 1`] = `11`; diff --git a/packages/api-gen/tests/php-dto/generator.test.ts b/packages/api-gen/tests/php-dto/generator.test.ts index 203a38938..05d427f52 100644 --- a/packages/api-gen/tests/php-dto/generator.test.ts +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -806,17 +806,17 @@ describe("generator", () => { const result = generatePreserveNullAttribute(); expect(result).toContain(" { + it("includes base namespace with Attributes suffix when provided", () => { const result = generatePreserveNullAttribute({ namespace: "App\\DTO", }); - expect(result).toContain("namespace App\\DTO;"); + expect(result).toContain("namespace App\\DTO\\Attributes;"); expect(result).toContain("class PreserveNull"); }); }); @@ -854,11 +854,9 @@ describe("generator", () => { const files = generateAllFiles(dtos); - expect(files).toHaveLength(3); - expect(files[0]?.fileName).toBe("PreserveNull.php"); - expect(files[0]?.content).toContain("class PreserveNull"); - expect(files[1]?.fileName).toBe("ReadProductResponseDTO.php"); - expect(files[2]?.fileName).toBe("shared/ProductDTO.php"); + expect(files).toHaveLength(2); + expect(files[0]?.fileName).toBe("ReadProductResponseDTO.php"); + expect(files[1]?.fileName).toBe("shared/ProductDTO.php"); }); it("defaults to shared/ when source is not set", () => { @@ -879,8 +877,8 @@ describe("generator", () => { const files = generateAllFiles(dtos); - expect(files).toHaveLength(2); - expect(files[1]?.fileName).toBe("shared/CartDTO.php"); + expect(files).toHaveLength(1); + expect(files[0]?.fileName).toBe("shared/CartDTO.php"); }); it("without --namespace: shared DTOs get namespace Shared, root DTOs get use imports", () => { @@ -928,15 +926,15 @@ describe("generator", () => { const files = generateAllFiles(dtos); - const requestContent = files[1]?.content ?? ""; + const requestContent = files[0]?.content ?? ""; expect(requestContent).not.toContain("namespace "); expect(requestContent).toContain("use Shared\\CustomerAddressDTO;"); - const addressContent = files[2]?.content ?? ""; + const addressContent = files[1]?.content ?? ""; expect(addressContent).toContain("namespace Shared;"); expect(addressContent).toContain("use Shared\\CountryDTO;"); - const countryContent = files[3]?.content ?? ""; + const countryContent = files[2]?.content ?? ""; expect(countryContent).toContain("namespace Shared;"); }); @@ -985,15 +983,13 @@ describe("generator", () => { const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); - expect(files[0]?.content).toContain("namespace App\\DTO;"); - - const requestContent = files[1]?.content ?? ""; + const requestContent = files[0]?.content ?? ""; expect(requestContent).toContain("namespace App\\DTO;"); expect(requestContent).toContain( "use App\\DTO\\Shared\\CustomerAddressDTO;", ); - const addressContent = files[2]?.content ?? ""; + const addressContent = files[1]?.content ?? ""; expect(addressContent).toContain("namespace App\\DTO\\Shared;"); expect(addressContent).toContain("use App\\DTO\\Shared\\CountryDTO;"); }); @@ -1030,9 +1026,57 @@ describe("generator", () => { const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); - expect(files[1]?.content).toContain("namespace App\\DTO;"); - expect(files[1]?.content).not.toContain("Shared"); - expect(files[2]?.content).toContain("namespace App\\DTO\\Shared;"); + expect(files[0]?.content).toContain("namespace App\\DTO;"); + expect(files[0]?.content).not.toContain("Shared"); + expect(files[1]?.content).toContain("namespace App\\DTO\\Shared;"); + }); + + it("omits PreserveNull.php when no property is nullable", () => { + const dtos: DtoDefinition[] = [ + { + name: "SimpleDTO", + source: "component", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + ]; + + const files = generateAllFiles(dtos); + + expect( + files.every((f) => f.fileName !== "attributes/PreserveNull.php"), + ).toBe(true); + }); + + it("includes PreserveNull.php when at least one property is nullable", () => { + const dtos: DtoDefinition[] = [ + { + name: "SimpleDTO", + source: "component", + properties: [ + { + name: "label", + phpType: "string", + nullable: true, + required: false, + isArray: false, + }, + ], + }, + ]; + + const files = generateAllFiles(dtos); + + expect(files[0]?.fileName).toBe("attributes/PreserveNull.php"); + expect(files[0]?.content).toContain("namespace Attributes;"); + expect(files[0]?.content).toContain("class PreserveNull"); }); it("generates PreserveNull import for component DTOs with namespace", () => { @@ -1056,7 +1100,9 @@ describe("generator", () => { const productContent = files[1]?.content ?? ""; expect(productContent).toContain("namespace App\\DTO\\Shared;"); - expect(productContent).toContain("use App\\DTO\\PreserveNull;"); + expect(productContent).toContain( + "use App\\DTO\\Attributes\\PreserveNull;", + ); expect(productContent).toContain("#[PreserveNull]"); }); }); diff --git a/packages/api-gen/tests/php-dto/phpDto.test.ts b/packages/api-gen/tests/php-dto/phpDto.test.ts index 3b8b02f0e..69a77e6b5 100644 --- a/packages/api-gen/tests/php-dto/phpDto.test.ts +++ b/packages/api-gen/tests/php-dto/phpDto.test.ts @@ -342,7 +342,6 @@ describe("phpDto command", () => { const files = collectPhpFiles(outputDir); - expect(files).toContain("PreserveNull.php"); expect(files).toContain("shared/CartDTO.php"); expect(files).toContain("shared/LineItemDTO.php"); expect(files).toContain("shared/ProductDTO.php"); From 220fc9724c76dd643948d17c001a7c528b9c3890 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:47:15 +0100 Subject: [PATCH 10/12] feat: move params to config and adjust generations --- packages/api-gen/README.md | 54 +- packages/api-gen/package.json | 2 + packages/api-gen/src/cli.ts | 26 +- packages/api-gen/src/commands/phpDto.ts | 107 ++- packages/api-gen/src/php-dto/generator.ts | 212 ++++- packages/api-gen/src/php-dto/schemaParser.ts | 44 +- .../__snapshots__/snapshots.test.ts.snap | 729 ++++++++++++------ .../tests/php-dto/fixtures/oneOfRequest.json | 81 ++ .../api-gen/tests/php-dto/generator.test.ts | 44 +- packages/api-gen/tests/php-dto/phpDto.test.ts | 146 ++-- .../tests/php-dto/schemaParser.test.ts | 51 +- .../api-gen/tests/php-dto/snapshots.test.ts | 4 +- pnpm-lock.yaml | 314 ++++---- 13 files changed, 1251 insertions(+), 563 deletions(-) create mode 100644 packages/api-gen/tests/php-dto/fixtures/oneOfRequest.json diff --git a/packages/api-gen/README.md b/packages/api-gen/README.md index 8ae6d9f40..981aaa141 100644 --- a/packages/api-gen/README.md +++ b/packages/api-gen/README.md @@ -335,11 +335,63 @@ flags: - `--namespace` / `-n` (optional) — PHP namespace added to every generated class - `--tag` / `-t` (optional) — generate only DTOs for endpoints tagged with the given value (and all transitively referenced schemas) - `--rawNames` (optional) — disable automatic PascalCase conversion for class/file names; errors on invalid PHP class names instead +- `--pathConfig` / `-p` (optional) — path to a JSON file that maps API path globs to output subdirectories (see [Path-based routing](#path-based-routing)) #### Generated file structure +Without `--pathConfig`: + - **Root directory** — request, response, and parameter DTOs derived from API operations -- **`shared/` subdirectory** — component schema DTOs referenced by the operation-level DTOs +- **`DTO/` subdirectory** — component schema DTOs referenced by the operation-level DTOs + +#### Path-based routing + +When `--pathConfig` is provided, operation DTOs are grouped into subdirectories based on their endpoint path. Each group gets its own `DTO/` subfolder containing only the component DTOs referenced by that group. Endpoints that don't match any pattern are **skipped** with a warning. + +Create a JSON config file (e.g. `phpDto.paths.json`): + +```json +{ + "/account/**": "account", + "/checkout/cart/**": "cart", + "/product/**": "product", + "/context/**": "context" +} +``` + +Keys are glob patterns matched against the OpenAPI endpoint path. Values are the output subdirectory names. + +```bash +pnpx @shopware/api-gen phpDto generate \ + --schemaFile ./api-types/storeApiSchema.json \ + --outputDir ./dto \ + --pathConfig ./phpDto.paths.json +``` + +This produces a directory structure like: + +``` +dto/ + attributes/ + PreserveNull.php + account/ + LoginCustomerRequestDTO.php + RegisterRequestDTO.php + DTO/ + CustomerDTO.php + CustomerAddressDTO.php + cart/ + AddLineItemRequestDTO.php + DTO/ + CartDTO.php + LineItemDTO.php + product/ + ReadProductRequestDTO.php + DTO/ + ProductDTO.php +``` + +When combined with `--namespace`, each group receives its own sub-namespace (e.g. `App\DTO\Account`, `App\DTO\Account\DTO`). #### `PreserveNull` attribute diff --git a/packages/api-gen/package.json b/packages/api-gen/package.json index 2238b3433..9ce7c3c12 100644 --- a/packages/api-gen/package.json +++ b/packages/api-gen/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@biomejs/biome": "1.8.3", + "@types/picomatch": "^4.0.2", "@types/prettier": "3.0.0", "@types/yargs": "17.0.35", "@typescript/native-preview": "7.0.0-dev.20260111.1", @@ -52,6 +53,7 @@ "@shopware/api-client": "workspace:*", "ofetch": "1.5.1", "openapi-typescript": "7.8.0", + "picomatch": "^4.0.3", "prettier": "3.7.4", "ts-morph": "27.0.2", "typescript": "5.9.3", diff --git a/packages/api-gen/src/cli.ts b/packages/api-gen/src/cli.ts index 9fe9907dc..5edd33746 100644 --- a/packages/api-gen/src/cli.ts +++ b/packages/api-gen/src/cli.ts @@ -158,34 +158,24 @@ yargs(hideBin(process.argv)) describe: "'generate' cleans output dir and regenerates files; 'check' verifies existing files match", }) - .option("schemaFile", { - alias: "f", + .option("config", { + alias: "c", type: "string", demandOption: true, - describe: "path to the OpenAPI JSON schema file", - }) - .option("outputDir", { - alias: "o", - type: "string", - default: "./dto", - describe: "output directory for generated PHP files", + describe: + "path to JSON config file (schemaUrl, outputDir, namespace, tag, routes)", }) - .option("namespace", { - alias: "n", + .option("schemaFile", { + alias: "f", type: "string", - describe: "PHP namespace for generated classes", + describe: + "override: load schema from a local file instead of fetching schemaUrl", }) .option("rawNames", { type: "boolean", default: false, describe: "skip auto-converting class names to PascalCase; fail on invalid names instead", - }) - .option("tag", { - alias: "t", - type: "string", - describe: - "only generate DTOs for endpoints with this tag (and their referenced schemas)", }); }, async (args) => phpDto(args as unknown as PhpDtoOptions), diff --git a/packages/api-gen/src/commands/phpDto.ts b/packages/api-gen/src/commands/phpDto.ts index b83639b22..a54626fc1 100644 --- a/packages/api-gen/src/commands/phpDto.ts +++ b/packages/api-gen/src/commands/phpDto.ts @@ -16,13 +16,19 @@ import { parseAllDtos } from "../php-dto/schemaParser"; import { isValidPhpClassName, toPascalCase } from "../php-dto/typeMapper"; import { loadLocalJSONFile } from "../utils"; +export interface PhpDtoConfig { + schemaUrl: string; + outputDir?: string; + namespace?: string; + tag?: string; + routes?: Record; +} + export interface PhpDtoOptions { action: "generate" | "check"; - schemaFile: string; - outputDir: string; - namespace?: string; + config: string; + schemaFile?: string; rawNames?: boolean; - tag?: string; cwd?: string; } @@ -64,19 +70,56 @@ function validateDtoNames(dtos: DtoDefinition[]): void { } } +async function loadSchema( + config: PhpDtoConfig, + schemaFileOverride: string | undefined, + cwd: string, +): Promise> { + if (schemaFileOverride) { + const schemaPath = resolve(cwd, schemaFileOverride); + const schema = await loadLocalJSONFile>(schemaPath); + if (!schema) { + throw new Error(`Schema file not found: ${schemaPath}`); + } + return schema; + } + + if (!config.schemaUrl) { + throw new Error( + "No schema source: provide schemaUrl in config or use --schemaFile CLI override.", + ); + } + + console.log(pc.blue(`Fetching schema from ${config.schemaUrl}...`)); + const response = await fetch(config.schemaUrl); + if (!response.ok) { + throw new Error( + `Failed to fetch schema from ${config.schemaUrl}: ${response.status} ${response.statusText}`, + ); + } + return (await response.json()) as Record; +} + export async function phpDto(options: PhpDtoOptions): Promise { - const { action, schemaFile, outputDir, namespace, rawNames, tag } = options; + const { action, config: configPath, schemaFile, rawNames } = options; const cwd = options.cwd || process.cwd(); - const outputPath = resolve(cwd, outputDir); - const schemaPath = resolve(cwd, schemaFile); - const schema = await loadLocalJSONFile>(schemaPath); - if (!schema) { - throw new Error(`Schema file not found: ${schemaPath}`); + const resolvedConfigPath = resolve(cwd, configPath); + const config = await loadLocalJSONFile(resolvedConfigPath); + if (!config) { + throw new Error(`Config file not found: ${resolvedConfigPath}`); } - const rawDtos = parseAllDtos(schema, { tag }); + + const outputDir = config.outputDir ?? "./dto"; + const outputPath = resolve(cwd, outputDir); + + const schema = await loadSchema(config, schemaFile, cwd); + const rawDtos = parseAllDtos(schema, { tag: config.tag }); if (rawDtos.length === 0) { + if (action === "generate") { + removeDtoFilesInDir(outputPath); + } console.log(pc.yellow("No DTO definitions found in the schema.")); return; } @@ -89,8 +132,26 @@ export async function phpDto(options: PhpDtoOptions): Promise { dtos = sanitizeDtoNames(rawDtos); } - const generatorOptions: GeneratorOptions = { namespace }; - const files = generateAllFiles(dtos, generatorOptions); + const pathMapping = config.routes; + const generatorOptions: GeneratorOptions = { + namespace: config.namespace, + pathMapping, + }; + const { files, unmappedPaths } = generateAllFiles(dtos, generatorOptions); + + if (unmappedPaths.length > 0) { + console.warn( + pc.yellow( + `Warning: The following API paths are not mapped in ${configPath}:`, + ), + ); + for (const p of unmappedPaths) { + console.warn(pc.yellow(` - ${p}`)); + } + console.warn( + pc.yellow("Add glob patterns for these paths to generate their DTOs."), + ); + } if (action === "generate") { await runGenerate(outputPath, files); @@ -99,13 +160,27 @@ export async function phpDto(options: PhpDtoOptions): Promise { } } +function removeDtoFilesInDir(dir: string): void { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir)) { + const fullPath = resolve(dir, entry); + if (statSync(fullPath).isDirectory()) { + removeDtoFilesInDir(fullPath); + if (readdirSync(fullPath).length === 0) { + rmSync(fullPath, { recursive: true }); + } + } else if (entry.endsWith("DTO.php") || entry === "PreserveNull.php") { + rmSync(fullPath); + } + } +} + async function runGenerate( outputPath: string, files: { fileName: string; content: string }[], ): Promise { - if (existsSync(outputPath)) { - rmSync(outputPath, { recursive: true, force: true }); - } + removeDtoFilesInDir(outputPath); + mkdirSync(outputPath, { recursive: true }); for (const file of files) { diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts index c63287726..f886a527c 100644 --- a/packages/api-gen/src/php-dto/generator.ts +++ b/packages/api-gen/src/php-dto/generator.ts @@ -1,10 +1,19 @@ +import picomatch from "picomatch"; import type { DtoDefinition, DtoProperty, DtoSource } from "./schemaParser"; export interface GeneratorOptions { namespace?: string; - /** Original base namespace before shared/ resolution, used for computing use statements */ + /** Original base namespace before DTO/ resolution, used for computing use statements */ baseNamespace?: string; + /** Top-level namespace from config, used for PreserveNull FQCN */ + rootNamespace?: string; dtoSourceMap?: Map; + pathMapping?: Record; +} + +export interface GenerateResult { + files: GeneratedFile[]; + unmappedPaths: string[]; } function escapePhpDocComment(text: string): string { @@ -48,7 +57,7 @@ function resolveNamespace( source: DtoSource, ): string | undefined { if (source === "component") { - return baseNamespace ? `${baseNamespace}\\Shared` : "Shared"; + return baseNamespace ? `${baseNamespace}\\DTO` : "DTO"; } return baseNamespace; } @@ -71,13 +80,13 @@ function buildUseStatements( referencedNames: Set, dtoSourceMap: Map, usesPreserveNull: boolean, + rootNamespace?: string, ): string[] { const imports: string[] = []; if (usesPreserveNull) { - const attrNs = baseNamespace - ? `${baseNamespace}\\Attributes` - : "Attributes"; + const attrBase = rootNamespace ?? baseNamespace; + const attrNs = attrBase ? `${attrBase}\\Attributes` : "Attributes"; imports.push(`${attrNs}\\PreserveNull`); } @@ -167,6 +176,8 @@ export function generatePhpClass( lines.push(", + collected: Set, +): void { + const dto = allDtos.get(dtoName); + if (!dto) return; + for (const prop of dto.properties) { + for (const typeName of [prop.phpType, prop.arrayItemType]) { + if ( + typeName && + !PHP_PRIMITIVE_TYPES.has(typeName) && + !collected.has(typeName) + ) { + collected.add(typeName); + collectTransitiveDeps(typeName, allDtos, collected); + } + } + } +} + +interface PathGroup { + dir: string; + operationDtos: DtoDefinition[]; + componentDtos: DtoDefinition[]; +} + +function capitalizeFirst(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +function dirToNamespace(dir: string): string { + return dir.split("/").filter(Boolean).map(capitalizeFirst).join("\\"); +} + +export function groupDtosByPath( dtos: DtoDefinition[], - options: GeneratorOptions = {}, -): GeneratedFile[] { - const dtoSourceMap = new Map(); + pathMapping: Record, +): { groups: PathGroup[]; unmappedPaths: string[] } { + const matchers = Object.entries(pathMapping).map(([glob, dir]) => ({ + match: picomatch(glob), + dir, + })); + + const allDtosByName = new Map(); for (const dto of dtos) { - dtoSourceMap.set(dto.name, dto.source ?? "component"); + allDtosByName.set(dto.name, dto); } - const dtoFiles = dtos.map((dto) => { + const groupMap = new Map(); + const unmappedPathSet = new Set(); + + for (const dto of dtos) { + if (dto.source !== "operation" || !dto.endpointPath) continue; + + const path = dto.endpointPath; + const matched = matchers.find((m) => m.match(path)); + if (!matched) { + unmappedPathSet.add(dto.endpointPath); + continue; + } + + const list = groupMap.get(matched.dir) ?? []; + list.push(dto); + groupMap.set(matched.dir, list); + } + + const groups: PathGroup[] = []; + for (const [dir, opDtos] of groupMap) { + const neededComponents = new Set(); + for (const opDto of opDtos) { + collectTransitiveDeps(opDto.name, allDtosByName, neededComponents); + } + + const componentDtos: DtoDefinition[] = []; + for (const name of neededComponents) { + const dto = allDtosByName.get(name); + if (dto) componentDtos.push(dto); + } + + groups.push({ dir, operationDtos: opDtos, componentDtos }); + } + + return { groups, unmappedPaths: [...unmappedPathSet].sort() }; +} + +function generateFilesForGroup( + dtos: DtoDefinition[], + dtoSourceMap: Map, + options: GeneratorOptions, + prefix: string, +): GeneratedFile[] { + return dtos.map((dto) => { const source = dto.source ?? "component"; - const dir = source === "component" ? "shared/" : ""; + const dir = source === "component" ? "DTO/" : ""; const effectiveNs = resolveNamespace(options.namespace, source); const fileOptions: GeneratorOptions = { ...options, namespace: effectiveNs, baseNamespace: options.namespace, + rootNamespace: options.rootNamespace, dtoSourceMap, }; return { - fileName: `${dir}${dtoToFileName(dto.name)}`, + fileName: `${prefix}${dir}${dtoToFileName(dto.name)}`, content: generatePhpClass(dto, fileOptions), }; }); +} + +export function generateAllFiles( + dtos: DtoDefinition[], + options: GeneratorOptions = {}, +): GenerateResult { + if (options.pathMapping) { + return generateGroupedFiles(dtos, options); + } + return generateFlatFiles(dtos, options); +} + +function generateFlatFiles( + dtos: DtoDefinition[], + options: GeneratorOptions, +): GenerateResult { + const dtoSourceMap = new Map(); + for (const dto of dtos) { + dtoSourceMap.set(dto.name, dto.source ?? "component"); + } + + const dtoFiles = generateFilesForGroup(dtos, dtoSourceMap, options, ""); const usesPreserveNull = dtos.some((dto) => dto.properties.some((p) => p.nullable), ); if (usesPreserveNull) { - return [ - { - fileName: "attributes/PreserveNull.php", - content: generatePreserveNullAttribute(options), - }, - ...dtoFiles, - ]; + return { + files: [ + { + fileName: "attributes/PreserveNull.php", + content: generatePreserveNullAttribute(options), + }, + ...dtoFiles, + ], + unmappedPaths: [], + }; + } + + return { files: dtoFiles, unmappedPaths: [] }; +} + +function generateGroupedFiles( + dtos: DtoDefinition[], + options: GeneratorOptions, +): GenerateResult { + const pathMapping = options.pathMapping; + if (!pathMapping) return { files: [], unmappedPaths: [] }; + const { groups, unmappedPaths } = groupDtosByPath(dtos, pathMapping); + + const allFiles: GeneratedFile[] = []; + let needsPreserveNull = false; + + for (const group of groups) { + const groupDtos = [...group.operationDtos, ...group.componentDtos]; + + const dtoSourceMap = new Map(); + for (const dto of groupDtos) { + dtoSourceMap.set(dto.name, dto.source ?? "component"); + } + + const dirNs = dirToNamespace(group.dir); + const groupNs = options.namespace + ? `${options.namespace}\\${dirNs}` + : dirNs; + + const groupFiles = generateFilesForGroup( + groupDtos, + dtoSourceMap, + { ...options, namespace: groupNs, rootNamespace: options.namespace }, + `${group.dir}/`, + ); + allFiles.push(...groupFiles); + + if (groupDtos.some((dto) => dto.properties.some((p) => p.nullable))) { + needsPreserveNull = true; + } + } + + if (needsPreserveNull) { + allFiles.unshift({ + fileName: "attributes/PreserveNull.php", + content: generatePreserveNullAttribute(options), + }); } - return dtoFiles; + return { files: allFiles, unmappedPaths }; } diff --git a/packages/api-gen/src/php-dto/schemaParser.ts b/packages/api-gen/src/php-dto/schemaParser.ts index e4d000593..7ef825a16 100644 --- a/packages/api-gen/src/php-dto/schemaParser.ts +++ b/packages/api-gen/src/php-dto/schemaParser.ts @@ -34,6 +34,7 @@ export interface DtoDefinition { description?: string; properties: DtoProperty[]; source?: DtoSource; + endpointPath?: string; } type SchemaRegistry = Record; @@ -359,7 +360,7 @@ export function parseRequestBodies( if (!paths) return dtos; - for (const pathMethods of Object.values(paths)) { + for (const [pathKey, pathMethods] of Object.entries(paths)) { for (const method of HTTP_METHODS) { const operation = pathMethods[method] as OperationObject | undefined; if (!operation?.operationId) continue; @@ -399,6 +400,38 @@ export function parseRequestBodies( if (!requestSchema && paramProperties.length === 0) continue; + const variants = requestSchema?.oneOf ?? requestSchema?.anyOf; + if (variants && variants.length > 1) { + for (const variant of variants) { + const resolved = dereferenceSchema(variant, registry); + const variantTitle = (variant as { title?: string }).title; + const variantName = variantTitle + ? toDtoClassName(variantTitle) + : dtoName; + + const variantResolved = resolveSchemaProperties(resolved, registry); + const extracted = extractPropertiesFromSchema( + { properties: variantResolved.properties }, + variantResolved.required, + variantName, + registry, + ); + + const allProperties = [...extracted.properties, ...paramProperties]; + if (allProperties.length === 0) continue; + + dtos.push({ + name: variantName, + description: resolved.description || operation.description, + properties: allProperties, + source: "operation", + endpointPath: pathKey, + }); + dtos.push(...extracted.nestedDtos); + } + continue; + } + let bodyProperties: DtoProperty[] = []; let bodyNestedDtos: DtoDefinition[] = []; let bodyDescription: string | undefined; @@ -424,6 +457,7 @@ export function parseRequestBodies( description: bodyDescription || operation.description, properties: allProperties, source: "operation", + endpointPath: pathKey, }); dtos.push(...bodyNestedDtos); } @@ -443,7 +477,7 @@ export function parseResponseBodies( if (!paths) return dtos; - for (const pathMethods of Object.values(paths)) { + for (const [pathKey, pathMethods] of Object.entries(paths)) { for (const method of HTTP_METHODS) { const operation = pathMethods[method] as OperationObject | undefined; if (!operation?.operationId) continue; @@ -476,6 +510,12 @@ export function parseResponseBodies( "operation", ); + for (const dto of extracted) { + if (dto.source === "operation") { + dto.endpointPath = pathKey; + } + } + dtos.push(...extracted); } } diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap index 6daba7f07..4aa9be33d 100644 --- a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -3,7 +3,9 @@ exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CreateCartRequestDTO.php 1`] = ` " createReadEntity.json > generates correct content for CreateCartResponseDTO.php 1`] = ` " createReadEntity.json > generates correct content for ReadCartResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for DTO/CartCreateDTO.php 1`] = ` " Initial line items to add to the cart - */ - public ?array $lineItems = null, - /** Date and time the cart was last modified */ - #[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)] - public ?string $updatedAt = null, - ) { - } -} -" -`; +namespace DTO; -exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for shared/CartCreateDTO.php 1`] = ` -" createReadEntity.json > generates correct content for shared/CartDTO.php 1`] = ` +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for DTO/CartDTO.php 1`] = ` " createReadEntity.json > generates correct content for shared/LineItemDTO.php 1`] = ` +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for DTO/LineItemDTO.php 1`] = ` " createReadEntity.json > generates correct content for ReadCartResponseDTO.php 1`] = ` +" Initial line items to add to the cart + */ + public ?array $lineItems = null, + /** Date and time the cart was last modified */ + #[Assert\\DateTime(format: \\Shopware\\Core\\Defaults::STORAGE_DATE_TIME_FORMAT)] + public ?string $updatedAt = null, + ) { + } +} +" +`; + exports[`php-dto snapshot tests > createReadEntity.json > generates correct content with namespace 1`] = ` " createReadEntity.json > generates expected fil [ "CreateCartRequestDTO.php", "CreateCartResponseDTO.php", + "DTO/CartCreateDTO.php", + "DTO/CartDTO.php", + "DTO/LineItemDTO.php", "ReadCartResponseDTO.php", - "shared/CartCreateDTO.php", - "shared/CartDTO.php", - "shared/LineItemDTO.php", ] `; exports[`php-dto snapshot tests > createReadEntity.json > generates expected number of files 1`] = `6`; -exports[`php-dto snapshot tests > formatAssertions.json > generates correct content for shared/UserProfileDTO.php 1`] = ` +exports[`php-dto snapshot tests > formatAssertions.json > generates correct content for DTO/UserProfileDTO.php 1`] = ` " formatAssertions.json > generates correct content with namespace 1`] = ` " formatAssertions.json > generates expected file names 1`] = ` [ - "shared/UserProfileDTO.php", + "DTO/UserProfileDTO.php", ] `; @@ -323,6 +341,8 @@ exports[`php-dto snapshot tests > formatAssertions.json > generates expected num exports[`php-dto snapshot tests > invalidNames.json > generates correct content for Api-infoRequestDTO.php 1`] = ` " invalidNames.json > generates correct content for Api-infoResponseDTO.php 1`] = ` " invalidNames.json > generates correct content for shared/Simple-ProductDTO.php 1`] = ` +exports[`php-dto snapshot tests > invalidNames.json > generates correct content for DTO/Simple-ProductDTO.php 1`] = ` " invalidNames.json > generates correct content for shared/errorDTO.php 1`] = ` +exports[`php-dto snapshot tests > invalidNames.json > generates correct content for DTO/errorDTO.php 1`] = ` " invalidNames.json > generates correct content for shared/errorResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > invalidNames.json > generates correct content for DTO/errorResponseDTO.php 1`] = ` " invalidNames.json > generates correct content with namespace 1`] = ` " invalidNames.json > generates expected file na [ "Api-infoRequestDTO.php", "Api-infoResponseDTO.php", - "shared/Simple-ProductDTO.php", - "shared/errorDTO.php", - "shared/errorResponseDTO.php", + "DTO/Simple-ProductDTO.php", + "DTO/errorDTO.php", + "DTO/errorResponseDTO.php", ] `; exports[`php-dto snapshot tests > invalidNames.json > generates expected number of files 1`] = `5`; -exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for shared/OrderBillingAddressCountryDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/OrderBillingAddressCountryDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/OrderBillingAddressDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/OrderBillingAddressDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/OrderDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/OrderDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/SalesChannelContextContextDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelContextContextDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/SalesChannelContextContextSourceDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelContextContextSourceDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/SalesChannelContextCurrentCustomerGroupDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelContextCurrentCustomerGroupDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/SalesChannelContextDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelContextDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/SalesChannelContextItemRoundingDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelContextItemRoundingDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/SalesChannelContextTaxRulesDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelContextTaxRulesDTO.php 1`] = ` " nestedObjects.json > generates correct content for shared/SalesChannelDTO.php 1`] = ` +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelDTO.php 1`] = ` " nestedObjects.json > generates correct content with namespace 1`] = ` " nestedObjects.json > generates expected file names 1`] = ` [ - "shared/OrderBillingAddressCountryDTO.php", - "shared/OrderBillingAddressDTO.php", - "shared/OrderDTO.php", - "shared/SalesChannelContextContextDTO.php", - "shared/SalesChannelContextContextSourceDTO.php", - "shared/SalesChannelContextCurrentCustomerGroupDTO.php", - "shared/SalesChannelContextDTO.php", - "shared/SalesChannelContextItemRoundingDTO.php", - "shared/SalesChannelContextTaxRulesDTO.php", - "shared/SalesChannelDTO.php", + "DTO/OrderBillingAddressCountryDTO.php", + "DTO/OrderBillingAddressDTO.php", + "DTO/OrderDTO.php", + "DTO/SalesChannelContextContextDTO.php", + "DTO/SalesChannelContextContextSourceDTO.php", + "DTO/SalesChannelContextCurrentCustomerGroupDTO.php", + "DTO/SalesChannelContextDTO.php", + "DTO/SalesChannelContextItemRoundingDTO.php", + "DTO/SalesChannelContextTaxRulesDTO.php", + "DTO/SalesChannelDTO.php", ] `; exports[`php-dto snapshot tests > nestedObjects.json > generates expected number of files 1`] = `10`; -exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for ReadCartResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > oneOfRequest.json > generates correct content for ImitateCustomerLoginResponseDTO.php 1`] = ` " All items within the cart - */ - public ?array $lineItems = null, - public ?int $totalItems = null, - public ?bool $active = null, - public ?float $taxRate = null, + /** Redirect URL if any */ + public ?string $redirectUrl = null, ) { } } " `; -exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` +exports[`php-dto snapshot tests > oneOfRequest.json > generates correct content for JwtImpersonationPayloadDTO.php 1`] = ` " simpleSchema.json > generates correct content for ReadProductResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > oneOfRequest.json > generates correct content for LegacyImpersonationPayloadDTO.php 1`] = ` " simpleSchema.json > generates correct content for SendContactMailRequestDTO.php 1`] = ` +exports[`php-dto snapshot tests > oneOfRequest.json > generates correct content with namespace 1`] = ` " simpleSchema.json > generates correct content for attributes/PreserveNull.php 1`] = ` -" oneOfRequest.json > generates expected file names 1`] = ` +[ + "ImitateCustomerLoginResponseDTO.php", + "JwtImpersonationPayloadDTO.php", + "LegacyImpersonationPayloadDTO.php", +] `; -exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for shared/CalculatedPriceDTO.php 1`] = ` +exports[`php-dto snapshot tests > oneOfRequest.json > generates expected number of files 1`] = `3`; + +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for DTO/CalculatedPriceDTO.php 1`] = ` " simpleSchema.json > generates correct content for shared/CartDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for DTO/CartDTO.php 1`] = ` " simpleSchema.json > generates correct content for shared/DefaultValuesDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for DTO/DefaultValuesDTO.php 1`] = ` " simpleSchema.json > generates correct content for shared/LineItemDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for DTO/LineItemDTO.php 1`] = ` " simpleSchema.json > generates correct content for shared/NavigationTypeDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for DTO/NavigationTypeDTO.php 1`] = ` " simpleSchema.json > generates correct content for shared/NullableUnionDTO.php 1`] = ` +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for DTO/NullableUnionDTO.php 1`] = ` " simpleSchema.json > generates correct content for ReadCartResponseDTO.php 1`] = ` +" All items within the cart + */ + public ?array $lineItems = null, + public ?int $totalItems = null, + public ?bool $active = null, + public ?float $taxRate = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` +" simpleSchema.json > generates correct content for ReadProductResponseDTO.php 1`] = ` +" simpleSchema.json > generates correct content for SendContactMailRequestDTO.php 1`] = ` +" simpleSchema.json > generates correct content for attributes/PreserveNull.php 1`] = ` +" simpleSchema.json > generates correct content with namespace 1`] = ` " simpleSchema.json > generates expected file names 1`] = ` [ + "DTO/CalculatedPriceDTO.php", + "DTO/CartDTO.php", + "DTO/DefaultValuesDTO.php", + "DTO/LineItemDTO.php", + "DTO/NavigationTypeDTO.php", + "DTO/NullableUnionDTO.php", "ReadCartResponseDTO.php", "ReadProductRequestDTO.php", "ReadProductResponseDTO.php", "SendContactMailRequestDTO.php", "attributes/PreserveNull.php", - "shared/CalculatedPriceDTO.php", - "shared/CartDTO.php", - "shared/DefaultValuesDTO.php", - "shared/LineItemDTO.php", - "shared/NavigationTypeDTO.php", - "shared/NullableUnionDTO.php", ] `; @@ -1033,6 +1224,8 @@ exports[`php-dto snapshot tests > simpleSchema.json > generates expected number exports[`php-dto snapshot tests > tagSchema.json > generates correct content for AddLineItemRequestDTO.php 1`] = ` " tagSchema.json > generates correct content for AddLineItemResponseDTO.php 1`] = ` " tagSchema.json > generates correct content for ReadCartResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for DTO/CartDTO.php 1`] = ` " tagSchema.json > generates correct content for ReadCategoriesResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for DTO/CategoryDTO.php 1`] = ` " - */ - public ?array $elements = null, + public ?string $id = null, + public ?string $name = null, ) { } } " `; -exports[`php-dto snapshot tests > tagSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for DTO/LineItemDTO.php 1`] = ` " tagSchema.json > generates correct content for ReadProductResponseDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for DTO/MediaDTO.php 1`] = ` " tagSchema.json > generates correct content for shared/CartDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for DTO/ProductDTO.php 1`] = ` " - */ - public ?array $lineItems = null, + #[Assert\\Regex(pattern: '/^[0-9a-f]{32}$/')] + public string $id, + #[Assert\\NotBlank] + public string $name, + public ?MediaDTO $cover = null, ) { } } " `; -exports[`php-dto snapshot tests > tagSchema.json > generates correct content for shared/CategoryDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for ReadCartResponseDTO.php 1`] = ` " + */ + public ?array $lineItems = null, ) { } } " `; -exports[`php-dto snapshot tests > tagSchema.json > generates correct content for shared/LineItemDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for ReadCategoriesResponseDTO.php 1`] = ` " + */ + public ?array $elements = null, ) { } } " `; -exports[`php-dto snapshot tests > tagSchema.json > generates correct content for shared/MediaDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for ReadProductRequestDTO.php 1`] = ` " tagSchema.json > generates correct content for shared/ProductDTO.php 1`] = ` +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for ReadProductResponseDTO.php 1`] = ` " tagSchema.json > generates correct content with namespace 1`] = ` " tagSchema.json > generates expected file names [ "AddLineItemRequestDTO.php", "AddLineItemResponseDTO.php", + "DTO/CartDTO.php", + "DTO/CategoryDTO.php", + "DTO/LineItemDTO.php", + "DTO/MediaDTO.php", + "DTO/ProductDTO.php", "ReadCartResponseDTO.php", "ReadCategoriesResponseDTO.php", "ReadProductRequestDTO.php", "ReadProductResponseDTO.php", - "shared/CartDTO.php", - "shared/CategoryDTO.php", - "shared/LineItemDTO.php", - "shared/MediaDTO.php", - "shared/ProductDTO.php", ] `; diff --git a/packages/api-gen/tests/php-dto/fixtures/oneOfRequest.json b/packages/api-gen/tests/php-dto/fixtures/oneOfRequest.json new file mode 100644 index 000000000..5f6a08620 --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/oneOfRequest.json @@ -0,0 +1,81 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/account/login/imitate-customer": { + "post": { + "tags": ["Login & Registration"], + "summary": "Imitate the log in as a customer", + "description": "Imitate the log in as a customer given a generated token.", + "operationId": "imitateCustomerLogin", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "title": "LegacyImpersonationPayload", + "type": "object", + "additionalProperties": false, + "required": ["token", "customerId", "userId"], + "properties": { + "token": { + "description": "Generated customer impersonation token (legacy UUID token).", + "format": "uuid", + "type": "string" + }, + "customerId": { + "description": "ID of the customer.", + "format": "uuid", + "type": "string" + }, + "userId": { + "description": "ID of the user who generated the token.", + "format": "uuid", + "type": "string" + } + } + }, + { + "title": "JwtImpersonationPayload", + "type": "object", + "additionalProperties": false, + "required": ["token"], + "properties": { + "token": { + "description": "Generated customer impersonation JWT token.", + "type": "string" + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Returns context token", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirectUrl": { + "description": "Redirect URL if any", + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": {} + } +} diff --git a/packages/api-gen/tests/php-dto/generator.test.ts b/packages/api-gen/tests/php-dto/generator.test.ts index 05d427f52..0164aa632 100644 --- a/packages/api-gen/tests/php-dto/generator.test.ts +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -822,7 +822,7 @@ describe("generator", () => { }); describe("generateAllFiles", () => { - it("puts operation DTOs in root and component DTOs in shared/", () => { + it("puts operation DTOs in root and component DTOs in DTO/", () => { const dtos: DtoDefinition[] = [ { name: "ReadProductResponseDTO", @@ -852,14 +852,14 @@ describe("generator", () => { }, ]; - const files = generateAllFiles(dtos); + const { files } = generateAllFiles(dtos); expect(files).toHaveLength(2); expect(files[0]?.fileName).toBe("ReadProductResponseDTO.php"); - expect(files[1]?.fileName).toBe("shared/ProductDTO.php"); + expect(files[1]?.fileName).toBe("DTO/ProductDTO.php"); }); - it("defaults to shared/ when source is not set", () => { + it("defaults to DTO/ when source is not set", () => { const dtos: DtoDefinition[] = [ { name: "CartDTO", @@ -875,13 +875,13 @@ describe("generator", () => { }, ]; - const files = generateAllFiles(dtos); + const { files } = generateAllFiles(dtos); expect(files).toHaveLength(1); - expect(files[0]?.fileName).toBe("shared/CartDTO.php"); + expect(files[0]?.fileName).toBe("DTO/CartDTO.php"); }); - it("without --namespace: shared DTOs get namespace Shared, root DTOs get use imports", () => { + it("without --namespace: component DTOs get namespace DTO, root DTOs get use imports", () => { const dtos: DtoDefinition[] = [ { name: "RegisterRequestDTO", @@ -924,18 +924,18 @@ describe("generator", () => { }, ]; - const files = generateAllFiles(dtos); + const { files } = generateAllFiles(dtos); const requestContent = files[0]?.content ?? ""; expect(requestContent).not.toContain("namespace "); - expect(requestContent).toContain("use Shared\\CustomerAddressDTO;"); + expect(requestContent).toContain("use DTO\\CustomerAddressDTO;"); const addressContent = files[1]?.content ?? ""; - expect(addressContent).toContain("namespace Shared;"); - expect(addressContent).toContain("use Shared\\CountryDTO;"); + expect(addressContent).toContain("namespace DTO;"); + expect(addressContent).toContain("use DTO\\CountryDTO;"); const countryContent = files[2]?.content ?? ""; - expect(countryContent).toContain("namespace Shared;"); + expect(countryContent).toContain("namespace DTO;"); }); it("adds namespace and use imports with --namespace", () => { @@ -981,17 +981,17 @@ describe("generator", () => { }, ]; - const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); + const { files } = generateAllFiles(dtos, { namespace: "App\\DTO" }); const requestContent = files[0]?.content ?? ""; expect(requestContent).toContain("namespace App\\DTO;"); expect(requestContent).toContain( - "use App\\DTO\\Shared\\CustomerAddressDTO;", + "use App\\DTO\\DTO\\CustomerAddressDTO;", ); const addressContent = files[1]?.content ?? ""; - expect(addressContent).toContain("namespace App\\DTO\\Shared;"); - expect(addressContent).toContain("use App\\DTO\\Shared\\CountryDTO;"); + expect(addressContent).toContain("namespace App\\DTO\\DTO;"); + expect(addressContent).toContain("use App\\DTO\\DTO\\CountryDTO;"); }); it("operation DTOs keep base namespace, component DTOs get Shared suffix", () => { @@ -1024,11 +1024,11 @@ describe("generator", () => { }, ]; - const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); + const { files } = generateAllFiles(dtos, { namespace: "App\\DTO" }); expect(files[0]?.content).toContain("namespace App\\DTO;"); expect(files[0]?.content).not.toContain("Shared"); - expect(files[1]?.content).toContain("namespace App\\DTO\\Shared;"); + expect(files[1]?.content).toContain("namespace App\\DTO\\DTO;"); }); it("omits PreserveNull.php when no property is nullable", () => { @@ -1048,7 +1048,7 @@ describe("generator", () => { }, ]; - const files = generateAllFiles(dtos); + const { files } = generateAllFiles(dtos); expect( files.every((f) => f.fileName !== "attributes/PreserveNull.php"), @@ -1072,7 +1072,7 @@ describe("generator", () => { }, ]; - const files = generateAllFiles(dtos); + const { files } = generateAllFiles(dtos); expect(files[0]?.fileName).toBe("attributes/PreserveNull.php"); expect(files[0]?.content).toContain("namespace Attributes;"); @@ -1096,10 +1096,10 @@ describe("generator", () => { }, ]; - const files = generateAllFiles(dtos, { namespace: "App\\DTO" }); + const { files } = generateAllFiles(dtos, { namespace: "App\\DTO" }); const productContent = files[1]?.content ?? ""; - expect(productContent).toContain("namespace App\\DTO\\Shared;"); + expect(productContent).toContain("namespace App\\DTO\\DTO;"); expect(productContent).toContain( "use App\\DTO\\Attributes\\PreserveNull;", ); diff --git a/packages/api-gen/tests/php-dto/phpDto.test.ts b/packages/api-gen/tests/php-dto/phpDto.test.ts index 69a77e6b5..ba31f6eed 100644 --- a/packages/api-gen/tests/php-dto/phpDto.test.ts +++ b/packages/api-gen/tests/php-dto/phpDto.test.ts @@ -1,5 +1,6 @@ import { existsSync, + mkdirSync, readFileSync, readdirSync, rmSync, @@ -26,8 +27,18 @@ function collectPhpFiles(dir: string, base?: string): string[] { } const TEST_OUTPUT_DIR = resolve(__dirname, "test-output-phpDto"); +const TEST_CONFIG_DIR = resolve(__dirname, "test-output-phpDto-configs"); const FIXTURE_SCHEMA = resolve(__dirname, "fixtures/simpleSchema.json"); +let configCounter = 0; +function writeConfig(overrides: Record = {}): string { + mkdirSync(TEST_CONFIG_DIR, { recursive: true }); + const configPath = resolve(TEST_CONFIG_DIR, `config-${configCounter++}.json`); + const config = { schemaUrl: "http://unused.test/schema.json", ...overrides }; + writeFileSync(configPath, JSON.stringify(config), "utf-8"); + return configPath; +} + describe("phpDto command", () => { beforeAll(() => { if (existsSync(TEST_OUTPUT_DIR)) { @@ -37,26 +48,28 @@ describe("phpDto command", () => { afterAll(() => { rmSync(TEST_OUTPUT_DIR, { recursive: true, force: true }); + rmSync(TEST_CONFIG_DIR, { recursive: true, force: true }); }); - it("generate: creates PHP files in output directory with shared/ for components", async () => { + it("generate: creates PHP files in output directory with DTO/ for components", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "generate"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }); expect(existsSync(outputDir)).toBe(true); const files = collectPhpFiles(outputDir); expect(files.length).toBeGreaterThan(0); - expect(files).toContain("shared/CartDTO.php"); + expect(files).toContain("DTO/CartDTO.php"); expect(files).toContain("SendContactMailRequestDTO.php"); const cartContent = readFileSync( - resolve(outputDir, "shared/CartDTO.php"), + resolve(outputDir, "DTO/CartDTO.php"), "utf-8", ); expect(cartContent).toContain("class CartDTO"); @@ -65,48 +78,53 @@ describe("phpDto command", () => { it("generate: with namespace adds namespace to files", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "generate-ns"); + const configPath = writeConfig({ + outputDir, + namespace: "App\\DTO", + }); await phpDto({ action: "generate", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, - namespace: "App\\DTO", }); const cartContent = readFileSync( - resolve(outputDir, "shared/CartDTO.php"), + resolve(outputDir, "DTO/CartDTO.php"), "utf-8", ); - expect(cartContent).toContain("namespace App\\DTO\\Shared;"); + expect(cartContent).toContain("namespace App\\DTO\\DTO;"); }); it("generate: cleans output directory on re-run", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "generate-clean"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }); - writeFileSync(resolve(outputDir, "stale.php"), " { const outputDir = resolve(TEST_OUTPUT_DIR, "check-pass"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }); const spy = vi.spyOn(process, "exit").mockImplementation(() => { @@ -115,8 +133,8 @@ describe("phpDto command", () => { await phpDto({ action: "check", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }); expect(spy).not.toHaveBeenCalled(); @@ -125,14 +143,15 @@ describe("phpDto command", () => { it("check: fails when files are missing", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "check-missing"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }); - rmSync(resolve(outputDir, "shared/CartDTO.php")); + rmSync(resolve(outputDir, "DTO/CartDTO.php")); const spy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit called"); @@ -141,8 +160,8 @@ describe("phpDto command", () => { await expect( phpDto({ action: "check", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }), ).rejects.toThrow("process.exit called"); @@ -152,17 +171,15 @@ describe("phpDto command", () => { it("check: fails when content differs", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "check-diff"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }); - writeFileSync( - resolve(outputDir, "shared/CartDTO.php"), - " { throw new Error("process.exit called"); @@ -171,8 +188,8 @@ describe("phpDto command", () => { await expect( phpDto({ action: "check", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }), ).rejects.toThrow("process.exit called"); @@ -182,11 +199,12 @@ describe("phpDto command", () => { it("check: fails when extra files exist", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "check-extra"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }); writeFileSync(resolve(outputDir, "ExtraDTO.php"), " { await expect( phpDto({ action: "check", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, }), ).rejects.toThrow("process.exit called"); @@ -208,35 +226,48 @@ describe("phpDto command", () => { }); it("throws when schema file does not exist", async () => { + const configPath = writeConfig(); + await expect( phpDto({ action: "generate", + config: configPath, schemaFile: "/nonexistent/schema.json", - outputDir: TEST_OUTPUT_DIR, }), ).rejects.toThrow("Schema file not found"); }); + it("throws when config file does not exist", async () => { + await expect( + phpDto({ + action: "generate", + config: "/nonexistent/config.json", + schemaFile: FIXTURE_SCHEMA, + }), + ).rejects.toThrow("Config file not found"); + }); + describe("name handling", () => { const INVALID_SCHEMA = resolve(__dirname, "fixtures/invalidNames.json"); it("default: converts hyphenated names to PascalCase", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "name-pascal"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: INVALID_SCHEMA, - outputDir, }); const files = collectPhpFiles(outputDir); - expect(files).toContain("shared/SimpleProductDTO.php"); + expect(files).toContain("DTO/SimpleProductDTO.php"); expect(files).toContain("ApiInfoRequestDTO.php"); - expect(files).not.toContain("shared/Simple-ProductDTO.php"); + expect(files).not.toContain("DTO/Simple-ProductDTO.php"); expect(files).not.toContain("Api-infoRequestDTO.php"); const content = readFileSync( - resolve(outputDir, "shared/SimpleProductDTO.php"), + resolve(outputDir, "DTO/SimpleProductDTO.php"), "utf-8", ); expect(content).toContain("class SimpleProductDTO"); @@ -244,19 +275,20 @@ describe("phpDto command", () => { it("default: uppercases lowercase names in both file and class", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "name-lowercase"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: INVALID_SCHEMA, - outputDir, }); const files = collectPhpFiles(outputDir); - expect(files).toContain("shared/ErrorDTO.php"); - expect(files).not.toContain("shared/errorDTO.php"); + expect(files).toContain("DTO/ErrorDTO.php"); + expect(files).not.toContain("DTO/errorDTO.php"); const content = readFileSync( - resolve(outputDir, "shared/ErrorDTO.php"), + resolve(outputDir, "DTO/ErrorDTO.php"), "utf-8", ); expect(content).toContain("class ErrorDTO"); @@ -265,15 +297,16 @@ describe("phpDto command", () => { it("default: updates $ref type references to renamed DTOs", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "name-pascal-refs"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: INVALID_SCHEMA, - outputDir, }); const content = readFileSync( - resolve(outputDir, "shared/ErrorResponseDTO.php"), + resolve(outputDir, "DTO/ErrorResponseDTO.php"), "utf-8", ); expect(content).toContain("class ErrorResponseDTO"); @@ -283,12 +316,13 @@ describe("phpDto command", () => { it("rawNames: throws listing invalid class names", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "name-error"); + const configPath = writeConfig({ outputDir }); await expect( phpDto({ action: "generate", + config: configPath, schemaFile: INVALID_SCHEMA, - outputDir, rawNames: true, }), ).rejects.toThrow("Invalid PHP class names found"); @@ -296,12 +330,13 @@ describe("phpDto command", () => { it("rawNames: includes all invalid names in error message", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "name-error-list"); + const configPath = writeConfig({ outputDir }); try { await phpDto({ action: "generate", + config: configPath, schemaFile: INVALID_SCHEMA, - outputDir, rawNames: true, }); expect.unreachable("should have thrown"); @@ -315,11 +350,12 @@ describe("phpDto command", () => { it("rawNames: passes for schemas with valid names", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "name-error-valid"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: FIXTURE_SCHEMA, - outputDir, rawNames: true, }); @@ -332,50 +368,56 @@ describe("phpDto command", () => { it("generates only DTOs for the specified tag and its dependencies", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "tag-cart"); + const configPath = writeConfig({ outputDir, tag: "Cart" }); await phpDto({ action: "generate", + config: configPath, schemaFile: TAG_SCHEMA, - outputDir, - tag: "Cart", }); const files = collectPhpFiles(outputDir); - expect(files).toContain("shared/CartDTO.php"); - expect(files).toContain("shared/LineItemDTO.php"); - expect(files).toContain("shared/ProductDTO.php"); - expect(files).toContain("shared/MediaDTO.php"); + expect(files).toContain("DTO/CartDTO.php"); + expect(files).toContain("DTO/LineItemDTO.php"); + expect(files).toContain("DTO/ProductDTO.php"); + expect(files).toContain("DTO/MediaDTO.php"); expect(files).toContain("AddLineItemRequestDTO.php"); - expect(files).not.toContain("shared/CategoryDTO.php"); + expect(files).not.toContain("DTO/CategoryDTO.php"); expect(files).not.toContain("ReadCategoriesResponseDTO.php"); }); it("without tag generates all DTOs", async () => { const outputDir = resolve(TEST_OUTPUT_DIR, "tag-none"); + const configPath = writeConfig({ outputDir }); await phpDto({ action: "generate", + config: configPath, schemaFile: TAG_SCHEMA, - outputDir, }); const files = collectPhpFiles(outputDir); - expect(files).toContain("shared/CartDTO.php"); - expect(files).toContain("shared/CategoryDTO.php"); - expect(files).toContain("shared/ProductDTO.php"); + expect(files).toContain("DTO/CartDTO.php"); + expect(files).toContain("DTO/CategoryDTO.php"); + expect(files).toContain("DTO/ProductDTO.php"); expect(files).toContain("ReadCategoriesResponseDTO.php"); }); it("with non-matching tag produces no DTOs", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "tag-empty"); + const configPath = writeConfig({ + outputDir, + tag: "NonExistent", + }); + await expect( phpDto({ action: "generate", + config: configPath, schemaFile: TAG_SCHEMA, - outputDir: resolve(TEST_OUTPUT_DIR, "tag-empty"), - tag: "NonExistent", }), ).resolves.toBeUndefined(); }); diff --git a/packages/api-gen/tests/php-dto/schemaParser.test.ts b/packages/api-gen/tests/php-dto/schemaParser.test.ts index e51f70c32..fa480a82f 100644 --- a/packages/api-gen/tests/php-dto/schemaParser.test.ts +++ b/packages/api-gen/tests/php-dto/schemaParser.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { OpenApiSchema } from "../../src/php-dto/openApiTypes"; import { parseAllDtos, parseComponentSchemas, @@ -487,4 +488,52 @@ describe("schemaParser", () => { expect(dtos).toHaveLength(0); }); }); + + describe("oneOf request bodies", () => { + let oneOfSchema: Record; + + beforeAll(async () => { + oneOfSchema = JSON.parse( + readFileSync(resolve(__dirname, "fixtures/oneOfRequest.json"), "utf-8"), + ); + }); + + it("generates a separate DTO per oneOf variant using title", () => { + const dtos = parseRequestBodies(oneOfSchema as OpenApiSchema); + const names = dtos.map((d) => d.name); + + expect(names).toContain("LegacyImpersonationPayloadDTO"); + expect(names).toContain("JwtImpersonationPayloadDTO"); + expect(names).not.toContain("ImitateCustomerLoginRequestDTO"); + }); + + it("variant DTOs have correct properties", () => { + const dtos = parseRequestBodies(oneOfSchema as OpenApiSchema); + + const legacy = dtos.find( + (d) => d.name === "LegacyImpersonationPayloadDTO", + ); + expect(legacy).toBeDefined(); + expect(legacy?.properties.map((p) => p.name)).toEqual( + expect.arrayContaining(["token", "customerId", "userId"]), + ); + expect(legacy?.properties.find((p) => p.name === "token")?.required).toBe( + true, + ); + + const jwt = dtos.find((d) => d.name === "JwtImpersonationPayloadDTO"); + expect(jwt).toBeDefined(); + expect(jwt?.properties).toHaveLength(1); + expect(jwt?.properties[0]?.name).toBe("token"); + }); + + it("variant DTOs are marked as operation source with endpoint path", () => { + const dtos = parseRequestBodies(oneOfSchema as OpenApiSchema); + + for (const dto of dtos) { + expect(dto.source).toBe("operation"); + expect(dto.endpointPath).toBe("/account/login/imitate-customer"); + } + }); + }); }); diff --git a/packages/api-gen/tests/php-dto/snapshots.test.ts b/packages/api-gen/tests/php-dto/snapshots.test.ts index 2471f7586..e6e85ef8b 100644 --- a/packages/api-gen/tests/php-dto/snapshots.test.ts +++ b/packages/api-gen/tests/php-dto/snapshots.test.ts @@ -17,8 +17,8 @@ describe("php-dto snapshot tests", () => { readFileSync(resolve(FIXTURES_DIR, fixtureFile), "utf-8"), ); const dtos = parseAllDtos(schema); - const files = generateAllFiles(dtos); - const filesWithNamespace = generateAllFiles(dtos, { + const { files } = generateAllFiles(dtos); + const { files: filesWithNamespace } = generateAllFiles(dtos, { namespace: "App\\DTO", }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a8f9ca7f..4fb82f409 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,7 +338,7 @@ importers: version: 22.13.14 '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) '@vueuse/core': specifier: 14.1.0 version: 14.1.0(vue@3.5.27(typescript@5.9.3)) @@ -375,7 +375,7 @@ importers: version: 22.13.14 '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) vite: specifier: 7.3.1 version: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) @@ -478,7 +478,7 @@ importers: version: 22.13.14 '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) '@vueuse/core': specifier: 14.1.0 version: 14.1.0(vue@3.5.27(typescript@5.9.3)) @@ -524,7 +524,7 @@ importers: version: 22.13.14 '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) vite: specifier: 7.3.1 version: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) @@ -577,7 +577,7 @@ importers: version: 22.13.14 '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) vite: specifier: 7.3.1 version: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) @@ -740,7 +740,7 @@ importers: version: 22.13.14 '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) typescript: specifier: 5.9.3 version: 5.9.3 @@ -807,7 +807,7 @@ importers: version: 22.13.14 '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) vite: specifier: 7.3.1 version: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) @@ -1018,6 +1018,9 @@ importers: openapi-typescript: specifier: 7.8.0 version: 7.8.0(typescript@5.9.3) + picomatch: + specifier: ^4.0.3 + version: 4.0.3 prettier: specifier: 3.7.4 version: 3.7.4 @@ -1034,6 +1037,9 @@ importers: '@biomejs/biome': specifier: 1.8.3 version: 1.8.3 + '@types/picomatch': + specifier: ^4.0.2 + version: 4.0.2 '@types/prettier': specifier: 3.0.0 version: 3.0.0 @@ -1312,7 +1318,7 @@ importers: version: link:../../packages/composables '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': specifier: 5.1.3 version: 5.1.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) @@ -1571,7 +1577,7 @@ importers: version: 22.13.14 '@vitejs/plugin-vue': specifier: 6.0.3 - version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) typescript: specifier: 5.9.3 version: 5.9.3 @@ -1584,8 +1590,8 @@ importers: packages: - '@acemir/cssom@0.9.31': - resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@acemir/cssom@0.9.24': + resolution: {integrity: sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==} '@adyen/adyen-web@6.5.1': resolution: {integrity: sha512-IAFn4gFq/XSTrErmXrJXGVtkJ7rpCqc2bZmSu5mvW9zfjP1kQe2lDDv1kGUdhv0lxlV3q2FBOJocUQv2M+6RAA==} @@ -1689,11 +1695,11 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} - '@asamuzakjp/css-color@4.1.2': - resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + '@asamuzakjp/css-color@4.0.5': + resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} - '@asamuzakjp/dom-selector@6.8.1': - resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + '@asamuzakjp/dom-selector@6.7.4': + resolution: {integrity: sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==} '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} @@ -1910,10 +1916,6 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} - engines: {node: '>=6.9.0'} - '@babel/standalone@7.26.6': resolution: {integrity: sha512-h1mkoNFYCqDkS+vTLGzsQYvp1v1qbuugk4lOtb/oyjArZ+EtreAaxcSYg3rSIzWZRQOjx4iqGe7A8NRYIMSTTw==} engines: {node: '>=6.9.0'} @@ -2130,36 +2132,37 @@ packages: resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} - '@csstools/color-helpers@6.0.2': - resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} - engines: {node: '>=20.19.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} - '@csstools/css-calc@3.1.1': - resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} - engines: {node: '>=20.19.0'} + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-color-parser@4.0.2': - resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} - engines: {node: '>=20.19.0'} + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-parser-algorithms@4.0.0': - resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} - engines: {node: '>=20.19.0'} + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.28': - resolution: {integrity: sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==} + '@csstools/css-syntax-patches-for-csstree@1.0.19': + resolution: {integrity: sha512-QW5/SM2ARltEhoKcmRI1LoLf3/C7dHGswwCnfLcoMgqurBT4f8GvwXMgAbK/FwcxthmJRK5MGTtddj0yQn0J9g==} + engines: {node: '>=18'} - '@csstools/css-tokenizer@4.0.0': - resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} - engines: {node: '>=20.19.0'} + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} @@ -3223,8 +3226,8 @@ packages: resolution: {integrity: sha512-5XUvZuffe3KetyhbWwd4n2ktd7wraocCYw10tlM+/u/95iAz29GjNiuNxbCD1T6Bn1MyGc4QLVNKOWhzJkVFAw==} engines: {node: ^14.16.0 || >=16.0.0} - '@netlify/open-api@2.49.2': - resolution: {integrity: sha512-0YqRW4Yn4XmZjljmuIMakwOS6ERtwpkMOz3F1j4QoFFdatZCiJ7PgkRzCGt61cR4iIBcA+mgIFmGSdjAqDjJxQ==} + '@netlify/open-api@2.40.0': + resolution: {integrity: sha512-Dp4lilDnkRKGWnljGkFVxfoh1wsWqxheE5/ZOf/sMZPsh3jGu5QZ4hVLEidzXYB/zIKFFqLaUbP2XYVxTqWqyQ==} engines: {node: '>=14.8.0'} '@netlify/runtime-utils@1.3.1': @@ -5463,6 +5466,9 @@ packages: '@types/paypal-checkout-components@4.0.8': resolution: {integrity: sha512-Z3IWbFPGdgL3O+Bg+TyVmMT8S3uGBsBjw3a8uRNR4OlYWa9m895djENErJMYU8itoki9rtcQMzoHOSFn8NFb1A==} + '@types/picomatch@4.0.2': + resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} + '@types/prettier@3.0.0': resolution: {integrity: sha512-mFMBfMOz8QxhYVbuINtswBp9VL2b4Y0QqYHwqLz3YbgtfAcat2Dl6Y1o4e22S/OVE6Ebl9m7wWiMT2lSbAs1wA==} deprecated: This is a stub types definition. prettier provides its own type definitions, so you do not need this installed. @@ -6374,12 +6380,12 @@ packages: resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} engines: {node: '>=18.0.0'} - '@whatwg-node/fetch@0.10.13': - resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + '@whatwg-node/fetch@0.10.11': + resolution: {integrity: sha512-eR8SYtf9Nem1Tnl0IWrY33qJ5wCtIWlt3Fs3c6V4aAaTFLtkEQErXu3SSZg/XCHrj9hXSJ8/8t+CdMk5Qec/ZA==} engines: {node: '>=18.0.0'} - '@whatwg-node/node-fetch@0.8.5': - resolution: {integrity: sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==} + '@whatwg-node/node-fetch@0.8.1': + resolution: {integrity: sha512-cQmQEo7IsI0EPX9VrwygXVzrVlX43Jb7/DBZSmpnC7xH4xkyOnn/HykHpTaQk7TUs7zh59A5uTGqx3p2Ouzffw==} engines: {node: '>=18.0.0'} '@whatwg-node/promise-helpers@1.3.2': @@ -7160,8 +7166,8 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - cssstyle@5.3.7: - resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + cssstyle@5.3.3: + resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} engines: {node: '>=20'} csstype@3.2.3: @@ -7171,8 +7177,8 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-urls@6.0.1: - resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} dataloader@1.4.0: @@ -8003,12 +8009,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -9044,8 +9050,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -10172,9 +10178,6 @@ packages: preact@10.28.2: resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} - preact@10.28.4: - resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -10693,10 +10696,6 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} - sax@1.4.4: - resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} - engines: {node: '>=11.0.0'} - saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -11109,8 +11108,8 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} - terser-webpack-plugin@5.3.16: - resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} + terser-webpack-plugin@5.3.14: + resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -11192,11 +11191,11 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tldts-core@7.0.23: - resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + tldts-core@7.0.17: + resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} - tldts@7.0.23: - resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + tldts@7.0.17: + resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==} hasBin: true to-regex-range@5.0.1: @@ -12340,8 +12339,8 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - watchpack@2.5.1: - resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} web-namespaces@2.0.1: @@ -12358,8 +12357,8 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webidl-conversions@8.0.1: - resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} engines: {node: '>=20'} webpack-sources@3.2.3: @@ -12370,10 +12369,6 @@ packages: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} - webpack-sources@3.3.4: - resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} - engines: {node: '>=10.13.0'} - webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -12395,7 +12390,6 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.18: resolution: {integrity: sha512-ltN7j66EneWn5TFDO4L9inYC1D+Czsxlrw2SalgjMmEMkLfA5SIZxEFdE6QtHFiiM6Q7WL32c7AkI3w6yxM84Q==} @@ -12408,10 +12402,6 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-mimetype@5.0.0: - resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} - engines: {node: '>=20'} - whatwg-url@15.1.0: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} @@ -12610,10 +12600,6 @@ packages: resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} engines: {node: '>=12.20'} - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - yocto-spinner@0.2.3: resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} engines: {node: '>=18.19'} @@ -12663,7 +12649,7 @@ packages: snapshots: - '@acemir/cssom@0.9.31': + '@acemir/cssom@0.9.24': optional: true '@adyen/adyen-web@6.5.1': @@ -12811,22 +12797,22 @@ snapshots: '@antfu/utils@0.7.10': {} - '@asamuzakjp/css-color@4.1.2': + '@asamuzakjp/css-color@4.0.5': dependencies: - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - lru-cache: 11.2.6 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.2 optional: true - '@asamuzakjp/dom-selector@6.8.1': + '@asamuzakjp/dom-selector@6.7.4': dependencies: '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 css-tree: 3.1.0 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.6 + lru-cache: 11.2.2 optional: true '@asamuzakjp/nwsapi@2.3.9': @@ -13165,8 +13151,6 @@ snapshots: '@babel/runtime@7.28.4': {} - '@babel/runtime@7.28.6': {} - '@babel/standalone@7.26.6': {} '@babel/template@7.27.2': @@ -13455,32 +13439,32 @@ snapshots: dependencies: mime: 3.0.0 - '@csstools/color-helpers@6.0.2': + '@csstools/color-helpers@5.1.0': optional: true - '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 optional: true - '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 optional: true - '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-tokenizer': 3.0.4 optional: true - '@csstools/css-syntax-patches-for-csstree@1.0.28': + '@csstools/css-syntax-patches-for-csstree@1.0.19': optional: true - '@csstools/css-tokenizer@4.0.0': + '@csstools/css-tokenizer@3.0.4': optional: true '@dimforge/rapier3d-compat@0.12.0': {} @@ -14280,7 +14264,7 @@ snapshots: write-file-atomic: 6.0.0 optional: true - '@netlify/open-api@2.49.2': + '@netlify/open-api@2.40.0': optional: true '@netlify/runtime-utils@1.3.1': @@ -15145,7 +15129,7 @@ snapshots: dependencies: '@nuxt/kit': 4.2.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@3.30.0) - '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) autoprefixer: 10.4.22(postcss@8.5.6) consola: 3.4.2 @@ -15206,7 +15190,7 @@ snapshots: dependencies: '@nuxt/kit': 4.2.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@3.30.0) - '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) autoprefixer: 10.4.22(postcss@8.5.6) consola: 3.4.2 @@ -15267,7 +15251,7 @@ snapshots: dependencies: '@nuxt/kit': 4.2.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) - '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) autoprefixer: 10.4.22(postcss@8.5.6) consola: 3.4.2 @@ -15328,7 +15312,7 @@ snapshots: dependencies: '@nuxt/kit': 4.2.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) - '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) autoprefixer: 10.4.22(postcss@8.5.6) consola: 3.4.2 @@ -15389,7 +15373,7 @@ snapshots: dependencies: '@nuxt/kit': 4.2.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) - '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3)) autoprefixer: 10.4.22(postcss@8.5.6) consola: 3.4.2 @@ -17638,6 +17622,8 @@ snapshots: '@types/paypal-checkout-components@4.0.8': {} + '@types/picomatch@4.0.2': {} + '@types/prettier@3.0.0': dependencies: prettier: 3.7.4 @@ -18449,7 +18435,7 @@ snapshots: vite: 5.4.21(@types/node@22.13.14)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0) vue: 3.5.27(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.3(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.53 vite: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) @@ -18693,7 +18679,7 @@ snapshots: mitt: 3.0.1 nanoid: 5.1.6 pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) + vite-hot-client: 2.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - vite @@ -19005,13 +18991,13 @@ snapshots: tslib: 2.8.1 optional: true - '@whatwg-node/fetch@0.10.13': + '@whatwg-node/fetch@0.10.11': dependencies: - '@whatwg-node/node-fetch': 0.8.5 + '@whatwg-node/node-fetch': 0.8.1 urlpattern-polyfill: 10.1.0 optional: true - '@whatwg-node/node-fetch@0.8.5': + '@whatwg-node/node-fetch@0.8.1': dependencies: '@fastify/busboy': 3.2.0 '@whatwg-node/disposablestack': 0.0.6 @@ -19027,7 +19013,7 @@ snapshots: '@whatwg-node/server@0.9.71': dependencies: '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/fetch': 0.10.11 '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 optional: true @@ -19995,12 +19981,11 @@ snapshots: dependencies: css-tree: 2.2.1 - cssstyle@5.3.7: + cssstyle@5.3.3: dependencies: - '@asamuzakjp/css-color': 4.1.2 - '@csstools/css-syntax-patches-for-csstree': 1.0.28 + '@asamuzakjp/css-color': 4.0.5 + '@csstools/css-syntax-patches-for-csstree': 1.0.19 css-tree: 3.1.0 - lru-cache: 11.2.6 optional: true csstype@3.2.3: {} @@ -20008,9 +19993,9 @@ snapshots: data-uri-to-buffer@4.0.1: optional: true - data-urls@6.0.1: + data-urls@6.0.0: dependencies: - whatwg-mimetype: 5.0.0 + whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 optional: true @@ -21387,7 +21372,7 @@ snapshots: instantsearch-ui-components@0.9.0: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.28.4 instantsearch.css@8.5.0: {} @@ -21403,7 +21388,7 @@ snapshots: hogan.js: 3.0.2 htm: 3.1.1 instantsearch-ui-components: 0.9.0 - preact: 10.28.4 + preact: 10.28.2 qs: 6.15.0 search-insights: 2.17.0 @@ -21690,10 +21675,10 @@ snapshots: jsdom@27.1.0: dependencies: - '@acemir/cssom': 0.9.31 - '@asamuzakjp/dom-selector': 6.8.1 - cssstyle: 5.3.7 - data-urls: 6.0.1 + '@acemir/cssom': 0.9.24 + '@asamuzakjp/dom-selector': 6.7.4 + cssstyle: 5.3.3 + data-urls: 6.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 @@ -21704,7 +21689,7 @@ snapshots: symbol-tree: 3.2.4 tough-cookie: 6.0.0 w3c-xmlserializer: 5.0.0 - webidl-conversions: 8.0.1 + webidl-conversions: 8.0.0 whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 @@ -22019,7 +22004,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.6: + lru-cache@11.2.2: optional: true lru-cache@5.1.1: @@ -22728,7 +22713,7 @@ snapshots: needle@3.3.1: dependencies: iconv-lite: 0.6.3 - sax: 1.4.4 + sax: 1.4.1 optional: true neo-async@2.6.2: {} @@ -22737,7 +22722,7 @@ snapshots: netlify@13.3.5: dependencies: - '@netlify/open-api': 2.49.2 + '@netlify/open-api': 2.40.0 lodash-es: 4.17.23 micro-api-client: 3.3.0 node-fetch: 3.3.2 @@ -23746,7 +23731,7 @@ snapshots: p-limit@4.0.0: dependencies: - yocto-queue: 1.2.2 + yocto-queue: 1.2.1 optional: true p-limit@6.2.0: @@ -24125,8 +24110,6 @@ snapshots: preact@10.28.2: {} - preact@10.28.4: {} - prelude-ls@1.2.1: {} prettier@2.8.8: {} @@ -24780,9 +24763,6 @@ snapshots: sax@1.4.1: {} - sax@1.4.4: - optional: true - saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -25256,7 +25236,7 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.16(esbuild@0.25.12)(webpack@5.91.0(esbuild@0.25.12)): + terser-webpack-plugin@5.3.14(esbuild@0.25.12)(webpack@5.91.0(esbuild@0.25.12)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 @@ -25267,7 +25247,7 @@ snapshots: optionalDependencies: esbuild: 0.25.12 - terser-webpack-plugin@5.3.16(webpack@5.91.0): + terser-webpack-plugin@5.3.14(webpack@5.91.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 @@ -25344,12 +25324,12 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@7.0.23: + tldts-core@7.0.17: optional: true - tldts@7.0.23: + tldts@7.0.17: dependencies: - tldts-core: 7.0.23 + tldts-core: 7.0.17 optional: true to-regex-range@5.0.1: @@ -25366,7 +25346,7 @@ snapshots: tough-cookie@6.0.0: dependencies: - tldts: 7.0.23 + tldts: 7.0.17 optional: true tr46@0.0.3: {} @@ -26256,11 +26236,11 @@ snapshots: vite: 5.4.21(@types/node@22.13.14)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0) vite-hot-client: 2.1.0(vite@5.4.21(@types/node@22.13.14)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)) - vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)): dependencies: birpc: 2.8.0 vite: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) - vite-hot-client: 2.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) + vite-hot-client: 2.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) vite-hot-client@2.1.0(vite@5.4.21(@types/node@22.13.14)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)): dependencies: @@ -26270,7 +26250,7 @@ snapshots: dependencies: vite: 6.4.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) - vite-hot-client@2.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-hot-client@2.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)): dependencies: vite: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) @@ -26364,7 +26344,7 @@ snapshots: sirv: 3.0.2 unplugin-utils: 0.3.1 vite: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) - vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) + vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) optionalDependencies: '@nuxt/kit': 4.2.2(magicast@0.5.2) transitivePeerDependencies: @@ -26381,7 +26361,7 @@ snapshots: sirv: 3.0.2 unplugin-utils: 0.3.1 vite: 7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) - vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) + vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@22.13.14)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass@1.89.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) optionalDependencies: '@nuxt/kit': 4.2.2(magicast@0.5.2) transitivePeerDependencies: @@ -26728,7 +26708,7 @@ snapshots: xml-name-validator: 5.0.0 optional: true - watchpack@2.5.1: + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 @@ -26742,15 +26722,13 @@ snapshots: webidl-conversions@3.0.1: {} - webidl-conversions@8.0.1: + webidl-conversions@8.0.0: optional: true webpack-sources@3.2.3: {} webpack-sources@3.3.3: {} - webpack-sources@3.3.4: {} - webpack-virtual-modules@0.6.2: {} webpack@5.91.0: @@ -26776,9 +26754,9 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(webpack@5.91.0) - watchpack: 2.5.1 - webpack-sources: 3.3.4 + terser-webpack-plugin: 5.3.14(webpack@5.91.0) + watchpack: 2.4.4 + webpack-sources: 3.3.3 transitivePeerDependencies: - '@swc/core' - esbuild @@ -26807,9 +26785,9 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(esbuild@0.25.12)(webpack@5.91.0(esbuild@0.25.12)) - watchpack: 2.5.1 - webpack-sources: 3.3.4 + terser-webpack-plugin: 5.3.14(esbuild@0.25.12)(webpack@5.91.0(esbuild@0.25.12)) + watchpack: 2.4.4 + webpack-sources: 3.3.3 transitivePeerDependencies: - '@swc/core' - esbuild @@ -26831,13 +26809,10 @@ snapshots: whatwg-mimetype@4.0.0: optional: true - whatwg-mimetype@5.0.0: - optional: true - whatwg-url@15.1.0: dependencies: tr46: 6.0.0 - webidl-conversions: 8.0.1 + webidl-conversions: 8.0.0 optional: true whatwg-url@5.0.0: @@ -27003,9 +26978,6 @@ snapshots: yocto-queue@1.2.1: {} - yocto-queue@1.2.2: - optional: true - yocto-spinner@0.2.3: dependencies: yoctocolors: 2.1.1 From 71069b10d35f4a5357357ec017ab90c3b5779b15 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:50:22 +0100 Subject: [PATCH 11/12] feat: proper array support --- packages/api-gen/src/commands/phpDto.ts | 5 + packages/api-gen/src/php-dto/generator.ts | 36 ++- packages/api-gen/src/php-dto/openApiTypes.ts | 2 + packages/api-gen/src/php-dto/schemaParser.ts | 23 +- .../__snapshots__/snapshots.test.ts.snap | 252 ++++++++++++++++++ .../php-dto/fixtures/arrayValidation.json | 77 ++++++ .../php-dto/fixtures/oneOfSharedFields.json | 89 +++++++ .../api-gen/tests/php-dto/generator.test.ts | 135 ++++++++++ .../tests/php-dto/schemaParser.test.ts | 139 ++++++++++ 9 files changed, 754 insertions(+), 4 deletions(-) create mode 100644 packages/api-gen/tests/php-dto/fixtures/arrayValidation.json create mode 100644 packages/api-gen/tests/php-dto/fixtures/oneOfSharedFields.json diff --git a/packages/api-gen/src/commands/phpDto.ts b/packages/api-gen/src/commands/phpDto.ts index a54626fc1..841e2c7a0 100644 --- a/packages/api-gen/src/commands/phpDto.ts +++ b/packages/api-gen/src/commands/phpDto.ts @@ -153,11 +153,16 @@ export async function phpDto(options: PhpDtoOptions): Promise { ); } + const start = performance.now(); + if (action === "generate") { await runGenerate(outputPath, files); } else if (action === "check") { await runCheck(outputPath, files); } + + const elapsed = (performance.now() - start).toFixed(0); + console.log(pc.dim(`Done in ${elapsed}ms`)); } function removeDtoFilesInDir(dir: string): void { diff --git a/packages/api-gen/src/php-dto/generator.ts b/packages/api-gen/src/php-dto/generator.ts index f886a527c..bce93dcbc 100644 --- a/packages/api-gen/src/php-dto/generator.ts +++ b/packages/api-gen/src/php-dto/generator.ts @@ -144,6 +144,35 @@ function renderConstructorParam(prop: DtoProperty): string { lines.push(` #[Assert\\Choice(choices: [${choices}])]`); } + if (prop.isArray && prop.minItems !== undefined && prop.minItems > 0) { + lines.push(` #[Assert\\Count(min: ${prop.minItems})]`); + } + + if (prop.isArray && prop.arrayItemType) { + const phpToSymfonyType: Record = { + string: "string", + int: "int", + float: "float", + bool: "bool", + }; + const sfType = phpToSymfonyType[prop.arrayItemType]; + if (sfType) { + const itemConstraints: string[] = []; + itemConstraints.push(`new Assert\\Type('${sfType}')`); + if ( + prop.arrayItemMinLength !== undefined && + prop.arrayItemMinLength >= 1 + ) { + itemConstraints.push("new Assert\\NotBlank"); + } + if (itemConstraints.length === 1) { + lines.push(` #[Assert\\All(${itemConstraints[0]})]`); + } else { + lines.push(` #[Assert\\All([${itemConstraints.join(", ")}])]`); + } + } + } + const needsNullFallback = !prop.required && !prop.nullable && prop.defaultValue === undefined; const effectiveNullable = prop.nullable || needsNullFallback; @@ -166,12 +195,17 @@ export function generatePhpClass( options: GeneratorOptions = {}, ): string { const lines: string[] = []; + const PRIMITIVE_ARRAY_TYPES = new Set(["string", "int", "float", "bool"]); const needsAssert = dto.properties.some( (p) => p.pattern || (p.format && FORMAT_ASSERT_MAP[p.format]) || (p.enum && p.enum.length > 0) || - (p.required && !p.nullable), + (p.required && !p.nullable) || + (p.isArray && p.minItems !== undefined && p.minItems > 0) || + (p.isArray && + p.arrayItemType && + PRIMITIVE_ARRAY_TYPES.has(p.arrayItemType)), ); lines.push(" 1) { + const sharedProps = requestSchema?.properties ?? {}; + const sharedRequired = requestSchema?.required ?? []; + for (const variant of variants) { const resolved = dereferenceSchema(variant, registry); const variantTitle = (variant as { title?: string }).title; @@ -409,10 +418,18 @@ export function parseRequestBodies( ? toDtoClassName(variantTitle) : dtoName; - const variantResolved = resolveSchemaProperties(resolved, registry); + const mergedProperties = { + ...sharedProps, + ...(resolved.properties ?? {}), + }; + const mergedRequired = [ + ...sharedRequired, + ...(resolved.required ?? []), + ]; + const extracted = extractPropertiesFromSchema( - { properties: variantResolved.properties }, - variantResolved.required, + { properties: mergedProperties }, + mergedRequired, variantName, registry, ); diff --git a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap index 4aa9be33d..6d88d9b5f 100644 --- a/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -1,5 +1,132 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`php-dto snapshot tests > arrayValidation.json > generates correct content for CreateItemsRequestDTO.php 1`] = ` +" List of tags + */ + #[Assert\\NotNull] + #[Assert\\Count(min: 1)] + #[Assert\\All(new Assert\\Type('string'))] + public array $tags, + /** + * @var list List of UUIDs + */ + #[Assert\\NotNull] + #[Assert\\Count(min: 2)] + #[Assert\\All(new Assert\\Type('string'))] + public array $ids, + /** + * @var list Optional scores + */ + #[Assert\\All(new Assert\\Type('int'))] + public ?array $scores = null, + /** + * @var list Boolean flags + */ + #[Assert\\Count(min: 1)] + #[Assert\\All(new Assert\\Type('bool'))] + public ?array $flags = null, + /** + * @var list Non-blank strings + */ + #[Assert\\Count(min: 1)] + #[Assert\\All([new Assert\\Type('string'), new Assert\\NotBlank])] + public ?array $vatIds = null, + /** Untyped array */ + public ?array $untyped = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > arrayValidation.json > generates correct content for CreateItemsResponseDTO.php 1`] = ` +" arrayValidation.json > generates correct content with namespace 1`] = ` +" List of tags + */ + #[Assert\\NotNull] + #[Assert\\Count(min: 1)] + #[Assert\\All(new Assert\\Type('string'))] + public array $tags, + /** + * @var list List of UUIDs + */ + #[Assert\\NotNull] + #[Assert\\Count(min: 2)] + #[Assert\\All(new Assert\\Type('string'))] + public array $ids, + /** + * @var list Optional scores + */ + #[Assert\\All(new Assert\\Type('int'))] + public ?array $scores = null, + /** + * @var list Boolean flags + */ + #[Assert\\Count(min: 1)] + #[Assert\\All(new Assert\\Type('bool'))] + public ?array $flags = null, + /** + * @var list Non-blank strings + */ + #[Assert\\Count(min: 1)] + #[Assert\\All([new Assert\\Type('string'), new Assert\\NotBlank])] + public ?array $vatIds = null, + /** Untyped array */ + public ?array $untyped = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > arrayValidation.json > generates expected file names 1`] = ` +[ + "CreateItemsRequestDTO.php", + "CreateItemsResponseDTO.php", +] +`; + +exports[`php-dto snapshot tests > arrayValidation.json > generates expected number of files 1`] = `2`; + exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CreateCartRequestDTO.php 1`] = ` " nestedObjects.json > generates correct content namespace DTO; use DTO\\SalesChannelContextContextSourceDTO; +use Symfony\\Component\\Validator\\Constraints as Assert; /** * Core context with general configuration values and state @@ -569,6 +697,7 @@ class SalesChannelContextContextDTO /** * @var list */ + #[Assert\\All(new Assert\\Type('string'))] public ?array $languageIdChain = null, public ?string $scope = null, public ?SalesChannelContextContextSourceDTO $source = null, @@ -906,6 +1035,129 @@ exports[`php-dto snapshot tests > oneOfRequest.json > generates expected file na exports[`php-dto snapshot tests > oneOfRequest.json > generates expected number of files 1`] = `3`; +exports[`php-dto snapshot tests > oneOfSharedFields.json > generates correct content for BusinessRegistrationDTO.php 1`] = ` +" VAT IDs + */ + #[Assert\\NotNull] + #[Assert\\All(new Assert\\Type('string'))] + public array $vatIds, + /** Account type */ + #[Assert\\Choice(choices: ['business'])] + public string $accountType = 'business', + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > oneOfSharedFields.json > generates correct content for PrivateRegistrationDTO.php 1`] = ` +" oneOfSharedFields.json > generates correct content for RegisterResponseDTO.php 1`] = ` +" oneOfSharedFields.json > generates correct content with namespace 1`] = ` +" oneOfSharedFields.json > generates expected file names 1`] = ` +[ + "BusinessRegistrationDTO.php", + "PrivateRegistrationDTO.php", + "RegisterResponseDTO.php", +] +`; + +exports[`php-dto snapshot tests > oneOfSharedFields.json > generates expected number of files 1`] = `3`; + exports[`php-dto snapshot tests > simpleSchema.json > generates correct content for DTO/CalculatedPriceDTO.php 1`] = ` " { ); expect(productContent).toContain("#[PreserveNull]"); }); + + it("generates Assert\\Count for arrays with minItems", () => { + const dto: DtoDefinition = { + name: "TagsDTO", + properties: [ + { + name: "tags", + phpType: "array", + nullable: false, + required: true, + isArray: true, + arrayItemType: "string", + minItems: 1, + }, + { + name: "ids", + phpType: "array", + nullable: false, + required: true, + isArray: true, + arrayItemType: "string", + minItems: 3, + }, + { + name: "labels", + phpType: "array", + nullable: false, + required: false, + isArray: true, + arrayItemType: "string", + }, + ], + }; + + const output = generatePhpClass(dto); + expect(output).toContain("#[Assert\\Count(min: 1)]"); + expect(output).toContain("#[Assert\\Count(min: 3)]"); + expect(output).not.toMatch(/labels[\s\S]*?#\[Assert\\Count/); + }); + + it("generates Assert\\All with Assert\\Type for primitive array items", () => { + const dto: DtoDefinition = { + name: "MixedArraysDTO", + properties: [ + { + name: "names", + phpType: "array", + nullable: false, + required: true, + isArray: true, + arrayItemType: "string", + }, + { + name: "counts", + phpType: "array", + nullable: false, + required: false, + isArray: true, + arrayItemType: "int", + }, + { + name: "prices", + phpType: "array", + nullable: false, + required: false, + isArray: true, + arrayItemType: "float", + }, + { + name: "flags", + phpType: "array", + nullable: false, + required: false, + isArray: true, + arrayItemType: "bool", + }, + { + name: "items", + phpType: "array", + nullable: false, + required: false, + isArray: true, + arrayItemType: "LineItemDTO", + }, + { + name: "untyped", + phpType: "array", + nullable: false, + required: false, + isArray: true, + }, + ], + }; + + const output = generatePhpClass(dto); + expect(output).toContain("#[Assert\\All(new Assert\\Type('string'))]"); + expect(output).toContain("#[Assert\\All(new Assert\\Type('int'))]"); + expect(output).toContain("#[Assert\\All(new Assert\\Type('float'))]"); + expect(output).toContain("#[Assert\\All(new Assert\\Type('bool'))]"); + expect(output).not.toContain("Type('LineItemDTO')"); + expect(output).not.toMatch(/untyped[\s\S]*?#\[Assert\\All/); + }); + + it("generates Assert\\All with NotBlank when arrayItemMinLength >= 1", () => { + const dto: DtoDefinition = { + name: "ItemMinLengthDTO", + properties: [ + { + name: "vatIds", + phpType: "array", + nullable: false, + required: true, + isArray: true, + arrayItemType: "string", + minItems: 1, + arrayItemMinLength: 1, + }, + { + name: "tags", + phpType: "array", + nullable: false, + required: true, + isArray: true, + arrayItemType: "string", + }, + ], + }; + + const output = generatePhpClass(dto); + expect(output).toContain( + "#[Assert\\All([new Assert\\Type('string'), new Assert\\NotBlank])]", + ); + const tagsSection = output.slice(output.indexOf("$tags")); + expect(tagsSection).not.toContain("NotBlank"); + }); }); }); diff --git a/packages/api-gen/tests/php-dto/schemaParser.test.ts b/packages/api-gen/tests/php-dto/schemaParser.test.ts index fa480a82f..efc000fde 100644 --- a/packages/api-gen/tests/php-dto/schemaParser.test.ts +++ b/packages/api-gen/tests/php-dto/schemaParser.test.ts @@ -536,4 +536,143 @@ describe("schemaParser", () => { } }); }); + + describe("oneOf with shared top-level fields", () => { + let sharedFieldsSchema: Record; + + beforeAll(async () => { + sharedFieldsSchema = JSON.parse( + readFileSync( + resolve(__dirname, "fixtures/oneOfSharedFields.json"), + "utf-8", + ), + ); + }); + + it("merges shared properties into each variant", () => { + const dtos = parseRequestBodies(sharedFieldsSchema as OpenApiSchema); + const names = dtos.map((d) => d.name); + + expect(names).toContain("PrivateRegistrationDTO"); + expect(names).toContain("BusinessRegistrationDTO"); + expect(names).not.toContain("RegisterRequestDTO"); + }); + + it("each variant contains shared fields plus its own", () => { + const dtos = parseRequestBodies(sharedFieldsSchema as OpenApiSchema); + + const privateDtos = dtos.find((d) => d.name === "PrivateRegistrationDTO"); + const privateNames = privateDtos?.properties.map((p) => p.name) ?? []; + expect(privateNames).toContain("email"); + expect(privateNames).toContain("firstName"); + expect(privateNames).toContain("lastName"); + expect(privateNames).toContain("accountType"); + + const businessDtos = dtos.find( + (d) => d.name === "BusinessRegistrationDTO", + ); + const businessNames = businessDtos?.properties.map((p) => p.name) ?? []; + expect(businessNames).toContain("email"); + expect(businessNames).toContain("firstName"); + expect(businessNames).toContain("lastName"); + expect(businessNames).toContain("accountType"); + expect(businessNames).toContain("company"); + expect(businessNames).toContain("vatIds"); + }); + + it("shared required fields apply to all variants", () => { + const dtos = parseRequestBodies(sharedFieldsSchema as OpenApiSchema); + + const privateDtos = dtos.find((d) => d.name === "PrivateRegistrationDTO"); + expect( + privateDtos?.properties.find((p) => p.name === "email")?.required, + ).toBe(true); + expect( + privateDtos?.properties.find((p) => p.name === "firstName")?.required, + ).toBe(true); + + const businessDtos = dtos.find( + (d) => d.name === "BusinessRegistrationDTO", + ); + expect( + businessDtos?.properties.find((p) => p.name === "email")?.required, + ).toBe(true); + expect( + businessDtos?.properties.find((p) => p.name === "company")?.required, + ).toBe(true); + expect( + businessDtos?.properties.find((p) => p.name === "vatIds")?.required, + ).toBe(true); + }); + + it("variant-specific properties override shared ones", () => { + const dtos = parseRequestBodies(sharedFieldsSchema as OpenApiSchema); + + const businessDtos = dtos.find( + (d) => d.name === "BusinessRegistrationDTO", + ); + const accountType = businessDtos?.properties.find( + (p) => p.name === "accountType", + ); + expect(accountType?.enum).toEqual(["business"]); + }); + }); + + describe("array validation (minItems and item types)", () => { + let arraySchema: Record; + + beforeAll(() => { + arraySchema = JSON.parse( + readFileSync( + resolve(__dirname, "fixtures/arrayValidation.json"), + "utf-8", + ), + ); + }); + + it("parses minItems on array properties", () => { + const dtos = parseRequestBodies(arraySchema as OpenApiSchema); + const dto = dtos.find((d) => d.name === "CreateItemsRequestDTO"); + expect(dto).toBeDefined(); + + const tags = dto?.properties.find((p) => p.name === "tags"); + expect(tags?.minItems).toBe(1); + + const ids = dto?.properties.find((p) => p.name === "ids"); + expect(ids?.minItems).toBe(2); + + const scores = dto?.properties.find((p) => p.name === "scores"); + expect(scores?.minItems).toBeUndefined(); + }); + + it("parses item types for typed arrays", () => { + const dtos = parseRequestBodies(arraySchema as OpenApiSchema); + const dto = dtos.find((d) => d.name === "CreateItemsRequestDTO"); + + expect( + dto?.properties.find((p) => p.name === "tags")?.arrayItemType, + ).toBe("string"); + expect( + dto?.properties.find((p) => p.name === "scores")?.arrayItemType, + ).toBe("int"); + expect( + dto?.properties.find((p) => p.name === "flags")?.arrayItemType, + ).toBe("bool"); + expect( + dto?.properties.find((p) => p.name === "untyped")?.arrayItemType, + ).toBeUndefined(); + }); + + it("parses arrayItemMinLength from items.minLength", () => { + const dtos = parseRequestBodies(arraySchema as OpenApiSchema); + const dto = dtos.find((d) => d.name === "CreateItemsRequestDTO"); + + expect( + dto?.properties.find((p) => p.name === "vatIds")?.arrayItemMinLength, + ).toBe(1); + expect( + dto?.properties.find((p) => p.name === "tags")?.arrayItemMinLength, + ).toBeUndefined(); + }); + }); }); From 70d8646838bf450e6d6d4cf41a246fbd41706b53 Mon Sep 17 00:00:00 2001 From: patzick <13100280+patzick@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:58:56 +0100 Subject: [PATCH 12/12] fix: check only DTO files --- packages/api-gen/src/commands/phpDto.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api-gen/src/commands/phpDto.ts b/packages/api-gen/src/commands/phpDto.ts index 841e2c7a0..6a5daab10 100644 --- a/packages/api-gen/src/commands/phpDto.ts +++ b/packages/api-gen/src/commands/phpDto.ts @@ -199,14 +199,14 @@ async function runGenerate( ); } -function collectPhpFiles(dir: string, base: string): string[] { +function collectDtoFiles(dir: string, base: string): string[] { const results: string[] = []; if (!existsSync(dir)) return results; for (const entry of readdirSync(dir)) { const fullPath = resolve(dir, entry); if (statSync(fullPath).isDirectory()) { - results.push(...collectPhpFiles(fullPath, base)); - } else if (entry.endsWith(".php")) { + results.push(...collectDtoFiles(fullPath, base)); + } else if (entry.endsWith("DTO.php") || entry === "PreserveNull.php") { results.push(relative(base, fullPath)); } } @@ -224,7 +224,7 @@ async function runCheck( process.exit(1); } - const existingFiles = new Set(collectPhpFiles(outputPath, outputPath)); + const existingFiles = new Set(collectDtoFiles(outputPath, outputPath)); const expectedFiles = new Set(files.map((f) => f.fileName)); for (const fileName of expectedFiles) {