From 614405bad1d21cb1212a2b9ed2f198336f8fff39 Mon Sep 17 00:00:00 2001 From: Harshit Sangani Date: Tue, 5 May 2026 11:36:47 +0530 Subject: [PATCH 1/2] feat(dart): add Dart/Flutter client code generation Add a new `@orval/dart` package that generates Dart model classes and Dio-based API client code from an OpenAPI specification. - Add `DART` to `OutputClient` enum in `@orval/core` - Implement Dart type mapper with support for nullable (`anyOf`), `$ref`, arrays, DateTime, binary, and mixed types - Generate model classes with `fromJson`, `toJson`, and `copyWith` - Generate Dio API client classes grouped by URL path segment - Wire `@orval/dart` into the main `orval` package client resolver - Skip TypeScript schema/implementation output when client is `dart` - Add example config (`orval.dart.config.ts`) --- bun.lock | 17 ++ orval.dart.config.ts | 12 + packages/core/src/types.ts | 1 + packages/dart/package.json | 53 ++++ packages/dart/src/dart-types.ts | 390 ++++++++++++++++++++++++++ packages/dart/src/generate-client.ts | 399 +++++++++++++++++++++++++++ packages/dart/src/generate-models.ts | 166 +++++++++++ packages/dart/src/index.ts | 58 ++++ packages/dart/src/utils.ts | 125 +++++++++ packages/dart/tsconfig.build.json | 20 ++ packages/dart/tsconfig.json | 6 + packages/dart/tsdown.config.ts | 11 + packages/orval/package.json | 1 + packages/orval/src/client.ts | 2 + packages/orval/src/write-specs.ts | 8 +- 15 files changed, 1266 insertions(+), 3 deletions(-) create mode 100644 orval.dart.config.ts create mode 100644 packages/dart/package.json create mode 100644 packages/dart/src/dart-types.ts create mode 100644 packages/dart/src/generate-client.ts create mode 100644 packages/dart/src/generate-models.ts create mode 100644 packages/dart/src/index.ts create mode 100644 packages/dart/src/utils.ts create mode 100644 packages/dart/tsconfig.build.json create mode 100644 packages/dart/tsconfig.json create mode 100644 packages/dart/tsdown.config.ts diff --git a/bun.lock b/bun.lock index 7896fcae0e..24ee8632b7 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,20 @@ "@faker-js/faker", ], }, + "packages/dart": { + "name": "@orval/dart", + "version": "8.9.1", + "dependencies": { + "@orval/core": "workspace:*", + }, + "devDependencies": { + "eslint": "catalog:", + "rimraf": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/fetch": { "name": "@orval/fetch", "version": "8.9.1", @@ -165,6 +179,7 @@ "@orval/angular": "workspace:*", "@orval/axios": "workspace:*", "@orval/core": "workspace:*", + "@orval/dart": "workspace:*", "@orval/fetch": "workspace:*", "@orval/hono": "workspace:*", "@orval/mcp": "workspace:*", @@ -1522,6 +1537,8 @@ "@orval/core": ["@orval/core@workspace:packages/core"], + "@orval/dart": ["@orval/dart@workspace:packages/dart"], + "@orval/fetch": ["@orval/fetch@workspace:packages/fetch"], "@orval/hono": ["@orval/hono@workspace:packages/hono"], diff --git a/orval.dart.config.ts b/orval.dart.config.ts new file mode 100644 index 0000000000..8934a0bae6 --- /dev/null +++ b/orval.dart.config.ts @@ -0,0 +1,12 @@ +export default { + dartApi: { + input: { + target: 'http://localhost:8000/openapi.json', + }, + output: { + target: 'lib/generated/', + client: 'dart', + mode: 'single', + }, + }, +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e4f0d837f1..c2324e8d02 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -361,6 +361,7 @@ export const OutputClient = { HONO: 'hono', FETCH: 'fetch', MCP: 'mcp', + DART: 'dart', } as const; export type OutputClient = (typeof OutputClient)[keyof typeof OutputClient]; diff --git a/packages/dart/package.json b/packages/dart/package.json new file mode 100644 index 0000000000..8624b7ded3 --- /dev/null +++ b/packages/dart/package.json @@ -0,0 +1,53 @@ +{ + "name": "@orval/dart", + "version": "8.9.1", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/orval-labs/orval.git", + "directory": "packages/dart" + }, + "homepage": "https://orval.dev", + "bugs": { + "url": "https://github.com/orval-labs/orval/issues" + }, + "type": "module", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "development": "./src/index.ts", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "!dist/**/*.d.ts.map", + "!dist/**/*.d.mts.map" + ], + "scripts": { + "build": "tsdown --config-loader unrun", + "dev": "tsdown --config-loader unrun --watch src", + "lint": "eslint .", + "test": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rimraf .turbo dist", + "nuke": "rimraf .turbo dist node_modules" + }, + "dependencies": { + "@orval/core": "workspace:*" + }, + "devDependencies": { + "eslint": "catalog:", + "rimraf": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "publishConfig": { + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + } + } +} diff --git a/packages/dart/src/dart-types.ts b/packages/dart/src/dart-types.ts new file mode 100644 index 0000000000..8674150dad --- /dev/null +++ b/packages/dart/src/dart-types.ts @@ -0,0 +1,390 @@ +import { toCamelCase, toSnakeCase } from './utils'; + +interface SchemaLike { + type?: string | string[]; + format?: string; + $ref?: string; + items?: SchemaLike; + anyOf?: SchemaLike[]; + oneOf?: SchemaLike[]; + allOf?: SchemaLike[]; + properties?: Record; + additionalProperties?: boolean | SchemaLike; + enum?: unknown[]; + const?: unknown; + default?: unknown; + required?: string[]; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + minLength?: number; + maxLength?: number; + minItems?: number; + maxItems?: number; +} + +export type { SchemaLike }; + +export interface DartTypeResult { + type: string; + nullable: boolean; + imports: string[]; + isDateTime: boolean; + isDateOnly: boolean; + isTimeOnly: boolean; + isBinary: boolean; + isList: boolean; + listItemType?: DartTypeResult; + isReference: boolean; + referenceName?: string; +} + +function makeResult(overrides: Partial = {}): DartTypeResult { + return { + type: 'dynamic', + nullable: false, + imports: [], + isDateTime: false, + isDateOnly: false, + isTimeOnly: false, + isBinary: false, + isList: false, + isReference: false, + ...overrides, + }; +} + +function extractRefName(ref: string): string { + return ref.split('/').pop() ?? ref; +} + +/** + * Resolve an OpenAPI schema to a Dart type descriptor. + */ +export function resolveDartType(schema: SchemaLike): DartTypeResult { + if (!schema) return makeResult(); + + // $ref + if (schema.$ref) { + const refName = extractRefName(schema.$ref); + return makeResult({ + type: refName, + isReference: true, + referenceName: refName, + imports: [toSnakeCase(refName)], + }); + } + + // anyOf / oneOf – handle nullable pattern and mixed types + const unionKey = schema.anyOf ? 'anyOf' : schema.oneOf ? 'oneOf' : null; + if (unionKey) { + const variants = schema[unionKey]!; + const nonNull = variants.filter( + (v) => + !( + v.type === 'null' || + (Array.isArray(v.type) && v.type.includes('null')) + ), + ); + const hasNull = nonNull.length < variants.length; + + if (nonNull.length === 1) { + const inner = resolveDartType(nonNull[0]); + return { ...inner, nullable: hasNull || inner.nullable }; + } + if (nonNull.length === 0) { + return makeResult({ nullable: true }); + } + // Multiple non-null types → dynamic + return makeResult({ nullable: hasNull }); + } + + // allOf – merge properties, use first $ref as base + if (schema.allOf) { + const refSchema = schema.allOf.find((s) => s.$ref); + if (refSchema) return resolveDartType(refSchema); + return makeResult({ type: 'Map' }); + } + + // array + if (schema.type === 'array' && schema.items) { + const itemResult = resolveDartType(schema.items); + return makeResult({ + type: `List<${itemResult.type}>`, + isList: true, + listItemType: itemResult, + imports: itemResult.imports, + }); + } + + // enum + if (schema.enum) { + return makeResult({ type: 'String' }); + } + + // primitives + const typ = Array.isArray(schema.type) ? schema.type[0] : schema.type; + switch (typ) { + case 'string': + if (schema.format === 'date') + return makeResult({ type: 'DateTime', isDateOnly: true }); + if (schema.format === 'date-time') + return makeResult({ type: 'DateTime', isDateTime: true }); + if (schema.format === 'time') + return makeResult({ type: 'String', isTimeOnly: true }); + if (schema.format === 'binary') + return makeResult({ type: 'dynamic', isBinary: true }); + return makeResult({ type: 'String' }); + case 'integer': + return makeResult({ type: 'int' }); + case 'number': + return makeResult({ type: 'double' }); + case 'boolean': + return makeResult({ type: 'bool' }); + case 'object': + return makeResult({ type: 'Map' }); + case 'null': + return makeResult({ nullable: true }); + default: + return makeResult(); + } +} + +export interface DartField { + name: string; + jsonName: string; + dartType: string; + fullType: string; + isRequired: boolean; + isNullable: boolean; + defaultValue: string | undefined; + typeResult: DartTypeResult; +} + +/** + * Resolve all properties of a schema into DartField descriptors. + */ +export function resolveSchemaFields(schema: SchemaLike): DartField[] { + const properties = (schema.properties ?? {}) as Record; + const requiredSet = new Set(schema.required ?? []); + const fields: DartField[] = []; + + for (const [jsonName, propSchema] of Object.entries(properties)) { + const typeResult = resolveDartType(propSchema); + const isRequired = requiredSet.has(jsonName); + const isNullable = + typeResult.nullable || (!isRequired && !('default' in propSchema)); + + const dartType = typeResult.type; + const fullType = + isNullable && dartType !== 'dynamic' ? `${dartType}?` : dartType; + const name = escapeDartFieldName(toCamelCase(jsonName)); + + let defaultValue: string | undefined; + if (propSchema.default !== undefined) { + defaultValue = dartLiteral(propSchema.default, typeResult); + } + + fields.push({ + name, + jsonName, + dartType, + fullType, + isRequired: isRequired && defaultValue === undefined, + isNullable, + defaultValue, + typeResult, + }); + } + return fields; +} + +function escapeDartFieldName(name: string): string { + const DART_RESERVED = new Set([ + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'default', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'get', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', + ]); + return DART_RESERVED.has(name) ? `${name}_` : name; +} + +function dartLiteral( + value: unknown, + typeResult: DartTypeResult, +): string | undefined { + if (value === null) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') return String(value); + if (typeof value === 'string') { + if (typeResult.isDateTime || typeResult.isDateOnly) return undefined; + return `'${value.replace(/'/g, "\\'")}'`; + } + return undefined; +} + +/** + * Generate the fromJson expression for a field. + */ +export function fromJsonExpr(field: DartField): string { + const accessor = `json['${field.jsonName}']`; + return fromJsonForType(accessor, field.typeResult, field.isNullable); +} + +function fromJsonForType( + accessor: string, + tr: DartTypeResult, + nullable: boolean, +): string { + if (tr.isReference) { + if (nullable) { + return `${accessor} != null ? ${tr.type}.fromJson(${accessor} as Map) : null`; + } + return `${tr.type}.fromJson(${accessor} as Map)`; + } + + if (tr.isList && tr.listItemType) { + const itemTr = tr.listItemType; + let mapExpr: string; + if (itemTr.isReference) { + mapExpr = `(e) => ${itemTr.type}.fromJson(e as Map)`; + } else if (itemTr.isDateTime || itemTr.isDateOnly) { + mapExpr = `(e) => DateTime.parse(e as String)`; + } else { + mapExpr = `(e) => e as ${itemTr.type}`; + } + const cast = `(${accessor} as List).map(${mapExpr}).toList()`; + if (nullable) return `${accessor} != null ? ${cast} : null`; + return cast; + } + + if (tr.isDateTime || tr.isDateOnly) { + if (nullable) + return `${accessor} != null ? DateTime.parse(${accessor} as String) : null`; + return `DateTime.parse(${accessor} as String)`; + } + + if (tr.type === 'Map') { + if (nullable) + return `${accessor} != null ? Map.from(${accessor} as Map) : null`; + return `Map.from(${accessor} as Map)`; + } + + if (tr.type === 'dynamic') return accessor; + + if (tr.type === 'double') { + if (nullable) + return `${accessor} != null ? (${accessor} as num).toDouble() : null`; + return `(${accessor} as num).toDouble()`; + } + + if (nullable) return `${accessor} as ${tr.type}?`; + return `${accessor} as ${tr.type}`; +} + +/** + * Generate the toJson expression for a field. + */ +export function toJsonExpr(field: DartField): string { + return toJsonForType(field.name, field.typeResult, field.isNullable); +} + +function toJsonForType( + accessor: string, + tr: DartTypeResult, + nullable: boolean, +): string { + if (tr.isReference) { + if (nullable) return `${accessor}?.toJson()`; + return `${accessor}.toJson()`; + } + + if (tr.isList && tr.listItemType) { + const itemTr = tr.listItemType; + if (itemTr.isReference) { + const mapExpr = `(e) => e.toJson()`; + if (nullable) return `${accessor}?.map(${mapExpr}).toList()`; + return `${accessor}.map(${mapExpr}).toList()`; + } + if (itemTr.isDateTime || itemTr.isDateOnly) { + const fmt = itemTr.isDateOnly + ? `(e) => e.toIso8601String().split('T').first` + : `(e) => e.toIso8601String()`; + if (nullable) return `${accessor}?.map(${fmt}).toList()`; + return `${accessor}.map(${fmt}).toList()`; + } + return accessor; + } + + if (tr.isDateOnly) { + if (nullable) return `${accessor}?.toIso8601String().split('T').first`; + return `${accessor}.toIso8601String().split('T').first`; + } + + if (tr.isDateTime) { + if (nullable) return `${accessor}?.toIso8601String()`; + return `${accessor}.toIso8601String()`; + } + + return accessor; +} diff --git a/packages/dart/src/generate-client.ts b/packages/dart/src/generate-client.ts new file mode 100644 index 0000000000..21b61858e6 --- /dev/null +++ b/packages/dart/src/generate-client.ts @@ -0,0 +1,399 @@ +import nodePath from 'node:path'; + +import type { ClientFileBuilder } from '@orval/core'; + +import { resolveDartType, type SchemaLike } from './dart-types'; +import { sanitizeTagName, toCamelCase, toSnakeCase } from './utils'; + +interface OpenApiSpec { + paths?: Record>; + components?: { + schemas?: Record; + securitySchemes?: Record; + }; + servers?: { url: string }[]; +} + +interface OperationObject { + tags?: string[]; + summary?: string; + operationId?: string; + parameters?: ParameterObject[]; + requestBody?: { + required?: boolean; + content?: Record; + }; + responses?: Record; + security?: Record[]; +} + +interface ParameterObject { + name: string; + in: string; + required?: boolean; + schema?: SchemaLike; +} + +interface ResponseObject { + description?: string; + content?: Record; +} + +interface OperationInfo { + httpMethod: string; + path: string; + operationId: string; + summary?: string; + pathParams: ParameterObject[]; + queryParams: ParameterObject[]; + requestBodySchema: SchemaLike | undefined; + requestBodyRequired: boolean; + responseSchema: SchemaLike | undefined; + requiresAuth: boolean; + isMultipart: boolean; +} + +/** + * Generate Dart API client files grouped by tag. + */ +export function generateDartApiClients( + spec: OpenApiSpec, + apiDir: string, + allSchemaNames: string[], +): ClientFileBuilder[] { + const operations = extractOperations(spec); + const grouped = groupByTag(operations); + const files: ClientFileBuilder[] = []; + + for (const [tag, ops] of Object.entries(grouped)) { + const className = sanitizeTagName(tag) + 'Api'; + const content = generateApiClass(className, ops, allSchemaNames); + const fileName = toSnakeCase(className) + '.dart'; + files.push({ + path: nodePath.join(apiDir, fileName), + content, + }); + } + + files.push({ + path: nodePath.join(apiDir, 'api.dart'), + content: generateApiBarrel(grouped), + }); + + return files; +} + +function extractOperations(spec: OpenApiSpec): OperationInfo[] { + const ops: OperationInfo[] = []; + const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head']; + + for (const [pathStr, pathItem] of Object.entries(spec.paths ?? {})) { + for (const method of httpMethods) { + const operation = pathItem[method] as OperationObject | undefined; + if (!operation) continue; + + const params = operation.parameters ?? []; + const pathParams = params.filter((p) => p.in === 'path'); + const queryParams = params.filter((p) => p.in === 'query'); + + let requestBodySchema: SchemaLike | undefined; + let isMultipart = false; + let requestBodyRequired = false; + if (operation.requestBody?.content) { + const contentTypes = Object.keys(operation.requestBody.content); + if (contentTypes.includes('multipart/form-data')) { + requestBodySchema = + operation.requestBody.content['multipart/form-data'].schema; + isMultipart = true; + } else { + const ct = contentTypes[0]; + requestBodySchema = operation.requestBody.content[ct]?.schema; + } + requestBodyRequired = operation.requestBody.required ?? false; + } + + let responseSchema: SchemaLike | undefined; + const successResp = + operation.responses?.['200'] ?? operation.responses?.['201']; + if (successResp?.content) { + const ct = Object.keys(successResp.content)[0]; + responseSchema = successResp.content[ct]?.schema; + } + + const requiresAuth = (operation.security ?? []).length > 0; + + ops.push({ + httpMethod: method, + path: pathStr, + operationId: + operation.operationId ?? + `${method}_${pathStr.replace(/[^a-zA-Z0-9]/g, '_')}`, + summary: operation.summary, + pathParams, + queryParams, + requestBodySchema, + requestBodyRequired, + responseSchema, + requiresAuth, + isMultipart, + }); + } + } + + return ops; +} + +function groupByTag( + operations: OperationInfo[], +): Record { + const spec_operations = operations.reduce( + (acc, op) => { + const tag = + extractOperations.length > 0 + ? operations.find((o) => o === op) + ? getTag(op) + : 'Default' + : 'Default'; + if (!acc[tag]) acc[tag] = []; + acc[tag].push(op); + return acc; + }, + {} as Record, + ); + return spec_operations; +} + +function getTag(op: OperationInfo): string { + // Derive tag from path: /api/{module}/... → module + const parts = op.path.split('/').filter(Boolean); + if (parts.length >= 2 && parts[0] === 'api') { + return toCamelCase(parts[1]); + } + return 'default'; +} + +function generateApiClass( + className: string, + operations: OperationInfo[], + allSchemaNames: string[], +): string { + const allSchemaSet = new Set(allSchemaNames); + const imports = new Set(); + const usedNames = new Set(); + + const methods: string[] = []; + for (const op of operations) { + const { method, referencedSchemas } = generateMethod( + op, + allSchemaSet, + usedNames, + ); + methods.push(method); + for (const s of referencedSchemas) imports.add(s); + } + + let out = "import 'package:dio/dio.dart';\n\n"; + + for (const imp of [...imports].sort()) { + out += `import '../models/${imp}.dart';\n`; + } + if (imports.size > 0) out += '\n'; + + out += `class ${className} {\n`; + out += ' final Dio _dio;\n\n'; + out += ` ${className}(this._dio);\n`; + + for (const m of methods) { + out += '\n' + m; + } + + out += '}\n'; + return out; +} + +/** + * Derive a short, readable Dart method name from an operation. + * Prefers the summary (e.g. "Send OTP") → sendOtp. + * Falls back to a cleaned operationId. + */ +function deriveMethodName(op: OperationInfo): string { + if (op.summary) { + const words = op.summary + .replace(/[^a-zA-Z0-9\s]/g, '') + .trim() + .split(/\s+/); + if (words.length > 0 && words[0]) { + return toCamelCase(words.map((w) => w.toLowerCase()).join('_')); + } + } + return toCamelCase(op.operationId.replace(/[^a-zA-Z0-9_]/g, '_')); +} + +function generateMethod( + op: OperationInfo, + allSchemaSet: Set, + usedNames: Set, +): { method: string; referencedSchemas: string[] } { + const refs: string[] = []; + let methodName = deriveMethodName(op); + // Deduplicate within the same API class + if (usedNames.has(methodName)) { + methodName = `${methodName}${op.httpMethod.charAt(0).toUpperCase() + op.httpMethod.slice(1)}`; + } + usedNames.add(methodName); + + const params: string[] = []; + const pathSubstitutions: Record = {}; + + // path params + for (const p of op.pathParams) { + const tr = resolveDartType(p.schema ?? { type: 'string' }); + const dartName = toCamelCase(p.name); + params.push(`required ${tr.type} ${dartName}`); + pathSubstitutions[p.name] = dartName; + } + + // query params + const queryParamNames: { + jsonName: string; + dartName: string; + required: boolean; + dartType: string; + }[] = []; + for (const p of op.queryParams) { + const tr = resolveDartType(p.schema ?? { type: 'string' }); + const dartName = toCamelCase(p.name); + const isRequired = p.required ?? false; + const type = isRequired + ? tr.type + : tr.type === 'dynamic' + ? 'dynamic' + : `${tr.type}?`; + params.push(`${isRequired ? 'required ' : ''}${type} ${dartName}`); + queryParamNames.push({ + jsonName: p.name, + dartName, + required: isRequired, + dartType: tr.type, + }); + } + + // request body + let bodyParamName: string | undefined; + let bodyTypeName: string | undefined; + if (op.requestBodySchema && !op.isMultipart) { + const tr = resolveDartType(op.requestBodySchema); + bodyTypeName = tr.type; + if (tr.isReference && tr.referenceName) { + refs.push(toSnakeCase(tr.referenceName)); + } + bodyParamName = 'body'; + const req = op.requestBodyRequired ? 'required ' : ''; + const type = op.requestBodyRequired ? tr.type : `${tr.type}?`; + params.push(`${req}${type} ${bodyParamName}`); + } + + // multipart body + if (op.isMultipart) { + bodyParamName = 'formData'; + params.push('required FormData formData'); + } + + // response type (currently returns Response; refs not imported + // to avoid unused-import warnings until typed deserialization is added) + let responseType = 'dynamic'; + if (op.responseSchema) { + const tr = resolveDartType(op.responseSchema); + responseType = tr.type; + } + + // check if response is empty schema + const isEmptyResponse = + !op.responseSchema || + (op.responseSchema && + !op.responseSchema.$ref && + !op.responseSchema.type && + !op.responseSchema.anyOf && + !op.responseSchema.oneOf && + !op.responseSchema.allOf); + + const returnType = isEmptyResponse + ? 'Response' + : `Response`; + + // build path with interpolation + let dartPath = op.path; + for (const [paramName, dartName] of Object.entries(pathSubstitutions)) { + dartPath = dartPath.replace(`{${paramName}}`, `\$${dartName}`); + } + + // build method + let out = ''; + if (op.summary) { + out += ` /// ${op.summary}\n`; + } + out += ` Future<${returnType}> ${methodName}(`; + if (params.length > 0) { + out += '{\n'; + for (const p of params) { + out += ` ${p},\n`; + } + out += ' }'; + } + out += ') async {\n'; + + // query parameters map + if (queryParamNames.length > 0) { + out += ' final queryParameters = {\n'; + for (const q of queryParamNames) { + if (q.required) { + out += ` '${q.jsonName}': ${q.dartName},\n`; + } + } + out += ' };\n'; + for (const q of queryParamNames) { + if (!q.required) { + out += ` if (${q.dartName} != null) {\n`; + out += ` queryParameters['${q.jsonName}'] = ${q.dartName};\n`; + out += ' }\n'; + } + } + out += '\n'; + } + + // dio call + const dioMethod = op.httpMethod; + out += ` final response = await _dio.${dioMethod}(\n`; + out += ` '${dartPath}',\n`; + if (bodyParamName && !op.isMultipart) { + const isRef = op.requestBodySchema?.$ref; + if (isRef) { + out += ` data: ${bodyParamName}.toJson(),\n`; + } else { + out += ` data: ${bodyParamName},\n`; + } + } + if (op.isMultipart) { + out += ` data: ${bodyParamName},\n`; + } + if (queryParamNames.length > 0) { + out += ' queryParameters: queryParameters,\n'; + } + out += ' );\n'; + out += ' return response;\n'; + out += ' }\n'; + + return { method: out, referencedSchemas: refs }; +} + +function generateApiBarrel(grouped: Record): string { + return ( + Object.keys(grouped) + .map((tag) => { + const className = sanitizeTagName(tag) + 'Api'; + return `export '${toSnakeCase(className)}.dart';`; + }) + .sort() + .join('\n') + '\n' + ); +} diff --git a/packages/dart/src/generate-models.ts b/packages/dart/src/generate-models.ts new file mode 100644 index 0000000000..d129a01611 --- /dev/null +++ b/packages/dart/src/generate-models.ts @@ -0,0 +1,166 @@ +import nodePath from 'node:path'; + +import type { ClientFileBuilder } from '@orval/core'; + +import { + type DartField, + fromJsonExpr, + resolveSchemaFields, + type SchemaLike, + toJsonExpr, +} from './dart-types'; +import { toSnakeCase } from './utils'; + +/** + * Generate Dart model files for every component schema in the spec. + */ +export function generateDartModels( + schemas: Record, + modelsDir: string, +): ClientFileBuilder[] { + const files: ClientFileBuilder[] = []; + const schemaNames = Object.keys(schemas); + + for (const [name, schema] of Object.entries(schemas)) { + const content = generateDartClass(name, schema, schemaNames); + const fileName = toSnakeCase(name) + '.dart'; + files.push({ + path: nodePath.join(modelsDir, fileName), + content, + }); + } + + files.push({ + path: nodePath.join(modelsDir, 'models.dart'), + content: generateModelsBarrel(schemaNames), + }); + + return files; +} + +function generateDartClass( + className: string, + schema: SchemaLike, + allSchemaNames: string[], +): string { + const fields = resolveSchemaFields(schema); + const imports = collectImports(fields, className, allSchemaNames); + + let out = ''; + + // imports + for (const imp of imports) { + out += `import '${imp}.dart';\n`; + } + if (imports.length > 0) out += '\n'; + + // class declaration + out += `class ${className} {\n`; + + // fields + for (const f of fields) { + out += ` final ${f.fullType} ${f.name};\n`; + } + + if (fields.length > 0) out += '\n'; + + // constructor + out += ` ${className}({\n`; + for (const f of fields) { + const req = f.isRequired ? 'required ' : ''; + const def = f.defaultValue !== undefined ? ` = ${f.defaultValue}` : ''; + out += ` ${req}this.${f.name}${def},\n`; + } + out += ' });\n\n'; + + // fromJson + out += generateFromJson(className, fields); + out += '\n'; + + // toJson + out += generateToJson(fields); + + // copyWith + out += '\n'; + out += generateCopyWith(className, fields); + + out += '}\n'; + return out; +} + +function generateFromJson(className: string, fields: DartField[]): string { + let out = ` factory ${className}.fromJson(Map json) {\n`; + out += ` return ${className}(\n`; + for (const f of fields) { + out += ` ${f.name}: ${fromJsonExpr(f)},\n`; + } + out += ' );\n'; + out += ' }\n'; + return out; +} + +function generateToJson(fields: DartField[]): string { + let out = ' Map toJson() {\n'; + out += ' return {\n'; + for (const f of fields) { + out += ` '${f.jsonName}': ${toJsonExpr(f)},\n`; + } + out += ' };\n'; + out += ' }\n'; + return out; +} + +function generateCopyWith(className: string, fields: DartField[]): string { + if (fields.length === 0) return ''; + + let out = ` ${className} copyWith({\n`; + for (const f of fields) { + const paramType = f.dartType === 'dynamic' ? 'dynamic' : `${f.dartType}?`; + out += ` ${paramType} ${f.name},\n`; + } + out += ' }) {\n'; + out += ` return ${className}(\n`; + for (const f of fields) { + out += ` ${f.name}: ${f.name} ?? this.${f.name},\n`; + } + out += ' );\n'; + out += ' }\n'; + return out; +} + +function collectImports( + fields: DartField[], + selfName: string, + allSchemaNames: string[], +): string[] { + const allSchemaSnake = new Set(allSchemaNames.map(toSnakeCase)); + const selfSnake = toSnakeCase(selfName); + const imports = new Set(); + + for (const f of fields) { + for (const imp of f.typeResult.imports) { + if (imp !== selfSnake && allSchemaSnake.has(imp)) { + imports.add(imp); + } + } + if (f.typeResult.listItemType) { + for (const imp of f.typeResult.listItemType.imports) { + if (imp !== selfSnake && allSchemaSnake.has(imp)) { + imports.add(imp); + } + } + } + } + + return [...imports].sort(); +} + +function generateModelsBarrel(schemaNames: string[]): string { + return ( + schemaNames + .map((n) => toSnakeCase(n)) + .sort() + .map((n) => `export '${n}.dart';`) + .join('\n') + '\n' + ); +} diff --git a/packages/dart/src/index.ts b/packages/dart/src/index.ts new file mode 100644 index 0000000000..b8388ee785 --- /dev/null +++ b/packages/dart/src/index.ts @@ -0,0 +1,58 @@ +import nodePath from 'node:path'; + +import type { + ClientBuilder, + ClientExtraFilesBuilder, + ClientFileBuilder, + ClientGeneratorsBuilder, +} from '@orval/core'; + +import { generateDartApiClients } from './generate-client'; +import { generateDartModels } from './generate-models'; + +const generateDartClient: ClientBuilder = () => { + return { implementation: '', imports: [] }; +}; + +const generateDartExtraFiles: ClientExtraFilesBuilder = async ( + _verbOptions, + output, + context, +) => { + // Dart target is a directory, not a file — use it directly. + // Strip a trailing file extension if someone passes a .ts/.dart file path. + const raw = output.target; + const outputDir = /\.\w+$/.test(nodePath.basename(raw)) + ? nodePath.dirname(raw) + : raw; + const modelsDir = nodePath.join(outputDir, 'models'); + const apiDir = nodePath.join(outputDir, 'api'); + + const schemas = + (context.spec.components?.schemas as Record) ?? {}; + const allSchemaNames = Object.keys(schemas); + + const modelFiles = generateDartModels(schemas, modelsDir); + const apiFiles = generateDartApiClients(context.spec, apiDir, allSchemaNames); + + const barrelLines = [ + "export 'models/models.dart';", + "export 'api/api.dart';", + '', + ]; + const barrelFile: ClientFileBuilder = { + path: nodePath.join(outputDir, 'generated.dart'), + content: barrelLines.join('\n'), + }; + + return [...modelFiles, ...apiFiles, barrelFile]; +}; + +const dartClientBuilder: ClientGeneratorsBuilder = { + client: generateDartClient, + extraFiles: generateDartExtraFiles, +}; + +export const builder = () => () => dartClientBuilder; + +export default builder; diff --git a/packages/dart/src/utils.ts b/packages/dart/src/utils.ts new file mode 100644 index 0000000000..f14b376d25 --- /dev/null +++ b/packages/dart/src/utils.ts @@ -0,0 +1,125 @@ +/** + * Convert a string to snake_case (Dart file naming convention). + */ +export function toSnakeCase(str: string): string { + return str + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') + .replace(/[\s-]+/g, '_') + .toLowerCase(); +} + +/** + * Convert a string to camelCase (Dart property naming convention). + */ +export function toCamelCase(str: string): string { + return str.replace(/[_-](\w)/g, (_, c: string) => c.toUpperCase()); +} + +/** + * Convert a string to PascalCase (Dart class naming convention). + */ +export function toPascalCase(str: string): string { + const camel = toCamelCase(str); + return camel.charAt(0).toUpperCase() + camel.slice(1); +} + +/** + * Sanitize a tag name into a valid Dart class name. + * e.g. "Tenant · Emergency" → "TenantEmergency" + */ +export function sanitizeTagName(tag: string): string { + return tag + .replace(/[^a-zA-Z0-9\s]/g, '') + .trim() + .split(/\s+/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(''); +} + +/** + * Sanitize a schema name for use as a Dart class name. + * Handles names like "Body_bulk_upload_api_flat_bulk_upload__post". + */ +export function sanitizeClassName(name: string): string { + return name + .split(/[_\s]+/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(''); +} + +const DART_RESERVED = new Set([ + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'Function', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +]); + +/** + * Escape a Dart reserved word by appending an underscore suffix. + */ +export function escapeDartReserved(name: string): string { + return DART_RESERVED.has(name) ? `${name}_` : name; +} diff --git a/packages/dart/tsconfig.build.json b/packages/dart/tsconfig.build.json new file mode 100644 index 0000000000..b935bd8dc4 --- /dev/null +++ b/packages/dart/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/dart/tsconfig.json b/packages/dart/tsconfig.json new file mode 100644 index 0000000000..43240dd64a --- /dev/null +++ b/packages/dart/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": "src" + } +} diff --git a/packages/dart/tsdown.config.ts b/packages/dart/tsdown.config.ts new file mode 100644 index 0000000000..d492e12a45 --- /dev/null +++ b/packages/dart/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + target: 'node22', + platform: 'node', + format: 'esm', + tsconfig: 'tsconfig.build.json', + dts: { sourcemap: true }, + exports: { devExports: 'development' }, +}); diff --git a/packages/orval/package.json b/packages/orval/package.json index 1f4199b9be..c812d667dc 100644 --- a/packages/orval/package.json +++ b/packages/orval/package.json @@ -75,6 +75,7 @@ "@orval/angular": "workspace:*", "@orval/axios": "workspace:*", "@orval/core": "workspace:*", + "@orval/dart": "workspace:*", "@orval/fetch": "workspace:*", "@orval/hono": "workspace:*", "@orval/mcp": "workspace:*", diff --git a/packages/orval/src/client.ts b/packages/orval/src/client.ts index 4addbfb37b..4dcc521dd2 100644 --- a/packages/orval/src/client.ts +++ b/packages/orval/src/client.ts @@ -1,5 +1,6 @@ import angular from '@orval/angular'; import axios from '@orval/axios'; +import dart from '@orval/dart'; import type { AngularOptions, ClientFileBuilder, @@ -61,6 +62,7 @@ const getGeneratorClient = ( hono: hono()(), fetch: fetchClient()(), mcp: mcp()(), + dart: dart()(), }; const generator = isFunction(outputClient) diff --git a/packages/orval/src/write-specs.ts b/packages/orval/src/write-specs.ts index b2b4b556e3..32faba5bff 100644 --- a/packages/orval/src/write-specs.ts +++ b/packages/orval/src/write-specs.ts @@ -12,6 +12,7 @@ import { logWarning, type NormalizedOptions, type OpenApiInfoObject, + OutputClient, OutputMode, splitSchemasByType, SupportedFormatter, @@ -144,8 +145,9 @@ export async function writeSpecs( const projectTitle = projectName ?? info.title; const header = getHeader(output.override.header, info); + const isDart = output.client === OutputClient.DART; - if (output.schemas) { + if (output.schemas && !isDart) { if (isString(output.schemas)) { const fileExtension = output.fileExtension || '.ts'; const schemaPath = output.schemas; @@ -325,7 +327,7 @@ export async function writeSpecs( let implementationPaths: string[] = []; - if (output.target) { + if (output.target && !isDart) { const writeMode = getWriteMode(output.mode); const isZodClient = output.client === 'zod'; const hasOperations = Object.keys(builder.operations).length > 0; @@ -345,7 +347,7 @@ export async function writeSpecs( }); } - if (output.workspace) { + if (output.workspace && !isDart) { const workspacePath = output.workspace; const indexFile = path.join(workspacePath, 'index.ts'); const imports = implementationPaths From a64d8f58aadb31ea6a5c1bab847bca6ba106033b Mon Sep 17 00:00:00 2001 From: Harshit Sangani Date: Tue, 5 May 2026 11:37:04 +0530 Subject: [PATCH 2/2] docs(dart): add Dart/Flutter client guide and reference - Add guides/dart.mdx with configuration, generated output examples, type mapping table, and Flutter usage instructions - Add dart to the Frameworks section in guides/meta.json - Add dart to the client options list in reference/configuration/output.mdx --- docs/content/docs/guides/dart.mdx | 180 ++++++++++++++++++ docs/content/docs/guides/meta.json | 1 + .../docs/reference/configuration/output.mdx | 2 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 docs/content/docs/guides/dart.mdx diff --git a/docs/content/docs/guides/dart.mdx b/docs/content/docs/guides/dart.mdx new file mode 100644 index 0000000000..d81adcd69d --- /dev/null +++ b/docs/content/docs/guides/dart.mdx @@ -0,0 +1,180 @@ +--- +title: Dart / Flutter +description: Generate Dart models and Dio API clients from OpenAPI +--- + +Generate idiomatic [Dart](https://dart.dev/) model classes and [Dio](https://pub.dev/packages/dio)-based API clients from your OpenAPI specification — ready to drop into any Flutter or Dart project. + +## Configuration + +Set the `client` option to `dart` and point the `target` at the directory where you want the generated code: + +```ts title="orval.config.ts" +import { defineConfig } from 'orval'; + +export default defineConfig({ + myApi: { + input: { + target: './openapi.yaml', + }, + output: { + client: 'dart', + target: './my_flutter_package/lib/src/generated/', + }, + }, +}); +``` + + +The `dart` client target is a **directory**, not a file. All generated `.dart` files are written inside it. + + +## Generated Structure + +``` +my_flutter_package/lib/src/generated/ +├── models/ +│ ├── pet.dart +│ ├── create_pet_body.dart +│ ├── error.dart +│ └── models.dart # barrel export +├── api/ +│ ├── pets_api.dart +│ ├── users_api.dart +│ └── api.dart # barrel export +└── generated.dart # top-level barrel +``` + +## Models + +Each OpenAPI component schema produces a Dart class with `fromJson`, `toJson`, and `copyWith`: + +```dart title="models/pet.dart" +class Pet { + final int id; + final String name; + final String? tag; + + Pet({ + required this.id, + required this.name, + this.tag, + }); + + factory Pet.fromJson(Map json) { + return Pet( + id: json['id'] as int, + name: json['name'] as String, + tag: json['tag'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'tag': tag, + }; + } + + Pet copyWith({ + int? id, + String? name, + String? tag, + }) { + return Pet( + id: id ?? this.id, + name: name ?? this.name, + tag: tag ?? this.tag, + ); + } +} +``` + +## Type Mapping + +| OpenAPI | Dart | +|---------|------| +| `string` | `String` | +| `integer` | `int` | +| `number` | `double` | +| `boolean` | `bool` | +| `array` | `List` | +| `object` (no properties) | `Map` | +| `string` + `format: date` | `DateTime` (date-only serialization) | +| `string` + `format: date-time` | `DateTime` | +| `string` + `format: binary` | `dynamic` (use `FormData` for uploads) | +| `$ref` | Referenced class | +| `anyOf: [T, null]` | `T?` | +| `anyOf: [T1, T2]` | `dynamic` | + +## API Client + +Operations are grouped by URL path segment and wrapped in Dio-based API classes: + +```dart title="api/pets_api.dart" +import 'package:dio/dio.dart'; +import '../models/pet.dart'; + +class PetsApi { + final Dio _dio; + + PetsApi(this._dio); + + /// List all pets + Future> listPets({ + int? limit, + }) async { + final queryParameters = {}; + if (limit != null) { + queryParameters['limit'] = limit; + } + + final response = await _dio.get( + '/pets', + queryParameters: queryParameters, + ); + return response; + } + + /// Create a pet + Future> createPet({ + required Pet body, + }) async { + final response = await _dio.post( + '/pets', + data: body.toJson(), + ); + return response; + } +} +``` + +### Features + +- **Path parameters** use Dart string interpolation: `/pets/$petId` +- **Query parameters** are collected into a map with null checks for optional params +- **Request bodies** are serialized via `toJson()` for `$ref` types +- **Multipart uploads** accept a `FormData` parameter directly +- **Doc comments** are generated from OpenAPI `summary` fields + +## Usage in Flutter + +Add the generated package to your `pubspec.yaml`: + +```yaml title="pubspec.yaml" +dependencies: + dio: ^5.4.0 +``` + +Then use it: + +```dart +import 'package:dio/dio.dart'; +import 'package:my_flutter_package/my_flutter_package.dart'; + +final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com')); +final petsApi = PetsApi(dio); + +final response = await petsApi.listPets(limit: 10); +``` diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index dcfc851fe2..eb74147325 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -19,6 +19,7 @@ "angular", "solid-start", "hono", + "dart", "---Validation & Mocking---", "zod", "client-with-zod", diff --git a/docs/content/docs/reference/configuration/output.mdx b/docs/content/docs/reference/configuration/output.mdx index 287279d7f5..ac6826bbba 100644 --- a/docs/content/docs/reference/configuration/output.mdx +++ b/docs/content/docs/reference/configuration/output.mdx @@ -25,7 +25,7 @@ export default defineConfig({ **Type:** `String | Function` **Default:** `'axios-functions'` -**Options:** `angular`, `axios`, `axios-functions`, `react-query`, `svelte-query`, `vue-query`, `swr`, `zod`, `hono`, `fetch`, `mcp` +**Options:** `angular`, `axios`, `axios-functions`, `react-query`, `svelte-query`, `vue-query`, `swr`, `zod`, `hono`, `fetch`, `mcp`, `dart` ```ts title="orval.config.ts" export default defineConfig({