Skip to content
Open
10 changes: 9 additions & 1 deletion src/Kiota.Builder/CodeDOM/CodeProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ public bool IsPrimaryErrorMessage
{
get; set;
}
/// <summary>
/// Indicates that this property appeared in the parent schema's <c>required</c> array.
/// Set during Code DOM construction in KiotaBuilder; should not be modified by refiners.
/// </summary>
public bool IsRequired
{
get; set;
}

public object Clone()
{
Expand All @@ -143,8 +151,8 @@ public object Clone()
OriginalPropertyFromBaseType = OriginalPropertyFromBaseType?.Clone() as CodeProperty,
Deprecation = Deprecation,
IsPrimaryErrorMessage = IsPrimaryErrorMessage,
IsRequired = IsRequired,
};
return property;
}
}

5 changes: 5 additions & 0 deletions src/Kiota.Builder/Configuration/GenerationConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ public bool UsesBackingStore
{
get; set;
}
public bool MakeRequiredPropertiesNonNullable
{
get; set;
} = true;
public bool ExcludeBackwardCompatible
{
get; set;
Expand Down Expand Up @@ -184,6 +188,7 @@ public object Clone()
DisableSSLValidation = DisableSSLValidation,
ExportPublicApi = ExportPublicApi,
PluginAuthInformation = PluginAuthInformation,
MakeRequiredPropertiesNonNullable = MakeRequiredPropertiesNonNullable,
};
}
private static readonly StringIEnumerableDeepComparer comparer = new();
Expand Down
10 changes: 10 additions & 0 deletions src/Kiota.Builder/Extensions/OpenApiSchemaExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ public static bool HasAnyProperty(this IOpenApiSchema? schema)
{
return schema?.Properties is { Count: > 0 };
}

