Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/compiler"
---

Apply JSON schema `default` values to emitter options so they appear in `context.options` during `$onEmit`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/openapi3"
---

Fix OpenAPI emitter failing with "Duplicate type name" error when using a named union with a `bytes` variant in a multipart body (e.g. `HttpPart<MyUnion>` where `MyUnion` includes `bytes`).
1 change: 1 addition & 0 deletions packages/compiler/src/core/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function createTypeSpecLibrary<
if (!emitterOptionValidator && lib.emitter?.options) {
emitterOptionValidator = createJSONSchemaValidator<E>(lib.emitter.options, {
coerceTypes: true,
useDefaults: true,
});
}
return emitterOptionValidator;
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/src/core/schema-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
export interface JSONSchemaValidatorOptions {
coerceTypes?: boolean;
strict?: boolean;
useDefaults?: boolean;
}

function absolutePathStatus(path: string): "valid" | "not-absolute" | "windows-style" {
Expand All @@ -35,6 +36,7 @@ export function createJSONSchemaValidator<T>(
const ajv = new Ajv({
strict: options.strict,
coerceTypes: options.coerceTypes,
useDefaults: options.useDefaults,
allowUnionTypes: true,
allErrors: true,
} satisfies Options);
Expand Down
76 changes: 76 additions & 0 deletions packages/compiler/test/core/emitter-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ const fakeEmitter = createTypeSpecLibrary({
},
});

const fakeEmitterWithDefaults = createTypeSpecLibrary({
name: "fake-emitter-defaults",
diagnostics: {},
emitter: {
options: {
type: "object",
properties: {
"target-name": { type: "string", nullable: true, default: "defaultTarget" },
"max-files": { type: "number", nullable: true, default: 10 },
verbose: { type: "boolean", nullable: true, default: false },
},
additionalProperties: false,
},
},
});

describe("compiler: emitter options", () => {
async function runWithEmitterOptions(
options: Record<string, unknown>,
Expand Down Expand Up @@ -127,4 +143,64 @@ describe("compiler: emitter options", () => {
});
});
});

