Skip to content

Commit 3906ac9

Browse files
kev-flexclaude
andcommitted
feat: opt-in non-nullable required properties for TypeScript and Python
Adds a `MakeRequiredPropertiesNonNullable` generation flag (CLI: `--make-required-properties-non-nullable` / `--mrpnn`) that honors the OpenAPI `required` array and `nullable`/`type:[...,"null"]` markers when generating models. A property that is required and not explicitly nullable is generated as non-optional and non-nullable. The flag defaults to false to preserve the historical all-nullable behavior for existing clients; no existing generation output changes unless it is explicitly enabled. Shared groundwork: - CodeProperty.IsRequired, populated from the parent schema's required array - GenerationConfiguration.MakeRequiredPropertiesNonNullable (default false) - OpenApiSchemaExtensions.IsExplicitlyNullable() (OAS 3.0 + 3.1) - KiotaBuilder flips Type.IsNullable=false for required, non-explicitly-nullable, non-collection custom properties when the flag is on TypeScript: the interface writer now drops `?` for required properties and drops `| null` unless the schema is explicitly nullable. Python: no writer change is required. The dataclass field writer already gates Optional[...] on IsNullable and retains the `= None` default, so a required non-nullable property renders as `name: T = None` -- non-Optional while keeping the default that allows no-arg construction (create_from_discriminator_value) and valid dataclass field ordering. Tests: full flag on/off matrix at the builder level (scalar/int/enum/object ref/collection), TypeScript interface rendering, and Python dataclass field rendering. Refs #3911. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aef1960 commit 3906ac9

13 files changed

Lines changed: 376 additions & 11 deletions

File tree

src/Kiota.Builder/CodeDOM/CodeProperty.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ public bool IsPrimaryErrorMessage
122122
{
123123
get; set;
124124
}
125+
/// <summary>
126+
/// Indicates that this property appeared in the parent schema's <c>required</c> array.
127+
/// Set during Code DOM construction in KiotaBuilder; should not be modified by refiners.
128+
/// </summary>
129+
public bool IsRequired
130+
{
131+
get; set;
132+
}
125133

126134
public object Clone()
127135
{
@@ -143,6 +151,7 @@ public object Clone()
143151
OriginalPropertyFromBaseType = OriginalPropertyFromBaseType?.Clone() as CodeProperty,
144152
Deprecation = Deprecation,
145153
IsPrimaryErrorMessage = IsPrimaryErrorMessage,
154+
IsRequired = IsRequired,
146155
};
147156
return property;
148157
}

src/Kiota.Builder/Configuration/GenerationConfiguration.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ public bool UsesBackingStore
6565
{
6666
get; set;
6767
}
68+
/// <summary>
69+
/// When enabled, properties marked as required in the OpenAPI description and not explicitly nullable
70+
/// are generated as non-nullable (and non-optional, for languages that distinguish the two).
71+
/// Defaults to false to preserve the historical all-nullable behavior for existing clients.
72+
/// </summary>
73+
public bool MakeRequiredPropertiesNonNullable
74+
{
75+
get; set;
76+
}
6877
public bool ExcludeBackwardCompatible
6978
{
7079
get; set;
@@ -184,6 +193,7 @@ public object Clone()
184193
DisableSSLValidation = DisableSSLValidation,
185194
ExportPublicApi = ExportPublicApi,
186195
PluginAuthInformation = PluginAuthInformation,
196+
MakeRequiredPropertiesNonNullable = MakeRequiredPropertiesNonNullable,
187197
};
188198
}
189199
private static readonly StringIEnumerableDeepComparer comparer = new();

