diff --git a/packages/api-gen/README.md b/packages/api-gen/README.md index 98e56940e..981aaa141 100644 --- a/packages/api-gen/README.md +++ b/packages/api-gen/README.md @@ -302,6 +302,112 @@ 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 +- `--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 +- **`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 + +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 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 +479,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/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 e06af31f9..5edd33746 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,39 @@ 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("config", { + alias: "c", + type: "string", + demandOption: true, + describe: + "path to JSON config file (schemaUrl, outputDir, namespace, tag, routes)", + }) + .option("schemaFile", { + alias: "f", + type: "string", + 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", + }); + }, + 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..6a5daab10 --- /dev/null +++ b/packages/api-gen/src/commands/phpDto.ts @@ -0,0 +1,261 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, relative, 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 PhpDtoConfig { + schemaUrl: string; + outputDir?: string; + namespace?: string; + tag?: string; + routes?: Record; +} + +export interface PhpDtoOptions { + action: "generate" | "check"; + config: string; + schemaFile?: 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.`, + ); + } +} + +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, config: configPath, schemaFile, rawNames } = options; + const cwd = options.cwd || process.cwd(); + + const resolvedConfigPath = resolve(cwd, configPath); + const config = await loadLocalJSONFile(resolvedConfigPath); + if (!config) { + throw new Error(`Config file not found: ${resolvedConfigPath}`); + } + + 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; + } + + let dtos: DtoDefinition[]; + if (rawNames) { + validateDtoNames(rawDtos); + dtos = rawDtos; + } else { + dtos = sanitizeDtoNames(rawDtos); + } + + 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."), + ); + } + + 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 { + 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 { + removeDtoFilesInDir(outputPath); + + mkdirSync(outputPath, { recursive: true }); + + for (const file of files) { + const filePath = resolve(outputPath, file.fileName); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, file.content, "utf-8"); + } + + console.log( + pc.green(`Generated ${files.length} PHP DTO files in ${outputPath}`), + ); +} + +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(...collectDtoFiles(fullPath, base)); + } else if (entry.endsWith("DTO.php") || entry === "PreserveNull.php") { + results.push(relative(base, fullPath)); + } + } + return results; +} + +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(collectDtoFiles(outputPath, outputPath)); + 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..bce93dcbc --- /dev/null +++ b/packages/api-gen/src/php-dto/generator.ts @@ -0,0 +1,507 @@ +import picomatch from "picomatch"; +import type { DtoDefinition, DtoProperty, DtoSource } from "./schemaParser"; + +export interface GeneratorOptions { + namespace?: string; + /** 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 { + return text.replace(/\*\//g, "* /"); +} + +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 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", + "float", + "bool", + "array", + "mixed", +]); + +function resolveNamespace( + baseNamespace: string | undefined, + source: DtoSource, +): string | undefined { + if (source === "component") { + return baseNamespace ? `${baseNamespace}\\DTO` : "DTO"; + } + 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, + rootNamespace?: string, +): string[] { + const imports: string[] = []; + + if (usesPreserveNull) { + const attrBase = rootNamespace ?? baseNamespace; + const attrNs = attrBase ? `${attrBase}\\Attributes` : "Attributes"; + imports.push(`${attrNs}\\PreserveNull`); + } + + 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; + + 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) { + if (prop.phpType === "string") { + lines.push(" #[Assert\\NotBlank]"); + } else { + lines.push(" #[Assert\\NotNull]"); + } + } + + 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)}/')]`, + ); + } + + if (prop.nullable) { + lines.push(" #[PreserveNull]"); + } + + if (prop.enum && prop.enum.length > 0) { + const choices = prop.enum + .map((v) => `'${escapePhpSingleQuoted(v)}'`) + .join(", "); + 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; + const typePrefix = effectiveNullable ? "?" : ""; + let defaultSuffix = ""; + if (prop.defaultValue !== undefined) { + defaultSuffix = ` = ${formatPhpDefault(prop.defaultValue)}`; + } else if (effectiveNullable) { + defaultSuffix = " = 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 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.isArray && p.minItems !== undefined && p.minItems > 0) || + (p.isArray && + p.arrayItemType && + PRIMITIVE_ARRAY_TYPES.has(p.arrayItemType)), + ); + + lines.push(" p.nullable); + const referencedNames = collectReferencedDtoNames(dto.properties); + const imports = buildUseStatements( + options.baseNamespace, + referencedNames, + options.dtoSourceMap, + usesPreserveNull, + options.rootNamespace, + ); + 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(""); + } + + if (dto.description) { + lines.push("/**"); + for (const line of dto.description.split("\n")) { + lines.push(` * ${escapePhpDocComment(line)}`); + } + lines.push(" */"); + } + + lines.push(`class ${dto.name}`); + lines.push("{"); + + 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(""); + + return lines.join("\n"); +} + +export function dtoToFileName(dtoName: string): string { + return `${dtoName}.php`; +} + +export interface GeneratedFile { + fileName: string; + content: string; +} + +export function generatePreserveNullAttribute( + options: GeneratorOptions = {}, +): string { + const ns = options.namespace + ? `${options.namespace}\\Attributes` + : "Attributes"; + const lines: string[] = []; + 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[], + 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) { + allDtosByName.set(dto.name, 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" ? "DTO/" : ""; + const effectiveNs = resolveNamespace(options.namespace, source); + const fileOptions: GeneratorOptions = { + ...options, + namespace: effectiveNs, + baseNamespace: options.namespace, + rootNamespace: options.rootNamespace, + dtoSourceMap, + }; + return { + 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 { + 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 { files: allFiles, unmappedPaths }; +} 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..f6237ec1e --- /dev/null +++ b/packages/api-gen/src/php-dto/openApiTypes.ts @@ -0,0 +1,63 @@ +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[]; + default?: unknown; + minItems?: number; + minLength?: number; + additionalProperties?: boolean | SchemaObject; +} + +export interface ParameterObject { + name: string; + in: string; + description?: string; + required?: boolean; + 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; + parameters?: ParameterObject[]; + requestBody?: { + required?: boolean; + 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 new file mode 100644 index 000000000..8e759d671 --- /dev/null +++ b/packages/api-gen/src/php-dto/schemaParser.ts @@ -0,0 +1,611 @@ +import type { + OpenApiSchema, + OperationObject, + ResponseObject, + 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; + format?: string; + enum?: string[]; + defaultValue?: string | number | boolean; + isArray: boolean; + arrayItemType?: string; + minItems?: number; + arrayItemMinLength?: number; +} + +export type DtoSource = "operation" | "component"; + +export interface DtoDefinition { + name: string; + description?: string; + properties: DtoProperty[]; + source?: DtoSource; + endpointPath?: string; +} + +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 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 { + 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 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" && + 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, + source: "component", + }); + nestedDtos.push(...nested.nestedDtos); + + properties.push({ + name: propName, + phpType: nestedName, + nullable: hasTypeNull(propSchema), + required: isRequired, + description: propSchema.description, + pattern: propSchema.pattern, + format: propSchema.format, + enum: enumValues, + defaultValue: resolveDefaultValue(propSchema), + 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, + source: "component", + }); + nestedDtos.push(...nested.nestedDtos); + + properties.push({ + name: propName, + phpType: "array", + nullable: hasTypeNull(propSchema), + required: isRequired, + description: propSchema.description, + pattern: propSchema.pattern, + format: propSchema.format, + enum: enumValues, + defaultValue: resolveDefaultValue(propSchema), + isArray: true, + arrayItemType: nestedName, + minItems: propSchema.minItems, + }); + continue; + } + + const typeResult: PhpTypeResult = mapOpenApiTypeToPhp(propSchema); + + properties.push({ + name: propName, + phpType: typeResult.phpType, + nullable: typeResult.nullable, + required: isRequired, + description: propSchema.description, + pattern: propSchema.pattern, + format: propSchema.format, + enum: enumValues, + defaultValue: resolveDefaultValue(propSchema), + isArray: typeResult.isArray, + arrayItemType: typeResult.arrayItemType, + minItems: propSchema.minItems, + arrayItemMinLength: propSchema.items?.minLength, + }); + } + + 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, + source: DtoSource = "component", +): 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, + source, + }, + ...nestedDtos, + ]; +} + +export function parseComponentSchemas( + schema: OpenApiSchema, + schemaFilter?: Set, +): 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)) { + if (schemaFilter && !schemaFilter.has(schemaName)) continue; + + const extracted = extractDtoFromSchema( + toDtoClassName(schemaName), + schemaObj, + registry, + ); + dtos.push(...extracted); + } + + return dtos; +} + +export function parseRequestBodies( + schema: OpenApiSchema, + operationFilter?: Set, +): DtoDefinition[] { + const dtos: DtoDefinition[] = []; + const paths = schema.paths; + const registry: SchemaRegistry = schema.components?.schemas || {}; + + if (!paths) return dtos; + + 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; + if (operationFilter && !operationFilter.has(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, + format: param.schema.format, + defaultValue: resolveDefaultValue(param.schema), + isArray: typeResult.isArray, + arrayItemType: typeResult.arrayItemType, + minItems: param.schema.minItems, + }); + } + } + } + + if (!requestSchema && paramProperties.length === 0) continue; + + const variants = requestSchema?.oneOf ?? requestSchema?.anyOf; + if (variants && variants.length > 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; + const variantName = variantTitle + ? toDtoClassName(variantTitle) + : dtoName; + + const mergedProperties = { + ...sharedProps, + ...(resolved.properties ?? {}), + }; + const mergedRequired = [ + ...sharedRequired, + ...(resolved.required ?? []), + ]; + + const extracted = extractPropertiesFromSchema( + { properties: mergedProperties }, + mergedRequired, + 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; + + 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, + source: "operation", + endpointPath: pathKey, + }); + dtos.push(...bodyNestedDtos); + } + } + + return dtos; +} + +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; + + 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; + if (operationFilter && !operationFilter.has(operation.operationId)) + continue; + + const responses = operation.responses; + if (!responses) continue; + + const rawSuccessResponse = responses["200"] || responses["201"]; + if (!rawSuccessResponse) continue; + + const successResponse = dereferenceResponse( + rawSuccessResponse, + responseRegistry, + ); + if (!successResponse.content) continue; + + const rawResponseSchema = + successResponse.content["application/json"]?.schema; + if (!rawResponseSchema) continue; + + const responseSchema = dereferenceSchema(rawResponseSchema, registry); + + const extracted = extractDtoFromSchema( + `${capitalizeFirst(operation.operationId)}ResponseDTO`, + responseSchema, + registry, + successResponse.description, + "operation", + ); + + for (const dto of extracted) { + if (dto.source === "operation") { + dto.endpointPath = pathKey; + } + } + + dtos.push(...extracted); + } + } + + return dtos; +} + +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); + + 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..eda4955ab --- /dev/null +++ b/packages/api-gen/src/php-dto/typeMapper.ts @@ -0,0 +1,144 @@ +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 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`; +} + +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..6d88d9b5f --- /dev/null +++ b/packages/api-gen/tests/php-dto/__snapshots__/snapshots.test.ts.snap @@ -0,0 +1,1762 @@ +// 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`] = ` +" Initial line items to add to the cart + */ + public ?array $lineItems = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates correct content for CreateCartResponseDTO.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 for DTO/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 DTO/CartDTO.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 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`] = ` +" Initial line items to add to the cart + */ + public ?array $lineItems = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > createReadEntity.json > generates expected file names 1`] = ` +[ + "CreateCartRequestDTO.php", + "CreateCartResponseDTO.php", + "DTO/CartCreateDTO.php", + "DTO/CartDTO.php", + "DTO/LineItemDTO.php", + "ReadCartResponseDTO.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 DTO/UserProfileDTO.php 1`] = ` +" formatAssertions.json > generates correct content with namespace 1`] = ` +" formatAssertions.json > generates expected file names 1`] = ` +[ + "DTO/UserProfileDTO.php", +] +`; + +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 Api-infoResponseDTO.php 1`] = ` +" invalidNames.json > generates correct content for DTO/Simple-ProductDTO.php 1`] = ` +" invalidNames.json > generates correct content for DTO/errorDTO.php 1`] = ` +" invalidNames.json > generates correct content for DTO/errorResponseDTO.php 1`] = ` +" + */ + public ?array $errors = null, + ) { + } +} +" +`; + +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", + "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 DTO/OrderBillingAddressCountryDTO.php 1`] = ` +" nestedObjects.json > generates correct content for DTO/OrderBillingAddressDTO.php 1`] = ` +" nestedObjects.json > generates correct content for DTO/OrderDTO.php 1`] = ` +" nestedObjects.json > generates correct content for DTO/SalesChannelContextContextDTO.php 1`] = ` +" + */ + #[Assert\\All(new Assert\\Type('string'))] + public ?array $languageIdChain = null, + public ?string $scope = null, + public ?SalesChannelContextContextSourceDTO $source = null, + public ?string $taxState = null, + public ?bool $useCache = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelContextContextSourceDTO.php 1`] = ` +" nestedObjects.json > generates correct content for DTO/SalesChannelContextCurrentCustomerGroupDTO.php 1`] = ` +" nestedObjects.json > generates correct content for DTO/SalesChannelContextDTO.php 1`] = ` +" Active tax rules + */ + public ?array $taxRules = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > nestedObjects.json > generates correct content for DTO/SalesChannelContextItemRoundingDTO.php 1`] = ` +" nestedObjects.json > generates correct content for DTO/SalesChannelContextTaxRulesDTO.php 1`] = ` +" nestedObjects.json > generates correct content for DTO/SalesChannelDTO.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`] = ` +[ + "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 > oneOfRequest.json > generates correct content for ImitateCustomerLoginResponseDTO.php 1`] = ` +" oneOfRequest.json > generates correct content for JwtImpersonationPayloadDTO.php 1`] = ` +" oneOfRequest.json > generates correct content for LegacyImpersonationPayloadDTO.php 1`] = ` +" oneOfRequest.json > generates correct content with namespace 1`] = ` +" oneOfRequest.json > generates expected file names 1`] = ` +[ + "ImitateCustomerLoginResponseDTO.php", + "JwtImpersonationPayloadDTO.php", + "LegacyImpersonationPayloadDTO.php", +] +`; + +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`] = ` +" simpleSchema.json > generates correct content for DTO/CartDTO.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 DTO/DefaultValuesDTO.php 1`] = ` +" simpleSchema.json > generates correct content for DTO/LineItemDTO.php 1`] = ` +" simpleSchema.json > generates correct content for DTO/NavigationTypeDTO.php 1`] = ` +" 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", +] +`; + +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`] = ` +" tagSchema.json > generates correct content for AddLineItemResponseDTO.php 1`] = ` +" + */ + public ?array $lineItems = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for DTO/CartDTO.php 1`] = ` +" + */ + public ?array $lineItems = null, + ) { + } +} +" +`; + +exports[`php-dto snapshot tests > tagSchema.json > generates correct content for DTO/CategoryDTO.php 1`] = ` +" tagSchema.json > generates correct content for DTO/LineItemDTO.php 1`] = ` +" tagSchema.json > generates correct content for DTO/MediaDTO.php 1`] = ` +" tagSchema.json > generates correct content for DTO/ProductDTO.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 with namespace 1`] = ` +" tagSchema.json > generates expected file names 1`] = ` +[ + "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", +] +`; + +exports[`php-dto snapshot tests > tagSchema.json > generates expected number of files 1`] = `11`; diff --git a/packages/api-gen/tests/php-dto/fixtures/arrayValidation.json b/packages/api-gen/tests/php-dto/fixtures/arrayValidation.json new file mode 100644 index 000000000..7ef184d4f --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/arrayValidation.json @@ -0,0 +1,77 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/items": { + "post": { + "tags": ["Items"], + "summary": "Create items", + "operationId": "createItems", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["tags", "ids"], + "properties": { + "tags": { + "description": "List of tags", + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "ids": { + "description": "List of UUIDs", + "type": "array", + "items": { "type": "string" }, + "minItems": 2 + }, + "scores": { + "description": "Optional scores", + "type": "array", + "items": { "type": "integer" } + }, + "flags": { + "description": "Boolean flags", + "type": "array", + "items": { "type": "boolean" }, + "minItems": 1 + }, + "vatIds": { + "description": "Non-blank strings", + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + }, + "untyped": { + "description": "Untyped array", + "type": "array" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "created": { "type": "integer" } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": {} + } +} 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/formatAssertions.json b/packages/api-gen/tests/php-dto/fixtures/formatAssertions.json new file mode 100644 index 000000000..779edc6ee --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/formatAssertions.json @@ -0,0 +1,59 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Format Assertions Test API", + "version": "1.0.0" + }, + "components": { + "schemas": { + "UserProfile": { + "type": "object", + "description": "User profile with various formatted fields", + "required": ["email", "id", "createdAt"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier" + }, + "email": { + "type": "string", + "format": "email", + "description": "Email address" + }, + "website": { + "type": "string", + "format": "uri" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp" + }, + "birthday": { + "type": "string", + "format": "date" + }, + "avatar": { + "type": "string", + "format": "uri-reference", + "description": "Should not produce a format assert" + }, + "fileSize": { + "type": "integer", + "format": "int64" + }, + "price": { + "type": "number", + "format": "float" + }, + "name": { + "type": "string", + "description": "Plain string without format" + } + } + } + } + }, + "paths": {} +} diff --git a/packages/api-gen/tests/php-dto/fixtures/invalidNames.json b/packages/api-gen/tests/php-dto/fixtures/invalidNames.json new file mode 100644 index 000000000..ba967648f --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/invalidNames.json @@ -0,0 +1,81 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Test API with invalid names", + "version": "1.0.0" + }, + "components": { + "schemas": { + "Simple-Product": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "errorResponse": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/error" + } + } + } + } + } + }, + "paths": { + "/_info": { + "get": { + "operationId": "api-info", + "summary": "Get API info", + "parameters": [ + { + "name": "type", + "in": "query", + "description": "Type of the api", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "API info", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +} 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/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/fixtures/oneOfSharedFields.json b/packages/api-gen/tests/php-dto/fixtures/oneOfSharedFields.json new file mode 100644 index 000000000..3c74dc4e6 --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/oneOfSharedFields.json @@ -0,0 +1,89 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/account/register": { + "post": { + "tags": ["Registration"], + "summary": "Register a customer", + "operationId": "register", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "description": "Email of the customer", + "type": "string" + }, + "firstName": { + "description": "Customer first name", + "type": "string" + }, + "lastName": { + "description": "Customer last name", + "type": "string" + } + }, + "required": ["email", "firstName", "lastName"], + "oneOf": [ + { + "title": "PrivateRegistration", + "properties": { + "accountType": { + "description": "Account type", + "type": "string", + "enum": ["private"], + "default": "private" + } + } + }, + { + "title": "BusinessRegistration", + "required": ["company", "vatIds"], + "properties": { + "accountType": { + "description": "Account type", + "type": "string", + "enum": ["business"] + }, + "company": { + "description": "Company name", + "type": "string" + }, + "vatIds": { + "description": "VAT IDs", + "type": "array", + "items": { "type": "string" } + } + } + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": {} + } +} 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..bac41c939 --- /dev/null +++ b/packages/api-gen/tests/php-dto/fixtures/simpleSchema.json @@ -0,0 +1,276 @@ +{ + "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" + }, + "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": { + "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/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 new file mode 100644 index 000000000..d4f7ee116 --- /dev/null +++ b/packages/api-gen/tests/php-dto/generator.test.ts @@ -0,0 +1,1244 @@ +import { describe, expect, it } from "vitest"; +import { + dtoToFileName, + generateAllFiles, + generatePhpClass, + generatePreserveNullAttribute, +} 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("uses NotBlank for required strings, NotNull for other required types", () => { + const dto: DtoDefinition = { + name: "TestDTO", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + { + name: "count", + phpType: "int", + 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\\NotBlank]\n public string $id,", + ); + expect(result).toContain( + "#[Assert\\NotNull]\n public int $count,", + ); + expect(result).not.toContain( + "#[Assert\\NotBlank]\n public ?string $label", + ); + expect(result).not.toContain( + "#[Assert\\NotBlank]\n public ?string $status", + ); + expect(result).toContain("public ?string $label = null,"); + 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 = null,"); + 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("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("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("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", + 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("generatePreserveNullAttribute", () => { + it("generates the attribute class", () => { + const result = generatePreserveNullAttribute(); + + expect(result).toContain(" { + const result = generatePreserveNullAttribute({ + namespace: "App\\DTO", + }); + + expect(result).toContain("namespace App\\DTO\\Attributes;"); + expect(result).toContain("class PreserveNull"); + }); + }); + + describe("generateAllFiles", () => { + it("puts operation DTOs in root and component DTOs in DTO/", () => { + const dtos: DtoDefinition[] = [ + { + name: "ReadProductResponseDTO", + source: "operation", + properties: [ + { + name: "id", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + { + name: "ProductDTO", + source: "component", + properties: [ + { + name: "name", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + ]; + + const { files } = generateAllFiles(dtos); + + expect(files).toHaveLength(2); + expect(files[0]?.fileName).toBe("ReadProductResponseDTO.php"); + expect(files[1]?.fileName).toBe("DTO/ProductDTO.php"); + }); + + it("defaults to DTO/ 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(1); + expect(files[0]?.fileName).toBe("DTO/CartDTO.php"); + }); + + it("without --namespace: component DTOs get namespace DTO, 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[0]?.content ?? ""; + expect(requestContent).not.toContain("namespace "); + expect(requestContent).toContain("use DTO\\CustomerAddressDTO;"); + + const addressContent = files[1]?.content ?? ""; + expect(addressContent).toContain("namespace DTO;"); + expect(addressContent).toContain("use DTO\\CountryDTO;"); + + const countryContent = files[2]?.content ?? ""; + expect(countryContent).toContain("namespace DTO;"); + }); + + 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" }); + + const requestContent = files[0]?.content ?? ""; + expect(requestContent).toContain("namespace App\\DTO;"); + expect(requestContent).toContain( + "use App\\DTO\\DTO\\CustomerAddressDTO;", + ); + + const addressContent = files[1]?.content ?? ""; + 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", () => { + 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", + phpType: "string", + nullable: false, + required: true, + isArray: false, + }, + ], + }, + ]; + + 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\\DTO;"); + }); + + 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", () => { + 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\\DTO;"); + expect(productContent).toContain( + "use App\\DTO\\Attributes\\PreserveNull;", + ); + 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/phpDto.test.ts b/packages/api-gen/tests/php-dto/phpDto.test.ts new file mode 100644 index 000000000..ba31f6eed --- /dev/null +++ b/packages/api-gen/tests/php-dto/phpDto.test.ts @@ -0,0 +1,425 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +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 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)) { + rmSync(TEST_OUTPUT_DIR, { recursive: true, force: true }); + } + }); + + 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 DTO/ for components", async () => { + const outputDir = resolve(TEST_OUTPUT_DIR, "generate"); + const configPath = writeConfig({ outputDir }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }); + + expect(existsSync(outputDir)).toBe(true); + + const files = collectPhpFiles(outputDir); + expect(files.length).toBeGreaterThan(0); + expect(files).toContain("DTO/CartDTO.php"); + expect(files).toContain("SendContactMailRequestDTO.php"); + + const cartContent = readFileSync( + resolve(outputDir, "DTO/CartDTO.php"), + "utf-8", + ); + expect(cartContent).toContain("class CartDTO"); + expect(cartContent).toContain(" { + const outputDir = resolve(TEST_OUTPUT_DIR, "generate-ns"); + const configPath = writeConfig({ + outputDir, + namespace: "App\\DTO", + }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }); + + const cartContent = readFileSync( + resolve(outputDir, "DTO/CartDTO.php"), + "utf-8", + ); + 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, + }); + + writeFileSync(resolve(outputDir, "StaleDTO.php"), " { + const outputDir = resolve(TEST_OUTPUT_DIR, "check-pass"); + const configPath = writeConfig({ outputDir }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }); + + const spy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + await phpDto({ + action: "check", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + 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, + }); + + rmSync(resolve(outputDir, "DTO/CartDTO.php")); + + const spy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + await expect( + phpDto({ + action: "check", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }), + ).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"); + const configPath = writeConfig({ outputDir }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }); + + writeFileSync(resolve(outputDir, "DTO/CartDTO.php"), " { + throw new Error("process.exit called"); + }); + + await expect( + phpDto({ + action: "check", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }), + ).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"); + const configPath = writeConfig({ outputDir }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }); + + writeFileSync(resolve(outputDir, "ExtraDTO.php"), " { + throw new Error("process.exit called"); + }); + + await expect( + phpDto({ + action: "check", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + }), + ).rejects.toThrow("process.exit called"); + + expect(spy).toHaveBeenCalledWith(1); + spy.mockRestore(); + }); + + it("throws when schema file does not exist", async () => { + const configPath = writeConfig(); + + await expect( + phpDto({ + action: "generate", + config: configPath, + schemaFile: "/nonexistent/schema.json", + }), + ).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, + }); + + const files = collectPhpFiles(outputDir); + expect(files).toContain("DTO/SimpleProductDTO.php"); + expect(files).toContain("ApiInfoRequestDTO.php"); + expect(files).not.toContain("DTO/Simple-ProductDTO.php"); + expect(files).not.toContain("Api-infoRequestDTO.php"); + + const content = readFileSync( + resolve(outputDir, "DTO/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"); + const configPath = writeConfig({ outputDir }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: INVALID_SCHEMA, + }); + + const files = collectPhpFiles(outputDir); + expect(files).toContain("DTO/ErrorDTO.php"); + expect(files).not.toContain("DTO/errorDTO.php"); + + const content = readFileSync( + resolve(outputDir, "DTO/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"); + const configPath = writeConfig({ outputDir }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: INVALID_SCHEMA, + }); + + const content = readFileSync( + resolve(outputDir, "DTO/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"); + const configPath = writeConfig({ outputDir }); + + await expect( + phpDto({ + action: "generate", + config: configPath, + schemaFile: INVALID_SCHEMA, + 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"); + const configPath = writeConfig({ outputDir }); + + try { + await phpDto({ + action: "generate", + config: configPath, + schemaFile: INVALID_SCHEMA, + 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"); + const configPath = writeConfig({ outputDir }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: FIXTURE_SCHEMA, + rawNames: true, + }); + + 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"); + const configPath = writeConfig({ outputDir, tag: "Cart" }); + + await phpDto({ + action: "generate", + config: configPath, + schemaFile: TAG_SCHEMA, + }); + + const files = collectPhpFiles(outputDir); + + 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("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, + }); + + const files = collectPhpFiles(outputDir); + + 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, + }), + ).resolves.toBeUndefined(); + }); + }); +}); 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..efc000fde --- /dev/null +++ b/packages/api-gen/tests/php-dto/schemaParser.test.ts @@ -0,0 +1,678 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { OpenApiSchema } from "../../src/php-dto/openApiTypes"; +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"), +); + +const tagSchema = JSON.parse( + readFileSync(resolve(__dirname, "fixtures/tagSchema.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("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); + + 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("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); + }); + + 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("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"); + + 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("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).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"); + }); + + 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); + }); + }); + + 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"); + } + }); + }); + + 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(); + }); + }); +}); 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..e6e85ef8b --- /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 { files: 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..873f47cca --- /dev/null +++ b/packages/api-gen/tests/php-dto/typeMapper.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; +import { + getSchemaType, + hasTypeNull, + isValidPhpClassName, + mapOpenApiTypeToPhp, + resolveRefName, + toDtoClassName, + toPascalCase, +} 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("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); + }); + + 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(); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96d1af182..29619a206 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1020,6 +1020,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 @@ -1036,6 +1039,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 @@ -1589,8 +1595,8 @@ importers: packages: - '@acemir/cssom@0.9.30': - resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + '@acemir/cssom@0.9.24': + resolution: {integrity: sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==} '@adyen/adyen-web@6.5.1': resolution: {integrity: sha512-IAFn4gFq/XSTrErmXrJXGVtkJ7rpCqc2bZmSu5mvW9zfjP1kQe2lDDv1kGUdhv0lxlV3q2FBOJocUQv2M+6RAA==} @@ -1694,11 +1700,11 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} - '@asamuzakjp/css-color@4.1.1': - resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + '@asamuzakjp/css-color@4.0.5': + resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} - '@asamuzakjp/dom-selector@6.7.6': - resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + '@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==} @@ -2155,8 +2161,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.22': - resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} + '@csstools/css-syntax-patches-for-csstree@1.0.19': + resolution: {integrity: sha512-QW5/SM2ARltEhoKcmRI1LoLf3/C7dHGswwCnfLcoMgqurBT4f8GvwXMgAbK/FwcxthmJRK5MGTtddj0yQn0J9g==} engines: {node: '>=18'} '@csstools/css-tokenizer@3.0.4': @@ -3225,8 +3231,8 @@ packages: resolution: {integrity: sha512-5XUvZuffe3KetyhbWwd4n2ktd7wraocCYw10tlM+/u/95iAz29GjNiuNxbCD1T6Bn1MyGc4QLVNKOWhzJkVFAw==} engines: {node: ^14.16.0 || >=16.0.0} - '@netlify/open-api@2.45.0': - resolution: {integrity: sha512-kLysr2N8HQi0qoEq04vpRvrE/fSnZaXJYf1bVxKre2lLaM1RSm05hqDswKTgxM601pZf9h1i1Ea3L4DZNgHb5w==} + '@netlify/open-api@2.40.0': + resolution: {integrity: sha512-Dp4lilDnkRKGWnljGkFVxfoh1wsWqxheE5/ZOf/sMZPsh3jGu5QZ4hVLEidzXYB/zIKFFqLaUbP2XYVxTqWqyQ==} engines: {node: '>=14.8.0'} '@netlify/runtime-utils@1.3.1': @@ -5465,6 +5471,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. @@ -6376,12 +6385,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.4': - resolution: {integrity: sha512-AlKLc57loGoyYlrzDbejB9EeR+pfdJdGzbYnkEuZaGekFboBwzfVYVMsy88PMriqPI1ORpiGYGgSSWpx7a2sDA==} + '@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': @@ -7162,8 +7171,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.5: - resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} + cssstyle@5.3.3: + resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} engines: {node: '>=20'} csstype@3.2.3: @@ -7457,6 +7466,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -9042,8 +9055,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -10685,6 +10698,9 @@ packages: sax@1.3.0: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + sax@1.5.0: resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} engines: {node: '>=11.0.0'} @@ -11101,8 +11117,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': '*' @@ -11184,11 +11200,11 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tldts-core@7.0.19: - resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + tldts-core@7.0.17: + resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} - tldts@7.0.19: - resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + tldts@7.0.17: + resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==} hasBin: true to-regex-range@5.0.1: @@ -12332,8 +12348,8 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - watchpack@2.5.0: - resolution: {integrity: sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA==} + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} web-namespaces@2.0.1: @@ -12383,7 +12399,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==} @@ -12594,10 +12609,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'} @@ -12647,7 +12658,7 @@ packages: snapshots: - '@acemir/cssom@0.9.30': + '@acemir/cssom@0.9.24': optional: true '@adyen/adyen-web@6.5.1': @@ -12795,22 +12806,22 @@ snapshots: '@antfu/utils@0.7.10': {} - '@asamuzakjp/css-color@4.1.1': + '@asamuzakjp/css-color@4.0.5': dependencies: '@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.4 + lru-cache: 11.2.2 optional: true - '@asamuzakjp/dom-selector@6.7.6': + '@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.4 + lru-cache: 11.2.2 optional: true '@asamuzakjp/nwsapi@2.3.9': @@ -12973,7 +12984,7 @@ snapshots: '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.1 lru-cache: 5.1.1 - semver: 7.7.4 + semver: 7.7.3 '@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.28.5)': dependencies: @@ -12997,7 +13008,7 @@ snapshots: '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/traverse': 7.28.5 - semver: 7.7.4 + semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -13459,7 +13470,7 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 optional: true - '@csstools/css-syntax-patches-for-csstree@1.0.22': + '@csstools/css-syntax-patches-for-csstree@1.0.19': optional: true '@csstools/css-tokenizer@3.0.4': @@ -14262,7 +14273,7 @@ snapshots: write-file-atomic: 6.0.0 optional: true - '@netlify/open-api@2.45.0': + '@netlify/open-api@2.40.0': optional: true '@netlify/runtime-utils@1.3.1': @@ -14557,7 +14568,7 @@ snapshots: pkg-types: 2.3.0 rc9: 2.1.2 scule: 1.3.0 - semver: 7.7.4 + semver: 7.7.3 tinyglobby: 0.2.15 ufo: 1.6.3 unctx: 2.4.1 @@ -15433,7 +15444,7 @@ snapshots: '@nuxt/kit': 3.20.2(magicast@0.5.2) pathe: 1.1.2 pkg-types: 1.3.1 - semver: 7.7.4 + semver: 7.7.3 transitivePeerDependencies: - magicast @@ -16134,7 +16145,7 @@ snapshots: estree-walker: 2.0.2 glob: 8.1.0 is-reference: 1.2.1 - magic-string: 0.30.7 + magic-string: 0.30.21 optionalDependencies: rollup: 3.30.0 @@ -16216,7 +16227,7 @@ snapshots: '@rollup/plugin-replace@5.0.5(rollup@3.30.0)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@3.30.0) - magic-string: 0.30.7 + magic-string: 0.30.21 optionalDependencies: rollup: 3.30.0 @@ -17620,6 +17631,8 @@ snapshots: '@types/paypal-checkout-components@4.0.8': {} + '@types/picomatch@4.0.2': {} + '@types/prettier@3.0.0': dependencies: prettier: 3.7.4 @@ -17755,7 +17768,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 10.2.4 - semver: 7.7.4 + semver: 7.7.3 ts-api-utils: 1.3.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 @@ -17770,7 +17783,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 10.2.4 - semver: 7.7.4 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -18987,13 +19000,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.4 + '@whatwg-node/node-fetch': 0.8.1 urlpattern-polyfill: 10.1.0 optional: true - '@whatwg-node/node-fetch@0.8.4': + '@whatwg-node/node-fetch@0.8.1': dependencies: '@fastify/busboy': 3.2.0 '@whatwg-node/disposablestack': 0.0.6 @@ -19009,7 +19022,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 @@ -19977,10 +19990,10 @@ snapshots: dependencies: css-tree: 2.2.1 - cssstyle@5.3.5: + cssstyle@5.3.3: dependencies: - '@asamuzakjp/css-color': 4.1.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.22 + '@asamuzakjp/css-color': 4.0.5 + '@csstools/css-syntax-patches-for-csstree': 1.0.19 css-tree: 3.1.0 optional: true @@ -20153,7 +20166,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 10.2.4 - semver: 7.7.4 + semver: 7.7.3 ee-first@1.1.1: {} @@ -20216,6 +20229,11 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -21666,9 +21684,9 @@ snapshots: jsdom@27.1.0: dependencies: - '@acemir/cssom': 0.9.30 - '@asamuzakjp/dom-selector': 6.7.6 - cssstyle: 5.3.5 + '@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 @@ -21995,7 +22013,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.4: + lru-cache@11.2.2: optional: true lru-cache@5.1.1: @@ -22704,7 +22722,7 @@ snapshots: needle@3.3.1: dependencies: iconv-lite: 0.6.3 - sax: 1.5.0 + sax: 1.4.1 optional: true neo-async@2.6.2: {} @@ -22713,7 +22731,7 @@ snapshots: netlify@13.3.5: dependencies: - '@netlify/open-api': 2.45.0 + '@netlify/open-api': 2.40.0 lodash-es: 4.17.23 micro-api-client: 3.3.0 node-fetch: 3.3.2 @@ -23722,7 +23740,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: @@ -24652,7 +24670,7 @@ snapshots: rollup-plugin-dts@6.0.2(rollup@3.30.0)(typescript@5.9.3): dependencies: - magic-string: 0.30.7 + magic-string: 0.30.21 rollup: 3.30.0 typescript: 5.9.3 optionalDependencies: @@ -24752,6 +24770,9 @@ snapshots: sax@1.3.0: {} + sax@1.4.1: + optional: true + sax@1.5.0: {} saxes@6.0.0: @@ -25227,7 +25248,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 @@ -25238,7 +25259,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 @@ -25315,12 +25336,12 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@7.0.19: + tldts-core@7.0.17: optional: true - tldts@7.0.19: + tldts@7.0.17: dependencies: - tldts-core: 7.0.19 + tldts-core: 7.0.17 optional: true to-regex-range@5.0.1: @@ -25337,7 +25358,7 @@ snapshots: tough-cookie@6.0.0: dependencies: - tldts: 7.0.19 + tldts: 7.0.17 optional: true tr46@0.0.3: {} @@ -26699,7 +26720,7 @@ snapshots: xml-name-validator: 5.0.0 optional: true - watchpack@2.5.0: + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 @@ -26733,7 +26754,7 @@ snapshots: acorn-import-assertions: 1.9.0(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.19.0 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -26745,8 +26766,8 @@ 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.0 + terser-webpack-plugin: 5.3.14(webpack@5.91.0) + watchpack: 2.4.4 webpack-sources: 3.3.3 transitivePeerDependencies: - '@swc/core' @@ -26764,7 +26785,7 @@ snapshots: acorn-import-assertions: 1.9.0(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.19.0 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -26776,8 +26797,8 @@ 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.0 + 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' @@ -26969,9 +26990,6 @@ snapshots: yocto-queue@1.2.1: {} - yocto-queue@1.2.2: - optional: true - yocto-spinner@0.2.3: dependencies: yoctocolors: 2.1.1