Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- summary: |
Support server URL variable templating. Environments with templated URLs now expose an
`ApiEnvironment.url(...)` method that substitutes the variables (falling back to their defaults),
and the root client accepts each variable as an initializer parameter. APIs with multiple base
URLs are unaffected, as Swift does not generate environments for them yet.
type: feat
7 changes: 6 additions & 1 deletion generators/swift/sdk/src/SdkGeneratorCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,13 +628,18 @@ export class SdkGeneratorCLI extends AbstractSwiftGeneratorCli<SdkCustomConfigSc
const environmentGenerator = new SingleUrlEnvironmentGenerator({
enumName: environmentSymbol.name,
environments: context.ir.environments.environments,
serverUrlVariables: context.serverUrlVariables,
sdkGeneratorContext: context
});
const environmentEnum = environmentGenerator.generate();
const urlVariablesExtension = environmentGenerator.generateUrlVariablesExtension();
context.project.addSourceFile({
nameCandidateWithoutExtension: environmentEnum.name,
directory: RelativeFilePath.of(""),
contents: [environmentEnum]
contents:
urlVariablesExtension != null
? [environmentEnum, swift.LineBreak.single(), swift.LineBreak.single(), urlVariablesExtension]
: [environmentEnum]
});
} else {
// TODO(kafkas): Handle multiple environments
Expand Down
40 changes: 40 additions & 0 deletions generators/swift/sdk/src/SdkGeneratorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,33 @@ import { AbstractSwiftGeneratorContext } from "@fern-api/swift-base";
import { DynamicSnippetsGenerator } from "@fern-api/swift-dynamic-snippets";
import { FernGeneratorExec } from "@fern-fern/generator-exec-sdk";
import { FernIr } from "@fern-fern/ir-sdk";
import { getServerUrlVariables, ServerUrlVariable } from "./generators/environment/serverUrlVariables.js";
import { ReadmeConfigBuilder } from "./readme/index.js";
import { SdkCustomConfigSchema } from "./SdkCustomConfig.js";
import { SwiftGeneratorAgent } from "./SwiftGeneratorAgent.js";
import { convertDynamicEndpointSnippetRequest } from "./utils/convertEndpointSnippetRequest.js";
import { convertIr } from "./utils/convertIr.js";
import { selectExamplesForSnippets } from "./utils/selectExamplesForSnippets.js";

/**
* Parameter names already present on the generated root client's initializers. A server URL
* variable whose name collides with one of these is exposed under a `serverUrl`-prefixed name.
*/
const ROOT_CLIENT_PARAMETER_NAMES = new Set<string>([
"baseURL",
"basicAuth",
"bearerAuth",
"headerAuth",
"headers",
"maxRetries",
"password",
"resolvedBaseURL",
"timeout",
"token",
"urlSession",
"username"
]);

type SPMDetails = {
gitUrl: string | null;
minVersion: string | null;
Expand All @@ -18,6 +38,7 @@ type SPMDetails = {
export class SdkGeneratorContext extends AbstractSwiftGeneratorContext<SdkCustomConfigSchema> {
public readonly generatorAgent: SwiftGeneratorAgent;
private _dynamicSnippetsGenerator: DynamicSnippetsGenerator | undefined;
private _serverUrlVariables: ServerUrlVariable[] | undefined;
private readonly endpointSnippetsById = new Map<FernIr.EndpointId, string | undefined>();

public constructor(
Expand Down Expand Up @@ -60,6 +81,25 @@ export class SdkGeneratorContext extends AbstractSwiftGeneratorContext<SdkCustom
return this.ir.selfHosted ?? false;
}

/**
* The server URL variables (e.g. `{region}`) that the API's environment URLs are templated on.
* Empty when the API does not use URL templating.
*/
public get serverUrlVariables(): ServerUrlVariable[] {
if (this._serverUrlVariables == null) {
const environments = this.ir.environments?.environments;
this._serverUrlVariables =
environments?.type === "singleBaseUrl"
? getServerUrlVariables({
environments,
caseConverter: this.caseConverter,
reservedParameterNames: ROOT_CLIENT_PARAMETER_NAMES
})
: [];
}
return this._serverUrlVariables;
}

public get dynamicSnippetsGenerator(): DynamicSnippetsGenerator {
if (this._dynamicSnippetsGenerator == null) {
const dynamicIr = this.ir.dynamic;
Expand Down
129 changes: 102 additions & 27 deletions generators/swift/sdk/src/generators/client/RootClientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export declare namespace RootClientGenerator {

type BearerTokenParamType = "string" | "async-provider";

/** The local the templated base URL is resolved into before it is passed along. */
const RESOLVED_BASE_URL_VARIABLE_NAME = "resolvedBaseURL";

export class RootClientGenerator {
private readonly symbol: swift.Symbol;
private readonly package_: FernIr.Package;
Expand All @@ -43,13 +46,58 @@ export class RootClientGenerator {
}

private get baseUrlParam() {
return swift.functionParameter({
argumentLabel: "baseURL",
unsafeName: "baseURL",
type: this.referencer.referenceSwiftType("String"),
defaultValue: this.getDefaultBaseUrl(),
docsContent:
"The base URL to use for requests from the client. If not provided, the default base URL will be used."
// When the base URL is templated, it is resolved from the server URL variables inside the
// initializer, so the parameter is optional rather than defaulted.
return this.usesServerUrlVariables
? swift.functionParameter({
argumentLabel: "baseURL",
unsafeName: "baseURL",
type: swift.TypeReference.optional(this.referencer.referenceSwiftType("String")),
defaultValue: swift.Expression.nil(),
docsContent:
"The base URL to use for requests from the client. If not provided, the default base URL is resolved from the server URL variables."
})
: swift.functionParameter({
argumentLabel: "baseURL",
unsafeName: "baseURL",
type: this.referencer.referenceSwiftType("String"),
defaultValue: this.getDefaultBaseUrl(),
docsContent:
"The base URL to use for requests from the client. If not provided, the default base URL will be used."
});
}

private get usesServerUrlVariables(): boolean {
return this.sdkGeneratorContext.serverUrlVariables.length > 0 && this.getDefaultEnvironmentCase() != null;
}

private get serverUrlVariableParams(): swift.FunctionParameter[] {
return this.sdkGeneratorContext.serverUrlVariables.map(({ variable, name }) =>
swift.functionParameter({
argumentLabel: name,
unsafeName: name,
type: swift.TypeReference.optional(this.referencer.referenceSwiftType("String")),
defaultValue: swift.Expression.nil(),
docsContent: `The \`${variable.id}\` server URL variable, substituted into the base URL. Defaults to "${variable.default ?? ""}".`
})
);
}

/**
* Declares the base URL to pass to the designated initializer, falling back to the default
* environment's URL resolved from the server URL variables.
*/
private get resolvedBaseUrlStatement(): swift.Statement {
const defaultEnvironmentUrl = swift.Expression.methodCall({
target: this.getDefaultEnvironmentCaseExpressionOrThrow(),
methodName: "url",
arguments_: this.sdkGeneratorContext.serverUrlVariables.map(({ name }) =>
swift.functionArgument({ label: name, value: swift.Expression.reference(name) })
)
});
return swift.Statement.constantDeclaration({
unsafeName: RESOLVED_BASE_URL_VARIABLE_NAME,
value: swift.Expression.rawValue(`baseURL ?? ${defaultEnvironmentUrl.toString()}`)
});
}

Expand Down Expand Up @@ -168,7 +216,9 @@ export class RootClientGenerator {
const designatedInitializerArgs: swift.FunctionArgument[] = [
swift.functionArgument({
label: "baseURL",
value: swift.Expression.reference("baseURL")
value: swift.Expression.reference(
this.usesServerUrlVariables ? RESOLVED_BASE_URL_VARIABLE_NAME : "baseURL"
)
}),
swift.functionArgument({
label: "headerAuth",
Expand Down Expand Up @@ -317,6 +367,10 @@ export class RootClientGenerator {

const bodyStatements: swift.Statement[] = [];

if (this.usesServerUrlVariables) {
bodyStatements.push(this.resolvedBaseUrlStatement);
}

if (globalHeaders.length > 0) {
bodyStatements.push(
swift.Statement.variableDeclaration({
Expand Down Expand Up @@ -390,6 +444,9 @@ export class RootClientGenerator {
bearerTokenParamType: BearerTokenParamType;
}): swift.FunctionParameter[] {
const params: swift.FunctionParameter[] = [this.baseUrlParam];
if (this.usesServerUrlVariables) {
params.push(...this.serverUrlVariableParams);
}
const authSchemes = this.getAuthSchemeParameters();
if (authSchemes.header) {
params.push(authSchemes.header.param);
Expand Down Expand Up @@ -534,36 +591,54 @@ export class RootClientGenerator {
});
}

private getDefaultBaseUrl() {
/**
* The environment the client defaults to, i.e. the one marked as default or, failing that, the
* first one. Undefined unless the API has single-base-URL environments.
*/
private getDefaultEnvironmentCase(): FernIr.SingleBaseUrlEnvironment | undefined {
if (this.sdkGeneratorContext.ir.environments == null) {
return undefined;
}

if (this.sdkGeneratorContext.ir.environments.environments.type === "singleBaseUrl") {
const environments = this.sdkGeneratorContext.ir.environments.environments;
if (environments.type === "singleBaseUrl") {
const defaultEnvId = this.sdkGeneratorContext.ir.environments.defaultEnvironment;

// If no default environment is specified, use the first environment
const defaultEnvironment = this.sdkGeneratorContext.ir.environments.environments.environments.find(
(e, idx) => (defaultEnvId == null ? idx === 0 : e.id === defaultEnvId)
return environments.environments.find((e, idx) =>
defaultEnvId == null ? idx === 0 : e.id === defaultEnvId
);
if (defaultEnvironment != null) {
const environmentSymbol = this.sdkGeneratorContext.project.nameRegistry.getEnvironmentSymbolOrThrow();
const environmentRef = this.sdkGeneratorContext.project.nameRegistry.reference({
fromSymbol: this.symbol,
toSymbol: environmentSymbol
});
return swift.Expression.memberAccess({
target: swift.Expression.reference(environmentRef),
memberName: `${this.sdkGeneratorContext.caseConverter.camelUnsafe(defaultEnvironment.name)}.rawValue`
});
}
return undefined;
} else if (this.sdkGeneratorContext.ir.environments.environments.type === "multipleBaseUrls") {
} else if (environments.type === "multipleBaseUrls") {
// TODO(kafkas): Handle multiple environments
return undefined;
} else {
assertNever(this.sdkGeneratorContext.ir.environments.environments);
assertNever(environments);
}
}

private getDefaultEnvironmentCaseExpressionOrThrow(): swift.Expression {
const defaultEnvironment = this.getDefaultEnvironmentCase();
if (defaultEnvironment == null) {
throw new Error("Cannot reference the default environment because the API declares none");
}
const environmentSymbol = this.sdkGeneratorContext.project.nameRegistry.getEnvironmentSymbolOrThrow();
const environmentRef = this.sdkGeneratorContext.project.nameRegistry.reference({
fromSymbol: this.symbol,
toSymbol: environmentSymbol
});
return swift.Expression.memberAccess({
target: swift.Expression.reference(environmentRef),
memberName: this.sdkGeneratorContext.caseConverter.camelUnsafe(defaultEnvironment.name)
});
}

private getDefaultBaseUrl() {
if (this.getDefaultEnvironmentCase() == null) {
return undefined;
}
return swift.Expression.memberAccess({
target: this.getDefaultEnvironmentCaseExpressionOrThrow(),
memberName: "rawValue"
});
}

private getAuthSchemeParameters() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,34 @@ import { swift } from "@fern-api/swift-codegen";
import { FernIr } from "@fern-fern/ir-sdk";

import { SdkGeneratorContext } from "../../SdkGeneratorContext.js";
import { ServerUrlVariable, urlTemplateToStringLiteral } from "./serverUrlVariables.js";

const URL_METHOD_NAME = "url";

export declare namespace SingleUrlEnvironmentGenerator {
interface Args {
enumName: string;
environments: FernIr.SingleBaseUrlEnvironments;
serverUrlVariables: ServerUrlVariable[];
sdkGeneratorContext: SdkGeneratorContext;
}
}

export class SingleUrlEnvironmentGenerator {
private readonly enumName: string;
private readonly environments: FernIr.SingleBaseUrlEnvironments;
private readonly serverUrlVariables: ServerUrlVariable[];
private readonly sdkGeneratorContext: SdkGeneratorContext;

public constructor({ enumName, environments, sdkGeneratorContext }: SingleUrlEnvironmentGenerator.Args) {
public constructor({
enumName,
environments,
serverUrlVariables,
sdkGeneratorContext
}: SingleUrlEnvironmentGenerator.Args) {
this.enumName = enumName;
this.environments = environments;
this.serverUrlVariables = serverUrlVariables;
this.sdkGeneratorContext = sdkGeneratorContext;
}

Expand All @@ -34,4 +45,56 @@ export class SingleUrlEnvironmentGenerator {
}))
});
}

/**
* Generates a `url(...)` method that resolves an environment's URL template with the given
* server URL variables. Returns undefined when the API does not use URL templating.
*/
public generateUrlVariablesExtension(): swift.Extension | undefined {
if (this.serverUrlVariables.length === 0) {
return undefined;
}
return swift.extension({
name: this.enumName,
methods: [
swift.method({
unsafeName: URL_METHOD_NAME,
accessLevel: swift.AccessLevel.Public,
parameters: this.serverUrlVariables.map(({ name }) =>
swift.functionParameter({
argumentLabel: name,
unsafeName: name,
type: swift.TypeReference.optional(swift.TypeReference.unqualifiedToSwiftType("String")),
defaultValue: swift.Expression.nil()
})
),
returnType: swift.TypeReference.unqualifiedToSwiftType("String"),
body: swift.CodeBlock.withStatements([
swift.Statement.switch({
target: swift.Expression.self(),
cases: this.environments.environments.map((environment) => ({
pattern: swift.Expression.enumCaseShorthand(
this.sdkGeneratorContext.caseConverter.camelUnsafe(environment.name)
),
body: [
swift.Statement.return(
swift.Expression.rawValue(
urlTemplateToStringLiteral(
environment.urlTemplate ?? environment.url,
this.serverUrlVariables
)
)
)
]
}))
})
]),
docs: swift.docComment({
summary:
"Returns this environment's URL with the given server URL variables substituted in. Variables that are not provided fall back to their defaults."
})
})
]
});
}
}
Loading