describe("schema defaults", () => {
async function runWithDefaultsEmitter(
options: Record<string, unknown>,
): Promise<[EmitContext | undefined, readonly Diagnostic[]]> {
let emitContext: EmitContext | undefined;
const diagnostics = await Tester.files({
"node_modules/fake-emitter-defaults/package.json": JSON.stringify({
main: "index.js",
}),
"node_modules/fake-emitter-defaults/index.js": mockFile.js({
$lib: fakeEmitterWithDefaults,
$onEmit: (ctx: EmitContext) => {
emitContext = ctx;
},
}),
}).diagnose("", {
compilerOptions: {
emit: ["fake-emitter-defaults"],
options: {
"fake-emitter-defaults": options,
},
},
});
return [emitContext, diagnostics];
}

it("applies default values from schema when options are not provided", async () => {
const [context, diagnostics] = await runWithDefaultsEmitter({});
expectDiagnosticEmpty(diagnostics);
ok(context, "Emit context should have been set.");
strictEqual(context.options["target-name"], "defaultTarget");
strictEqual(context.options["max-files"], 10);
strictEqual(context.options["verbose"], false);
});

it("user-provided values override defaults", async () => {
const [context, diagnostics] = await runWithDefaultsEmitter({
"target-name": "custom",
"max-files": 20,
verbose: true,
});
expectDiagnosticEmpty(diagnostics);
ok(context, "Emit context should have been set.");
strictEqual(context.options["target-name"], "custom");
strictEqual(context.options["max-files"], 20);
strictEqual(context.options["verbose"], true);
});

it("applies defaults only for missing options", async () => {
const [context, diagnostics] = await runWithDefaultsEmitter({
"target-name": "custom",
});
expectDiagnosticEmpty(diagnostics);
ok(context, "Emit context should have been set.");
strictEqual(context.options["target-name"], "custom");
strictEqual(context.options["max-files"], 10);
strictEqual(context.options["verbose"], false);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ public ClientProvider(InputClient inputClient)
_publicCtorDescription = $"Initializes a new instance of {Name}.";
ClientOptions = _inputClient.Parent is null ? ClientOptionsProvider.CreateClientOptionsProvider(_inputClient, this) : null;
ClientOptionsParameter = ClientOptions != null ? ScmKnownParameters.ClientOptions(ClientOptions.Type) : null;
ClientSettings = ClientOptions != null ? new ClientSettingsProvider(_inputClient, this) : null;
bool isIndividuallyInitialized = (_inputClient.InitializedBy & InputClientInitializedBy.Individually) != 0;
ClientSettings = isIndividuallyInitialized
? new ClientSettingsProvider(_inputClient, this)
: null;
IsMultiServiceClient = _inputClient.IsMultiServiceClient;

var apiKey = _inputAuth?.ApiKey;
Expand All @@ -133,8 +136,7 @@ public ClientProvider(InputClient inputClient)
this,
initializationValue: Literal(apiKey.Prefix)) :
null;
// skip auth fields for sub-clients
_apiKeyAuthFields = ClientOptions is null ? null : new(apiKeyAuthField, authorizationHeaderField, authorizationApiKeyPrefixField);
_apiKeyAuthFields = isIndividuallyInitialized ? new(apiKeyAuthField, authorizationHeaderField, authorizationApiKeyPrefixField) : null;
}

var tokenAuth = _inputAuth?.OAuth2;
Expand All @@ -158,8 +160,7 @@ public ClientProvider(InputClient inputClient)

var tokenCredentialScopesField = BuildTokenCredentialScopesField(tokenAuth, tokenCredentialType);

// skip auth fields for sub-clients
_oauth2Fields = ClientOptions is null ? null : new(tokenCredentialField, tokenCredentialScopesField);
_oauth2Fields = isIndividuallyInitialized ? new(tokenCredentialField, tokenCredentialScopesField) : null;
}
EndpointField = new(
FieldModifiers.Private | FieldModifiers.ReadOnly,
Expand Down Expand Up @@ -300,14 +301,8 @@ private IReadOnlyList<ParameterProvider> GetSubClientInternalConstructorParamete
PipelineProperty.AsParameter
};

if (_apiKeyAuthFields != null)
{
subClientParameters.Add(_apiKeyAuthFields.AuthField.AsParameter);
}
if (_oauth2Fields != null)
{
subClientParameters.Add(_oauth2Fields.AuthField.AsParameter);
}
// Auth credentials are NOT included here — the parent passes its authenticated
// pipeline, so the sub-client doesn't need separate credential parameters.
subClientParameters.Add(_endpointParameter);
subClientParameters.AddRange(ClientParameters);

Expand Down Expand Up @@ -385,6 +380,12 @@ private IReadOnlyList<ParameterProvider> GetClientParameters()
public ClientOptionsProvider? ClientOptions { get; }
public ClientSettingsProvider? ClientSettings { get; }

/// <summary>
/// Gets the effective <see cref="ClientOptionsProvider"/> — the client's own options for root clients,
/// or the root client's options for individually-initialized sub-clients.
/// </summary>
internal ClientOptionsProvider? EffectiveClientOptions => ClientOptions ?? GetRootClient()?.ClientOptions;

public PropertyProvider PipelineProperty { get; }
public FieldProvider EndpointField { get; }

Expand Down Expand Up @@ -647,7 +648,9 @@ void AppendPublicConstructors(
foreach (var p in requiredParameters)
{
if (authParamName == null || p.Name != authParamName)
{
initializerArgs.Add(p);
}
}
initializerArgs.Add(ClientOptionsParameter!);

Expand Down Expand Up @@ -686,6 +689,14 @@ private IEnumerable<ConstructorProvider> BuildSettingsConstructors()
yield break;
}

// Only publicly constructible clients should get the Settings constructor.
// Internal clients (e.g., those made internal via custom code) cannot be
// constructed by consumers, so a public Settings constructor is not useful.
if (!DeclarationModifiers.HasFlag(TypeSignatureModifiers.Public))
{
yield break;
}

var settingsParam = new ParameterProvider(SettingsParamName, $"The settings for {Name}.", ClientSettings.Type);
var experimentalAttr = new AttributeStatement(typeof(ExperimentalAttribute), [Literal(ClientSettingsProvider.ClientSettingsDiagnosticId)]);

Expand Down Expand Up @@ -733,64 +744,108 @@ private IEnumerable<ConstructorProvider> BuildSettingsConstructors()
private void AppendSubClientPublicConstructors(List<ConstructorProvider> constructors)
{
// For sub-clients that can be initialized individually, we need to create public constructors
// similar to the root client constructors but adapted for sub-client needs
// with the same auth pattern as the root client.
var primaryConstructors = new List<ConstructorProvider>();
var secondaryConstructors = new List<ConstructorProvider>();

// if there is key auth
var rootClient = GetRootClient();
var clientOptionsParameter = rootClient?.ClientOptionsParameter;
var clientOptionsProvider = rootClient?.ClientOptions;

if (clientOptionsParameter == null || clientOptionsProvider == null)
{
return;
}

// Add the internal AuthenticationPolicy constructor first — public constructors chain to it.
var authPolicyParam = new ParameterProvider(
"authenticationPolicy",
$"The authentication policy to use for pipeline creation.",
new CSharpType(typeof(AuthenticationPolicy), isNullable: true));

var requiredNonAuthParams = GetRequiredParameters(null);
ParameterProvider[] internalConstructorParameters = [authPolicyParam, _endpointParameter, .. requiredNonAuthParams, clientOptionsParameter];

var internalConstructor = new ConstructorProvider(
new ConstructorSignature(Type, _publicCtorDescription, MethodSignatureModifiers.Internal, internalConstructorParameters),
BuildPrimaryConstructorBody(internalConstructorParameters, null, authPolicyParam, clientOptionsProvider, clientOptionsParameter, addExplicitValidation: true),
this);
primaryConstructors.Add(internalConstructor);

// Add public constructors with auth — same pattern as root client
if (_apiKeyAuthFields != null)
{
AppendSubClientPublicConstructorsForAuth(_apiKeyAuthFields, primaryConstructors, secondaryConstructors);
}
// if there is oauth2 auth
if (_oauth2Fields != null)
{
AppendSubClientPublicConstructorsForAuth(_oauth2Fields, primaryConstructors, secondaryConstructors);
}

// if there is no auth
bool onlyContainsUnsupportedAuth = _inputAuth != null && _apiKeyAuthFields == null && _oauth2Fields == null;
if (_apiKeyAuthFields == null && _oauth2Fields == null)
{
AppendSubClientPublicConstructorsForAuth(null, primaryConstructors, secondaryConstructors);
AppendSubClientPublicConstructorsForAuth(null, primaryConstructors, secondaryConstructors, onlyContainsUnsupportedAuth);
}

constructors.AddRange(secondaryConstructors);
constructors.AddRange(primaryConstructors);

// Add Settings constructor for individually-initialized sub-clients
foreach (var settingsConstructor in BuildSettingsConstructors())
{
constructors.Add(settingsConstructor);
}

void AppendSubClientPublicConstructorsForAuth(
AuthFields? authFields,
List<ConstructorProvider> primaryConstructors,
List<ConstructorProvider> secondaryConstructors)
{
// For a sub-client with individual initialization, we need:
// - endpoint parameter
// - auth parameter (if auth exists)
// - client options parameter (we need to get this from the root client)
var rootClient = GetRootClient();
var clientOptionsParameter = rootClient?.ClientOptionsParameter;
var clientOptionsProvider = rootClient?.ClientOptions;
if (clientOptionsParameter == null || clientOptionsProvider == null)
List<ConstructorProvider> secondaryConstructors,
bool onlyContainsUnsupportedAuth = false)
{
// Public constructor with credential parameter — delegates to the internal constructor via this(...).
var requiredParameters = GetRequiredParameters(authFields?.AuthField);
ParameterProvider[] primaryConstructorParameters = [_endpointParameter, .. requiredParameters, clientOptionsParameter];
var constructorModifier = onlyContainsUnsupportedAuth ? MethodSignatureModifiers.Internal : MethodSignatureModifiers.Public;

// Build the auth policy expression for the this() initializer
ValueExpression authPolicyArg = BuildAuthPolicyArgument(authFields, requiredParameters);
var initializerArgs = new List<ValueExpression> { authPolicyArg, _endpointParameter };
string? authParamName = authFields != null
? (authFields.AuthField.Name != TokenProviderFieldName ? CredentialParamName : authFields.AuthField.AsParameter.Name)
: null;
foreach (var p in requiredParameters)
{
// Cannot create public constructor without client options
return;
if (authParamName == null || p.Name != authParamName)
{
initializerArgs.Add(p);
}
}
initializerArgs.Add(clientOptionsParameter!);

var requiredParameters = GetRequiredParameters(authFields?.AuthField);
ParameterProvider[] primaryConstructorParameters = [_endpointParameter, .. requiredParameters, clientOptionsParameter];
var primaryConstructor = new ConstructorProvider(
new ConstructorSignature(Type, _publicCtorDescription, MethodSignatureModifiers.Public, primaryConstructorParameters),
BuildPrimaryConstructorBody(primaryConstructorParameters, authFields, null, clientOptionsProvider, clientOptionsParameter),
new ConstructorSignature(Type, _publicCtorDescription, constructorModifier, primaryConstructorParameters,
initializer: new ConstructorInitializer(false, initializerArgs)),
MethodBodyStatement.Empty,
this);

primaryConstructors.Add(primaryConstructor);

// If the endpoint parameter contains an initialization value, it is not required.
ParameterProvider[] secondaryConstructorParameters = _endpointParameter.InitializationValue is null
? [_endpointParameter, .. requiredParameters]
: [.. requiredParameters];
var secondaryConstructor = BuildSecondaryConstructor(secondaryConstructorParameters, primaryConstructorParameters, MethodSignatureModifiers.Public);
var secondaryConstructor = BuildSecondaryConstructor(secondaryConstructorParameters, primaryConstructorParameters, constructorModifier);

secondaryConstructors.Add(secondaryConstructor);

// When endpoint has a default value and there are required parameters,
// add an additional constructor that accepts required parameters + options.
if (_endpointParameter.InitializationValue is not null && requiredParameters.Count > 0)
{
ParameterProvider[] simplifiedConstructorWithOptionsParameters = [.. requiredParameters, clientOptionsParameter];
var simplifiedConstructorWithOptions = BuildSecondaryConstructor(simplifiedConstructorWithOptionsParameters, primaryConstructorParameters, constructorModifier);
secondaryConstructors.Add(simplifiedConstructorWithOptions);
}
}
}

Expand Down Expand Up @@ -917,11 +972,18 @@ private MethodBodyStatement[] BuildPrimaryConstructorBody(IReadOnlyList<Paramete
}

ValueExpression perRetryPolicies;
if (authPolicyParam != null && authFields != null)
if (authPolicyParam != null)
{
// Internal implementation constructor: use the authenticationPolicy parameter directly
perRetryPoliciesList.Add(authPolicyParam);
perRetryPolicies = New.Array(ScmCodeModelGenerator.Instance.TypeFactory.ClientPipelineApi.PipelinePolicyType, isInline: true, [.. perRetryPoliciesList]);
// Internal implementation constructor: generate a runtime null check for the auth policy.
// No-auth clients pass null, so we must guard against adding null to the policies array.
var pipelinePolicyType = ScmCodeModelGenerator.Instance.TypeFactory.ClientPipelineApi.PipelinePolicyType;
var perRetryWithoutAuth = New.Array(pipelinePolicyType, isInline: true, [.. perRetryPoliciesList]);
var perRetryWithAuth = New.Array(pipelinePolicyType, isInline: true, [.. perRetryPoliciesList, authPolicyParam]);

body.Add(new IfElseStatement(
authPolicyParam.NotEqual(Null),
PipelineProperty.Assign(This.ToApi<ClientPipelineApi>().Create(clientOptionsParameter, perRetryWithAuth)).Terminate(),
PipelineProperty.Assign(This.ToApi<ClientPipelineApi>().Create(clientOptionsParameter, perRetryWithoutAuth)).Terminate()));
}
else
{
Expand All @@ -940,9 +1002,9 @@ private MethodBodyStatement[] BuildPrimaryConstructorBody(IReadOnlyList<Paramete
perRetryPolicies = New.Array(ScmCodeModelGenerator.Instance.TypeFactory.ClientPipelineApi.PipelinePolicyType, isInline: true, [.. perRetryPoliciesList]);
break;
}
}

body.Add(PipelineProperty.Assign(This.ToApi<ClientPipelineApi>().Create(clientOptionsParameter, perRetryPolicies)).Terminate());
body.Add(PipelineProperty.Assign(This.ToApi<ClientPipelineApi>().Create(clientOptionsParameter, perRetryPolicies)).Terminate());
}

foreach (var f in Fields)
{
Expand Down
Loading
Loading