internal static bool IsExplicitlyNullable(this IOpenApiSchema? schema)
{
if (schema is null) return false;
// OAS 3.0 nullable: true or OAS 3.1 type includes null
if ((schema.Type & JsonSchemaType.Null) is JsonSchemaType.Null) return true;
// OAS 3.1 anyOf [ { type: null } ] pattern
return schema.AnyOf?.Any(static x =>
(x.Type & JsonSchemaType.Null) is JsonSchemaType.Null && !x.HasAnyProperty()) ?? false;
}
public static bool IsInclusiveUnion(this IOpenApiSchema? schema, uint exclusiveMinimumNumberOfEntries = 1)
{
return schema?.AnyOf?.Count(static x => IsSemanticallyMeaningful(x, true)) > exclusiveMinimumNumberOfEntries;
Expand Down
25 changes: 22 additions & 3 deletions src/Kiota.Builder/KiotaBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ public async Task ApplyLanguageRefinementAsync(GenerationConfiguration config, C

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

private CodeProperty? CreateProperty(string childIdentifier, string childType, IOpenApiSchema? propertySchema = null, CodeTypeBase? existingType = null, CodePropertyKind kind = CodePropertyKind.Custom)
private CodeProperty? CreateProperty(string childIdentifier, string childType, IOpenApiSchema? propertySchema = null, CodeTypeBase? existingType = null, CodePropertyKind kind = CodePropertyKind.Custom, bool isRequired = false)
{
var propertyName = childIdentifier.CleanupSymbolName();
if (structuralPropertiesReservedNameProvider.ReservedNames.Contains(propertyName))
Expand All @@ -1196,6 +1196,7 @@ private CodeIndexer[] CreateIndexer(string childIdentifier, string childType, Co
ReadOnly = propertySchema?.ReadOnly ?? false,
Type = resultType,
Deprecation = propertySchema?.GetDeprecationInformation(),
IsRequired = isRequired,
IsPrimaryErrorMessage = kind == CodePropertyKind.Custom &&
propertySchema is { Extensions: not null } &&
propertySchema.Extensions.TryGetValue(OpenApiPrimaryErrorMessageExtension.Name, out var openApiExtension) &&
Expand All @@ -1213,6 +1214,23 @@ openApiExtension is OpenApiPrimaryErrorMessageExtension primaryErrorMessageExten
!"null".Equals(stringDefaultValue, StringComparison.OrdinalIgnoreCase))
prop.DefaultValue = $"\"{stringDefaultValue}\"";

// If the property is required and the schema explicitly does not allow null,
// mark the type as non-nullable. We clone existingType to avoid mutating a shared reference.
// Collections are excluded: IsNullable on a collection type controls both the outer
// collection ? AND the enum element ? (e.g. List<Status?> vs List<Status>). Setting
// IsNullable = false on a required collection breaks the serialization API which always
// expects IEnumerable<T?>?. The outer collection ? is suppressed via IsRequired in
// CodePropertyWriter instead.
var isCollection = existingType != null
? existingType.CollectionKind != CodeTypeBase.CodeTypeCollectionKind.None
: propertySchema.IsArray();
if (kind == CodePropertyKind.Custom && isRequired && !propertySchema.IsExplicitlyNullable() && !isCollection)
{
if (existingType != null)
prop.Type = (CodeTypeBase)existingType.Clone();
prop.Type.IsNullable = false;
}

if (existingType == null)
{
prop.Type.CollectionKind = propertySchema.IsArray() ? CodeTypeBase.CodeTypeCollectionKind.Complex : default;
Expand Down Expand Up @@ -2418,7 +2436,8 @@ private void CreatePropertiesForModelClass(OpenApiUrlTreeNode currentNode, IOpen
LogOmittedPropertyInvalidSchema(x.Key, model.Name, currentNode.Path);
return null;
}
return CreateProperty(x.Key, definition.Name, propertySchema: propertySchema, existingType: definition);
var isRequired = schema.Required?.Contains(x.Key) ?? false;
return CreateProperty(x.Key, definition.Name, propertySchema: propertySchema, existingType: definition, isRequired: isRequired);
})
.OfType<CodeProperty>()
.ToArray() ?? [];
Expand Down
1 change: 1 addition & 0 deletions src/Kiota.Builder/Refiners/CSharpRefiner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ protected static void MakeEnumPropertiesNullable(CodeElement currentElement)
if (currentElement is CodeClass currentClass && currentClass.IsOfKind(CodeClassKind.Model))
currentClass.Properties
.Where(x => x.Type is CodeType propType && propType.TypeDefinition is CodeEnum)
.Where(x => !x.IsRequired)
.ToList()
.ForEach(x => x.Type.IsNullable = true);
CrawlTree(currentElement, MakeEnumPropertiesNullable);
Expand Down
14 changes: 14 additions & 0 deletions src/Kiota.Builder/Writers/CSharp/CSharpConventionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,20 @@ _ when NullableTypes.Contains(typeName) => true,
_ => false,
};
}
internal bool IsValueType(CodeTypeBase type)
{
if (type is not CodeType codeType) return false;
// Collections are reference types regardless of element type — never use .Value on them
if (codeType.CollectionKind != CodeTypeBase.CodeTypeCollectionKind.None) return false;
if (codeType.TypeDefinition is CodeEnum) return true;
var typeName = TranslateType(codeType);
return NullableTypes.Contains(typeName);
}
/// <summary>
/// When true (default), required non-nullable OAS properties are generated as non-nullable C# types.
/// Set to false to revert to the previous all-nullable behavior.
/// </summary>
public bool MakeRequiredPropertiesNonNullable { get; set; } = true;
public override string GetParameterSignature(CodeParameter parameter, CodeElement targetElement, LanguageWriter? writer = null)
{
ArgumentNullException.ThrowIfNull(parameter);
Expand Down
7 changes: 5 additions & 2 deletions src/Kiota.Builder/Writers/CSharp/CSharpWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ namespace Kiota.Builder.Writers.CSharp;

public class CSharpWriter : LanguageWriter
{
public CSharpWriter(string rootPath, string clientNamespaceName)
public CSharpWriter(string rootPath, string clientNamespaceName, bool makeRequiredPropertiesNonNullable = true)
{
PathSegmenter = new CSharpPathSegmenter(rootPath, clientNamespaceName);
var conventionService = new CSharpConventionService();
var conventionService = new CSharpConventionService
{
MakeRequiredPropertiesNonNullable = makeRequiredPropertiesNonNullable
};
AddOrReplaceCodeElementWriter(new CodeClassDeclarationWriter(conventionService));
AddOrReplaceCodeElementWriter(new CodeBlockEndWriter(conventionService));
AddOrReplaceCodeElementWriter(new CodeEnumWriter(conventionService));
Expand Down
5 changes: 4 additions & 1 deletion src/Kiota.Builder/Writers/CSharp/CodeMethodWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,10 @@ private void WriteDeserializerBodyForInheritedModel(bool shouldHide, CodeMethod
.Where(static x => !x.ExistsInBaseType)
.OrderBy(static x => x.Name, StringComparer.Ordinal))
{
writer.WriteLine($"{{ \"{otherProp.WireName.SanitizeDoubleQuote()}\", n => {{ {otherProp.Name.ToFirstCharacterUpperCase()} = n.{GetDeserializationMethodName(otherProp.Type, codeElement)}; }} }},");
// When a property is required and non-nullable, the C# property type is T (not T?).
// Parse-node methods for value types and enums return T?, so we must unwrap with .Value.
var deserializeSuffix = !otherProp.Type.IsNullable && conventions.IsValueType(otherProp.Type) ? ".Value" : string.Empty;
writer.WriteLine($"{{ \"{otherProp.WireName.SanitizeDoubleQuote()}\", n => {{ {otherProp.Name.ToFirstCharacterUpperCase()} = n.{GetDeserializationMethodName(otherProp.Type, codeElement)}{deserializeSuffix}; }} }},");
}
writer.CloseBlock("};");
}
Expand Down
6 changes: 4 additions & 2 deletions src/Kiota.Builder/Writers/CSharp/CodePropertyWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ public override void WriteCodeElement(CodeProperty codeElement, LanguageWriter w
if (codeElement.ExistsInExternalBaseType) return;
var propertyType = conventions.GetTypeString(codeElement.Type, codeElement);
var isNullableReferenceType = !propertyType.EndsWith('?')
&& codeElement.Type.IsNullable
&& !(conventions.MakeRequiredPropertiesNonNullable && codeElement.IsRequired)
&& codeElement.IsOfKind(
CodePropertyKind.Custom,
CodePropertyKind.QueryParameter);// Other property types are appropriately constructor initialized
CodePropertyKind.QueryParameter);
conventions.WriteShortDescription(codeElement, writer);
conventions.WriteDeprecationAttribute(codeElement, writer);
if (isNullableReferenceType)
Expand All @@ -26,7 +28,7 @@ public override void WriteCodeElement(CodeProperty codeElement, LanguageWriter w
CSharpConventionService.WriteNullableMiddle(writer);
}

WritePropertyInternal(codeElement, writer, propertyType);// Always write the normal way
WritePropertyInternal(codeElement, writer, propertyType);

if (isNullableReferenceType)
CSharpConventionService.WriteNullableClosing(writer);
Expand Down
4 changes: 2 additions & 2 deletions src/Kiota.Builder/Writers/LanguageWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,11 @@ protected void AddOrReplaceCodeElementWriter<T>(ICodeElementWriter<T> writer) wh
Writers[typeof(T)] = writer;
}
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`
public static LanguageWriter GetLanguageWriter(GenerationLanguage language, string outputPath, string clientNamespaceName, bool usesBackingStore = false, bool excludeBackwardCompatible = false)
public static LanguageWriter GetLanguageWriter(GenerationLanguage language, string outputPath, string clientNamespaceName, bool usesBackingStore = false, bool excludeBackwardCompatible = false, bool makeRequiredPropertiesNonNullable = true)
{
return language switch
{
GenerationLanguage.CSharp => new CSharpWriter(outputPath, clientNamespaceName),
GenerationLanguage.CSharp => new CSharpWriter(outputPath, clientNamespaceName, makeRequiredPropertiesNonNullable),
GenerationLanguage.Java => new JavaWriter(outputPath, clientNamespaceName),
GenerationLanguage.TypeScript => new TypeScriptWriter(outputPath, clientNamespaceName),
GenerationLanguage.Ruby => new RubyWriter(outputPath, clientNamespaceName),
Expand Down
6 changes: 6 additions & 0 deletions src/kiota/Handlers/KiotaGenerateCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
bool excludeBackwardCompatible = parseResult.GetValue(ExcludeBackwardCompatibleOption);
bool clearCache = parseResult.GetValue(ClearCacheOption);
bool disableSSLValidation = parseResult.GetValue(DisableSSLValidationOption);
bool makeRequiredPropertiesNonNullable = parseResult.GetValue(MakeRequiredPropertiesNonNullableOption);
bool includeAdditionalData = parseResult.GetValue(AdditionalDataOption);
string? className = parseResult.GetValue(ClassOption);
AccessModifier typeAccessModifier = parseResult.GetValue(TypeAccessModifierOption);
Expand Down Expand Up @@ -152,6 +153,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
Configuration.Generation.CleanOutput = cleanOutput;
Configuration.Generation.ClearCache = clearCache;
Configuration.Generation.DisableSSLValidation = disableSSLValidation;
Configuration.Generation.MakeRequiredPropertiesNonNullable = makeRequiredPropertiesNonNullable;

var (loggerFactory, logger) = GetLoggerAndFactory<KiotaBuilder>(parseResult, Configuration.Generation.OutputPath);
using (loggerFactory)
Expand Down Expand Up @@ -233,6 +235,10 @@ public required Option<bool> DisableSSLValidationOption
{
get; init;
}
public required Option<bool> MakeRequiredPropertiesNonNullableOption
{
get; init;
}

private static void CreateTelemetryTags(ActivitySource? activitySource, GenerationLanguage language, bool backingStore,
bool excludeBackwardCompatible, bool clearCache, bool disableSslValidation, bool cleanOutput, string? output,
Expand Down
14 changes: 14 additions & 0 deletions src/kiota/KiotaHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,8 @@ private static Command GetGenerateCommand(IServiceProvider serviceProvider)

var disableSSLValidationOption = GetDisableSSLValidationOption(defaultConfiguration.DisableSSLValidation);

var makeRequiredPropertiesNonNullableOption = GetMakeRequiredPropertiesNonNullableOption(defaultConfiguration.MakeRequiredPropertiesNonNullable);

var command = new Command("generate", "Generates a REST HTTP API client from an OpenAPI description file.") {
descriptionOption,
manifestOption,
Expand All @@ -610,6 +612,7 @@ private static Command GetGenerateCommand(IServiceProvider serviceProvider)
dvrOption,
clearCacheOption,
disableSSLValidationOption,
makeRequiredPropertiesNonNullableOption,
};
command.Action = new KiotaGenerateCommandHandler
{
Expand All @@ -633,6 +636,7 @@ private static Command GetGenerateCommand(IServiceProvider serviceProvider)
DisabledValidationRulesOption = dvrOption,
ClearCacheOption = clearCacheOption,
DisableSSLValidationOption = disableSSLValidationOption,
MakeRequiredPropertiesNonNullableOption = makeRequiredPropertiesNonNullableOption,
ServiceProvider = serviceProvider,
};
return command;
Expand Down Expand Up @@ -708,6 +712,16 @@ private static Option<bool> GetClearCacheOption(bool defaultValue)
return clearCacheOption;
}

internal static Option<bool> GetMakeRequiredPropertiesNonNullableOption(bool defaultValue = true)
{
var option = new Option<bool>("--make-required-properties-non-nullable")
{
DefaultValueFactory = _ => defaultValue,
Description = "When enabled (default), properties marked as required in the OpenAPI description and not explicitly nullable are generated as non-nullable types. Set to false to revert to the previous behavior where all properties are nullable, useful for specs that incorrectly mark fields as required.",
};
option.Aliases.Add("--mrpnn");
return option;
}
private static Option<bool> GetDisableSSLValidationOption(bool defaultValue)
{
var disableSSLValidationOption = new Option<bool>("--disable-ssl-validation")
Expand Down
Loading