src/Kiota.Builder/Extensions/OpenApiSchemaExtensions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ public static bool HasAnyProperty(this IOpenApiSchema? schema)
8080
{
8181
return schema?.Properties is { Count: > 0 };
8282
}
83+
/// <summary>
84+
/// Indicates whether the schema explicitly permits null.
85+
/// Covers OAS 3.0 <c>nullable: true</c> and OAS 3.1 <c>type: [..., "null"]</c>,
86+
/// as well as the OAS 3.1 <c>anyOf: [{ type: null }]</c> pattern.
87+
/// </summary>
88+
internal static bool IsExplicitlyNullable(this IOpenApiSchema? schema)
89+
{
90+
if (schema is null) return false;
91+
// OAS 3.0 nullable: true or OAS 3.1 type includes null
92+
if ((schema.Type & JsonSchemaType.Null) is JsonSchemaType.Null) return true;
93+
// OAS 3.1 anyOf [ { type: null } ] pattern
94+
return schema.AnyOf?.Any(static x =>
95+
(x.Type & JsonSchemaType.Null) is JsonSchemaType.Null && !x.HasAnyProperty()) ?? false;
96+
}
8397
public static bool IsInclusiveUnion(this IOpenApiSchema? schema, uint exclusiveMinimumNumberOfEntries = 1)
8498
{
8599
return schema?.AnyOf?.Count(static x => IsSemanticallyMeaningful(x, true)) > exclusiveMinimumNumberOfEntries;

src/Kiota.Builder/KiotaBuilder.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -661,7 +661,7 @@ public async Task ApplyLanguageRefinementAsync(GenerationConfiguration config, C
661661

662662
public async Task CreateLanguageSourceFilesAsync(GenerationLanguage language, CodeNamespace generatedCode, CancellationToken cancellationToken)
663663
{
664-
var languageWriter = LanguageWriter.GetLanguageWriter(language, config.OutputPath, config.ClientNamespaceName, config.UsesBackingStore, config.ExcludeBackwardCompatible);
664+
var languageWriter = LanguageWriter.GetLanguageWriter(language, config.OutputPath, config.ClientNamespaceName, config.UsesBackingStore, config.ExcludeBackwardCompatible, config.MakeRequiredPropertiesNonNullable);
665665
var stopwatch = new Stopwatch();
666666
stopwatch.Start();
667667
var codeRenderer = CodeRenderer.GetCodeRender(config);
@@ -1174,7 +1174,7 @@ private CodeIndexer[] CreateIndexer(string childIdentifier, string childType, Co
11741174
}
11751175
private static readonly StructuralPropertiesReservedNameProvider structuralPropertiesReservedNameProvider = new();
11761176

1177-
private CodeProperty? CreateProperty(string childIdentifier, string childType, IOpenApiSchema? propertySchema = null, CodeTypeBase? existingType = null, CodePropertyKind kind = CodePropertyKind.Custom)
1177+
private CodeProperty? CreateProperty(string childIdentifier, string childType, IOpenApiSchema? propertySchema = null, CodeTypeBase? existingType = null, CodePropertyKind kind = CodePropertyKind.Custom, bool isRequired = false)
11781178
{
11791179
var propertyName = childIdentifier.CleanupSymbolName();
11801180
if (structuralPropertiesReservedNameProvider.ReservedNames.Contains(propertyName))
@@ -1196,6 +1196,7 @@ private CodeIndexer[] CreateIndexer(string childIdentifier, string childType, Co
11961196
ReadOnly = propertySchema?.ReadOnly ?? false,
11971197
Type = resultType,
11981198
Deprecation = propertySchema?.GetDeprecationInformation(),
1199+
IsRequired = isRequired,
11991200
IsPrimaryErrorMessage = kind == CodePropertyKind.Custom &&
12001201
propertySchema is { Extensions: not null } &&
12011202
propertySchema.Extensions.TryGetValue(OpenApiPrimaryErrorMessageExtension.Name, out var openApiExtension) &&
@@ -1221,6 +1222,23 @@ openApiExtension is OpenApiPrimaryErrorMessageExtension primaryErrorMessageExten
12211222
prop.DefaultValue = stringDefaultJsonValue2.ToString();
12221223
}
12231224

1225+
// When the property is required and its schema does not explicitly allow null, mark the type as
1226+
// non-nullable so writers can drop the nullability/optionality markers. Gated on the configuration
1227+
// flag so the historical all-nullable behavior is preserved by default.
1228+
// Collections are excluded: IsNullable on a collection type controls both the outer collection
1229+
// nullability AND the element nullability (e.g. list of Status? vs list of Status), and the
1230+
// serialization APIs always expect a nullable element collection. The cloning avoids mutating a
1231+
// shared/cached type reference (the same definition can back multiple properties).
1232+
var isCollection = existingType != null
1233+
? existingType.CollectionKind != CodeTypeBase.CodeTypeCollectionKind.None
1234+
: propertySchema.IsArray();
1235+
if (config.MakeRequiredPropertiesNonNullable && kind == CodePropertyKind.Custom && isRequired && !propertySchema.IsExplicitlyNullable() && !isCollection)
1236+
{
1237+
if (existingType != null)
1238+
prop.Type = (CodeTypeBase)existingType.Clone();
1239+
prop.Type.IsNullable = false;
1240+
}
1241+
12241242
if (existingType == null)
12251243
{
12261244
prop.Type.CollectionKind = propertySchema.IsArray() ? CodeTypeBase.CodeTypeCollectionKind.Complex : default;
@@ -2434,7 +2452,8 @@ private void CreatePropertiesForModelClass(OpenApiUrlTreeNode currentNode, IOpen
24342452
LogOmittedPropertyInvalidSchema(x.Key, model.Name, currentNode.Path);
24352453
return null;
24362454
}
2437-
return CreateProperty(x.Key, definition.Name, propertySchema: propertySchema, existingType: definition);
2455+
var isRequired = schema.Required?.Contains(x.Key) ?? false;
2456+
return CreateProperty(x.Key, definition.Name, propertySchema: propertySchema, existingType: definition, isRequired: isRequired);
24382457
})
24392458
.OfType<CodeProperty>()
24402459
.ToArray() ?? [];

src/Kiota.Builder/Writers/LanguageWriter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,13 @@ protected void AddOrReplaceCodeElementWriter<T>(ICodeElementWriter<T> writer) wh
178178
Writers[typeof(T)] = writer;
179179
}
180180
private readonly Dictionary<Type, object> Writers = []; // we have to type as object because dotnet doesn't have type capture i.e eq for `? extends CodeElement`
181-
public static LanguageWriter GetLanguageWriter(GenerationLanguage language, string outputPath, string clientNamespaceName, bool usesBackingStore = false, bool excludeBackwardCompatible = false)
181+
public static LanguageWriter GetLanguageWriter(GenerationLanguage language, string outputPath, string clientNamespaceName, bool usesBackingStore = false, bool excludeBackwardCompatible = false, bool makeRequiredPropertiesNonNullable = false)
182182
{
183183
return language switch
184184
{
185185
GenerationLanguage.CSharp => new CSharpWriter(outputPath, clientNamespaceName),
186186
GenerationLanguage.Java => new JavaWriter(outputPath, clientNamespaceName),
187-
GenerationLanguage.TypeScript => new TypeScriptWriter(outputPath, clientNamespaceName),
187+
GenerationLanguage.TypeScript => new TypeScriptWriter(outputPath, clientNamespaceName, makeRequiredPropertiesNonNullable),
188188
GenerationLanguage.Ruby => new RubyWriter(outputPath, clientNamespaceName),
189189
GenerationLanguage.PHP => new PhpWriter(outputPath, clientNamespaceName, usesBackingStore),
190190
GenerationLanguage.Python => new PythonWriter(outputPath, clientNamespaceName, usesBackingStore),

src/Kiota.Builder/Writers/TypeScript/CodePropertyWriter.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,32 @@ public override void WriteCodeElement(CodeProperty codeElement, LanguageWriter w
2828
switch (codeElement.Parent)
2929
{
3030
case CodeInterface:
31-
WriteCodePropertyForInterface(codeElement, writer, returnType, isFlagEnum);
31+
WriteCodePropertyForInterface(codeElement, writer, returnType, isFlagEnum, conventions.MakeRequiredPropertiesNonNullable);
3232
break;
3333
case CodeClass:
3434
throw new InvalidOperationException($"All properties are defined on interfaces in TypeScript.");
3535
}
3636
}
37-
private static void WriteCodePropertyForInterface(CodeProperty codeElement, LanguageWriter writer, string returnType, bool isFlagEnum)
37+
private static void WriteCodePropertyForInterface(CodeProperty codeElement, LanguageWriter writer, string returnType, bool isFlagEnum, bool makeRequiredPropertiesNonNullable)
3838
{
39+
var collectionSuffix = isFlagEnum ? "[]" : string.Empty;
3940
switch (codeElement.Kind)
4041
{
4142
case CodePropertyKind.RequestBuilder:
4243
writer.WriteLine($"get {codeElement.Name.ToFirstCharacterLowerCase()}(): {returnType};");
4344
break;
4445
case CodePropertyKind.QueryParameter:
45-
writer.WriteLine($"{codeElement.Name.ToFirstCharacterLowerCase()}?: {returnType}{(isFlagEnum ? "[]" : string.Empty)};");
46+
writer.WriteLine($"{codeElement.Name.ToFirstCharacterLowerCase()}?: {returnType}{collectionSuffix};");
4647
break;
4748
default:
48-
writer.WriteLine($"{codeElement.Name.ToFirstCharacterLowerCase()}?: {returnType}{(isFlagEnum ? "[]" : string.Empty)} | null;");
49+
// When enabled, a required property is non-optional (no `?`), and drops `| null` unless its
50+
// schema is explicitly nullable. Otherwise the historical optional + nullable form is kept.
51+
var suppressOptionalAndNull = makeRequiredPropertiesNonNullable && codeElement.IsRequired;
52+
var optionalMarker = suppressOptionalAndNull ? string.Empty : "?";
53+
var nullSuffix = suppressOptionalAndNull
54+
? (codeElement.Type.IsNullable ? " | null" : string.Empty)
55+
: " | null";
56+
writer.WriteLine($"{codeElement.Name.ToFirstCharacterLowerCase()}{optionalMarker}: {returnType}{collectionSuffix}{nullSuffix};");
4957
break;
5058
}
5159
}

src/Kiota.Builder/Writers/TypeScript/TypeScriptConventionService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ namespace Kiota.Builder.Writers.TypeScript;
1212

1313
public class TypeScriptConventionService : CommonLanguageConventionService
1414
{
15+
/// <summary>
16+
/// When true, required and not-explicitly-nullable properties are rendered as non-optional
17+
/// (no <c>?</c>) and non-nullable (no <c>| null</c>). Defaults to false to preserve the
18+
/// historical behavior where every model property is optional and nullable.
19+
/// </summary>
20+
public bool MakeRequiredPropertiesNonNullable { get; init; }
21+
1522
#pragma warning disable CA1707 // Remove the underscores
1623
public const string TYPE_INTEGER = "integer";
1724
public const string TYPE_INT64 = "int64";

src/Kiota.Builder/Writers/TypeScript/TypeScriptWriter.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ namespace Kiota.Builder.Writers.TypeScript;
44

55
public class TypeScriptWriter : LanguageWriter
66
{
7-
public TypeScriptWriter(string rootPath, string clientNamespaceName)
7+
public TypeScriptWriter(string rootPath, string clientNamespaceName, bool makeRequiredPropertiesNonNullable = false)
88
{
99
PathSegmenter = new TypeScriptPathSegmenter(rootPath, clientNamespaceName);
10-
var conventionService = new TypeScriptConventionService();
10+
var conventionService = new TypeScriptConventionService
11+
{
12+
MakeRequiredPropertiesNonNullable = makeRequiredPropertiesNonNullable
13+
};
1114
AddOrReplaceCodeElementWriter(new CodeClassDeclarationWriter(conventionService, clientNamespaceName));
1215
AddOrReplaceCodeElementWriter(new CodeBlockEndWriter(conventionService));
1316
AddOrReplaceCodeElementWriter(new CodeEnumWriter(conventionService));

src/kiota/Handlers/KiotaGenerateCommandHandler.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
8686
bool excludeBackwardCompatible = parseResult.GetValue(ExcludeBackwardCompatibleOption);
8787
bool clearCache = parseResult.GetValue(ClearCacheOption);
8888
bool disableSSLValidation = parseResult.GetValue(DisableSSLValidationOption);
89+
bool makeRequiredPropertiesNonNullable = parseResult.GetValue(MakeRequiredPropertiesNonNullableOption);
8990
bool includeAdditionalData = parseResult.GetValue(AdditionalDataOption);
9091
string? className = parseResult.GetValue(ClassOption);
9192
AccessModifier typeAccessModifier = parseResult.GetValue(TypeAccessModifierOption);
@@ -152,6 +153,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
152153
Configuration.Generation.CleanOutput = cleanOutput;
153154
Configuration.Generation.ClearCache = clearCache;
154155
Configuration.Generation.DisableSSLValidation = disableSSLValidation;
156+
Configuration.Generation.MakeRequiredPropertiesNonNullable = makeRequiredPropertiesNonNullable;
155157

156158
var (loggerFactory, logger) = GetLoggerAndFactory<KiotaBuilder>(parseResult, Configuration.Generation.OutputPath);
157159
using (loggerFactory)
@@ -233,6 +235,10 @@ public required Option<bool> DisableSSLValidationOption
233235
{
234236
get; init;
235237
}
238+
public required Option<bool> MakeRequiredPropertiesNonNullableOption
239+
{
240+
get; init;
241+
}
236242

237243
private static void CreateTelemetryTags(ActivitySource? activitySource, GenerationLanguage language, bool backingStore,
238244
bool excludeBackwardCompatible, bool clearCache, bool disableSslValidation, bool cleanOutput, string? output,

src/kiota/KiotaHost.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,8 @@ private static Command GetGenerateCommand(IServiceProvider serviceProvider)
589589

590590
var disableSSLValidationOption = GetDisableSSLValidationOption(defaultConfiguration.DisableSSLValidation);
591591

592+
var makeRequiredPropertiesNonNullableOption = GetMakeRequiredPropertiesNonNullableOption(defaultConfiguration.MakeRequiredPropertiesNonNullable);
593+
592594
var command = new Command("generate", "Generates a REST HTTP API client from an OpenAPI description file.") {
593595
descriptionOption,
594596
manifestOption,
@@ -610,6 +612,7 @@ private static Command GetGenerateCommand(IServiceProvider serviceProvider)
610612
dvrOption,
611613
clearCacheOption,
612614
disableSSLValidationOption,
615+
makeRequiredPropertiesNonNullableOption,
613616
};
614617
command.Action = new KiotaGenerateCommandHandler
615618
{
@@ -633,6 +636,7 @@ private static Command GetGenerateCommand(IServiceProvider serviceProvider)
633636
DisabledValidationRulesOption = dvrOption,
634637
ClearCacheOption = clearCacheOption,
635638
DisableSSLValidationOption = disableSSLValidationOption,
639+
MakeRequiredPropertiesNonNullableOption = makeRequiredPropertiesNonNullableOption,
636640
ServiceProvider = serviceProvider,
637641
};
638642
return command;
@@ -719,6 +723,17 @@ private static Option<bool> GetDisableSSLValidationOption(bool defaultValue)
719723
return disableSSLValidationOption;
720724
}
721725

726+
internal static Option<bool> GetMakeRequiredPropertiesNonNullableOption(bool defaultValue = false)
727+
{
728+
var option = new Option<bool>("--make-required-properties-non-nullable")
729+
{
730+
DefaultValueFactory = _ => defaultValue,
731+
Description = "When enabled, properties marked as required in the OpenAPI description and not explicitly nullable are generated as non-nullable (and non-optional, for languages that distinguish the two). Disabled by default to preserve the previous behavior where all properties are nullable.",
732+
};
733+
option.Aliases.Add("--mrpnn");
734+
return option;
735+
}
736+
722737
private static void AddStringRegexValidator(Option<string> option, Regex validator, string parameterName, bool allowEmpty = false)
723738
{
724739
option.Validators.Add(input =>

0 commit comments

Comments
 (0)