Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions generators/swift/base/src/context/AbstractSwiftGeneratorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,22 @@ import {
} from "@fern-api/base-generator";
import { assertDefined, assertNever, entries } from "@fern-api/core-utils";
import { RelativeFilePath } from "@fern-api/fs-utils";
import { BaseSwiftCustomConfigSchema, Referencer, swift, UndiscriminatedUnion } from "@fern-api/swift-codegen";
import {
BaseSwiftCustomConfigSchema,
NameRegistry,
Referencer,
swift,
UndiscriminatedUnion
} from "@fern-api/swift-codegen";
import { FernIr } from "@fern-fern/ir-sdk";
import { AsIsFileDefinition, SourceAsIsFiles, TestAsIsFiles } from "../AsIs.js";
import { SwiftProject } from "../project/index.js";
import { CycleDetector } from "./cycle-detector.js";
import { registerLiteralEnums, registerLiteralEnumsForObjectProperties } from "./register-literal-enums.js";
import {
registerLiteralEnums,
registerLiteralEnumsForObjectProperties,
registerLiteralEnumsForTypeReference
} from "./register-literal-enums.js";
import { registerUndiscriminatedUnionVariants } from "./register-undiscriminated-unions.js";

export abstract class AbstractSwiftGeneratorContext<
Expand Down Expand Up @@ -156,13 +166,53 @@ export abstract class AbstractSwiftGeneratorContext<
});
});
Object.entries(ir.subpackages).forEach(([subpackageId, subpackage]) => {
nameRegistry.registerSubClientSymbol({
const subClientSymbol = nameRegistry.registerSubClientSymbol({
subpackageId,
fernFilepathPartNamesPascalCase: subpackage.fernFilepath.allParts.map((name) =>
this.caseConverter.pascalUnsafe(name)
),
subpackageNamePascalCase: this.caseConverter.pascalUnsafe(subpackage.name)
});
if (subpackage.service != null) {
this.registerEndpointParameterLiteralEnums({
parentSymbol: subClientSymbol,
service: ir.services[subpackage.service],
registry: nameRegistry
});
}
});
if (ir.rootPackage.service != null) {
this.registerEndpointParameterLiteralEnums({
parentSymbol: nameRegistry.getRootClientSymbolOrThrow(),
service: ir.services[ir.rootPackage.service],
registry: nameRegistry
});
}
}

/**
* Inline literal query parameters become method parameters on the generated client, so their
* literal enums must be registered under the owning client symbol (matching the scope used to
* resolve the parameter's Swift type). Otherwise the parameter falls back to `JSONValue` while
* snippets and wire tests emit the literal enum case, producing a type mismatch.
*/
private registerEndpointParameterLiteralEnums({
parentSymbol,
service,
registry
}: {
parentSymbol: swift.Symbol;
service: FernIr.HttpService | undefined;
registry: NameRegistry;
}): void {
service?.endpoints.forEach((endpoint) => {
endpoint.queryParameters.forEach((queryParam) => {
registerLiteralEnumsForTypeReference({
parentSymbol,
registry,
typeReference: queryParam.valueType
});
});
});
}

Expand Down Expand Up @@ -282,7 +332,7 @@ export abstract class AbstractSwiftGeneratorContext<
return typeReference.container._visit({
literal: (literal) =>
literal._visit({
boolean: () => referencer.referenceAsIsType("JSONValue"),
boolean: () => referencer.referenceSwiftType("Bool"),
string: (literalValue) => {
const symbol = this.project.nameRegistry.getNestedLiteralEnumSymbol(
fromSymbol,
Expand Down
15 changes: 15 additions & 0 deletions generators/swift/codegen/src/ast/Class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { escapeReservedKeyword } from "../syntax/index.js";
import { AccessLevel } from "./AccessLevel.js";
import { AstNode, Writer } from "./core/index.js";
import { DocComment } from "./DocComment.js";
import { EnumWithRawValues } from "./EnumWithRawValues.js";
import { Initializer } from "./Initializer.js";
import { Method } from "./Method.js";
import { Property } from "./Property.js";
Expand All @@ -16,6 +17,7 @@ export declare namespace Class {
properties: Property[];
initializers?: Initializer[];
methods?: Method[];
nestedTypes?: EnumWithRawValues[];
docs?: DocComment;
}
}
Expand All @@ -28,6 +30,7 @@ export class Class extends AstNode {
public readonly properties: Property[];
public readonly initializers: Initializer[];
public readonly methods: Method[];
public readonly nestedTypes: EnumWithRawValues[];
public readonly docs?: DocComment;

public constructor({
Expand All @@ -38,6 +41,7 @@ export class Class extends AstNode {
properties,
initializers,
methods,
nestedTypes,
docs
}: Class.Args) {
super();
Expand All @@ -48,6 +52,7 @@ export class Class extends AstNode {
this.properties = properties;
this.initializers = initializers ?? [];
this.methods = methods ?? [];
this.nestedTypes = nestedTypes ?? [];
this.docs = docs;
}

Expand Down Expand Up @@ -98,6 +103,16 @@ export class Class extends AstNode {
writer.newLine();
});
}
if (this.nestedTypes.length > 0) {
writer.newLine();
this.nestedTypes.forEach((nestedType, nestedTypeIdx) => {
if (nestedTypeIdx > 0) {
writer.newLine();
}
nestedType.write(writer);
writer.newLine();
});
}
writer.dedent();
writer.write("}");
}
Expand Down
5 changes: 4 additions & 1 deletion generators/swift/codegen/src/name-registry/name-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,11 @@ export class NameRegistry {
variants: UndiscriminatedUnionVariant[];
}) {
const parentSymbolId = typeof parentSymbol === "string" ? parentSymbol : parentSymbol.id;
// Preserve the declaration order of the union members. Decoding attempts are emitted in
// this order, and Fern decodes undiscriminated unions by trying members in declaration
// order; sorting (e.g. alphabetically) would, for example, try `double` before `int` and
// decode an integral JSON number as a `Double`.
const distinctVariants = uniqWith(variants, (a, b) => a.caseName === b.caseName);
distinctVariants.sort((a, b) => a.caseName.localeCompare(b.caseName));
this.undiscriminatedUnionVariantsByParentSymbolId.set(parentSymbolId, distinctVariants);
return distinctVariants;
}
Expand Down
65 changes: 63 additions & 2 deletions generators/swift/dynamic-snippets/src/EndpointSnippetGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,19 @@ export class EndpointSnippetGenerator {
this.context.errors.unscope();
args.push(...pathParameterFields);

// The generated SDK declares endpoint headers as method parameters after the
// path parameters and before the query parameters, so emit them here to keep
// the rendered argument order aligned with the method signature.
this.context.errors.scope(Scope.Headers);
const headerParameterFields: swift.FunctionArgument[] = [];
if (request.headers != null) {
headerParameterFields.push(
...this.getEndpointMethodHeaderParameters({ namedParameters: request.headers, snippet })
);
}
this.context.errors.unscope();
args.push(...headerParameterFields);

this.context.errors.scope(Scope.QueryParameters);
const queryParameterFields: swift.FunctionArgument[] = [];
if (request.queryParameters != null) {
Expand Down Expand Up @@ -514,6 +527,53 @@ export class EndpointSnippetGenerator {
});
}

private getEndpointMethodHeaderParameters({
namedParameters,
snippet
}: {
namedParameters: FernIr.dynamic.NamedParameter[];
snippet: FernIr.dynamic.EndpointSnippetRequest;
}): swift.FunctionArgument[] {
const moduleSymbol = this.context.nameRegistry.getRegisteredSourceModuleSymbolOrThrow();
const referencer = this.context.createReferencer(moduleSymbol);
return this.context
.getExampleObjectProperties({
parameters: namedParameters,
snippetObject: snippet.headers ?? {}
})
.filter((parameter) => {
// The generated SDK only surfaces String-typed headers as endpoint
// method parameters; non-String and literal headers are set
// automatically, so the snippet must omit them to match the signature.
// Resolve through type aliases first so that a header typed as an
// alias of String (which the SDK treats as a String parameter) is
// still emitted here.
const resolvedTypeReference = this.resolveAliasTypeReference(parameter.typeReference);
const swiftType = this.context.getSwiftTypeReferenceFromScope(resolvedTypeReference, moduleSymbol);
return referencer.resolvesToTheSwiftType(swiftType.nonOptional(), "String");
})
.map((parameter) => {
return swift.functionArgument({
label: parameter.name.name.camelCase.unsafeName,
value: this.context.dynamicTypeLiteralMapper.convert({
fromSymbol: moduleSymbol,
typeReference: parameter.typeReference,
value: parameter.value
})
});
});
}

private resolveAliasTypeReference(typeReference: FernIr.dynamic.TypeReference): FernIr.dynamic.TypeReference {
if (typeReference.type === "named") {
const namedType = this.context.ir.types[typeReference.value];
if (namedType != null && namedType.type === "alias") {
return this.resolveAliasTypeReference(namedType.typeReference);
}
}
return typeReference;
}

private getFilePropertyInfo({
request,
snippet
Expand All @@ -525,7 +585,8 @@ export class EndpointSnippetGenerator {
if (request.body == null || !this.context.isFileUploadRequestBody(request.body)) {
return {
fileFields: [],
bodyPropertyFields: []
bodyPropertyFields: [],
orderedFields: []
};
}
return this.context.filePropertyMapper.getFilePropertyInfo({
Expand Down Expand Up @@ -575,7 +636,7 @@ export class EndpointSnippetGenerator {
}): swift.FunctionArgument[] {
switch (body.type) {
case "fileUpload":
return [...filePropertyInfo.fileFields, ...filePropertyInfo.bodyPropertyFields];
return filePropertyInfo.orderedFields;
case "properties":
return this.getInlinedRequestBodyPropertyObjectFields({ parameters: body.value, value });
case "referenced":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ private func main() async throws {
let client = AcmeClient(token: "<YOUR_API_KEY>")

_ = try await client.service.getMetadata(
xAPIVersion: "0.0.1",
shallow: false,
tag: [
"development",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { BaseSwiftCustomConfigSchema, NameRegistry, Referencer, swift } from "@f
import { pascalCase } from "../util/pascal-case.js";
import { DynamicTypeLiteralMapper } from "./DynamicTypeLiteralMapper.js";
import { FilePropertyMapper } from "./FilePropertyMapper.js";
import { registerLiteralEnums, registerLiteralEnumsForObjectProperties } from "./register-literal-enums.js";
import {
registerLiteralEnums,
registerLiteralEnumsForObjectProperties,
registerLiteralEnumsForTypeReference
} from "./register-literal-enums.js";
import { registerUndiscriminatedUnionVariants } from "./register-undiscriminated-unions.js";

export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGeneratorContext {
Expand Down Expand Up @@ -131,6 +135,17 @@ export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGene
requestNamePascalCase: endpoint.request.declaration.name.pascalCase.unsafeName
});
}
// Inline literal query parameters are resolved against the source module symbol
// (see EndpointSnippetGenerator.getEndpointMethodQueryParameters), so their literal
// enums must be registered under that same scope. This mirrors the SDK generator's
// AbstractSwiftGeneratorContext, which registers them under the owning client symbol.
endpoint.request.queryParameters?.forEach((queryParameter) => {
registerLiteralEnumsForTypeReference({
parentSymbol: registeredSourceModuleSymbol,
registry: nameRegistry,
typeReference: queryParameter.typeReference
});
});
}
});

Expand Down Expand Up @@ -164,12 +179,12 @@ export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGene
list: (ref) => swift.TypeReference.array(this.getSwiftTypeReferenceFromScope(ref.value, fromSymbol)),
literal: (ref) => {
return visitDiscriminatedUnion(ref.value, "type")._visit({
boolean: () => referencer.referenceAsIsType("JSONValue"),
boolean: () => referencer.referenceSwiftType("Bool"),
string: (literalType) => {
const symbol = this.nameRegistry.getNestedLiteralEnumSymbolOrThrow(
fromSymbol,
literalType.value
);
const symbol = this.nameRegistry.getNestedLiteralEnumSymbol(fromSymbol, literalType.value);
if (symbol == null) {
return referencer.referenceAsIsType("JSONValue");
}
return referencer.referenceType(symbol);
},
_other: () => referencer.referenceAsIsType("JSONValue")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,19 @@ export class DynamicTypeLiteralMapper {
if (record == null) {
return swift.Expression.nop();
}
// A single-property variant wraps its value under the variant's property
// wire key (the union's CodingKeys raw value), which defaults to "value" but
// can be customized via `key:` and is not exposed on the dynamic IR. The
// discriminant has already been stripped from the record, so exclude any
// inherited base properties to isolate the wrapped value's key.
const basePropertyKeys = new Set(
(unionVariant.properties ?? []).map((property) => property.name.wireValue)
);
const propertyKey = Object.keys(record).find((key) => !basePropertyKeys.has(key)) ?? "value";
const converted = this.convert({
fromSymbol,
typeReference: unionVariant.typeReference,
value: record[unionVariant.discriminantValue.wireValue]
value: record[propertyKey]
});
return swift.Expression.methodCall({
target: swift.Expression.reference(unionSymbol.name),
Expand Down Expand Up @@ -323,13 +332,30 @@ export class DynamicTypeLiteralMapper {
value: unknown;
}): swift.Expression {
const symbol = this.context.nameRegistry.getSchemaTypeSymbolOrThrow(typeId);
const exampleProperties = this.context.getExampleObjectProperties({
parameters: object_.properties,
snippetObject: value
});
const examplePropertiesByWireValue = new Map(
exampleProperties.map((typeInstance) => [typeInstance.name.wireValue, typeInstance])
);
// Literal properties are constants that the generated initializer always requires, so emit
// them in declaration order even when the example omits them.
const orderedProperties = object_.properties
.map((property) => {
const exampleProperty = examplePropertiesByWireValue.get(property.name.wireValue);
if (exampleProperty != null) {
return exampleProperty;
}
if (property.typeReference.type === "literal") {
return { name: property.name, typeReference: property.typeReference, value: undefined };
}
return null;
})
.filter((typeInstance) => typeInstance != null);
return swift.Expression.structInitialization({
unsafeName: symbol.name,
arguments_: this.context
.getExampleObjectProperties({
parameters: object_.properties,
snippetObject: value
})
arguments_: orderedProperties
.map((typeInstance) => {
const expression = this.convert({
fromSymbol,
Expand Down
Loading
Loading