diff --git a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs index 576a7e7e..47ed7efb 100644 --- a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs +++ b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs @@ -49,7 +49,8 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = ProvidedStaticParameter("PreferNullable", typeof, false) ProvidedStaticParameter("PreferAsync", typeof, false) ProvidedStaticParameter("SsrfProtection", typeof, true) - ProvidedStaticParameter("IgnoreParseErrors", typeof, false) ] + ProvidedStaticParameter("IgnoreParseErrors", typeof, false) + ProvidedStaticParameter("WrapNullableStrings", typeof, false) ] t.AddXmlDoc """Statically typed OpenAPI provider. @@ -59,7 +60,8 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = Provide `Nullable<_>` for not required properties, instead of `Option<_>`. Defaults value `false`. Generate async actions of type `Async<'T>` instead of `Task<'T>`. Defaults value `false`. Enable SSRF protection (blocks HTTP and localhost). Set to false for development/testing. Default value `true`. - Continue generating the provider even when the OpenAPI parser reports validation errors (e.g. vendor extensions or non-strictly-compliant schemas). Warnings are printed to stderr. Default value `false`.""" + Continue generating the provider even when the OpenAPI parser reports validation errors (e.g. vendor extensions or non-strictly-compliant schemas). Warnings are printed to stderr. Default value `false`. + Wrap optional/nullable string fields as `Option<string>` instead of plain `string`. Matches the treatment of other optional types. Default value `false`.""" t.DefineStaticParameters( staticParams, @@ -71,6 +73,7 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = let preferAsync = unbox args.[4] let ssrfProtection = unbox args.[5] let ignoreParseErrors = unbox args.[6] + let wrapNullableStrings = unbox args.[7] // Cache key includes cfg.RuntimeAssembly, cfg.ResolutionFolder, and cfg.SystemRuntimeAssemblyVersion // to differentiate between different TFM builds (same approach as FSharp.Data) @@ -83,6 +86,7 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = preferAsync, ssrfProtection, ignoreParseErrors, + wrapNullableStrings, cfg.RuntimeAssembly, cfg.ResolutionFolder, cfg.SystemRuntimeAssemblyVersion) @@ -119,7 +123,9 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = |> Seq.toList let useDateOnly = cfg.SystemRuntimeAssemblyVersion.Major >= 6 - let defCompiler = DefinitionCompiler(schema, preferNullable, useDateOnly) + + let defCompiler = + DefinitionCompiler(schema, preferNullable, useDateOnly, wrapNullableStrings) let opCompiler = OperationCompiler(schema, defCompiler, ignoreControllerPrefix, ignoreOperationId, preferAsync) diff --git a/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs index 76617de5..210aee92 100644 --- a/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs @@ -165,7 +165,9 @@ and NamespaceAbstraction(name: string) = Some ty) /// Object for compiling definitions. -type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: bool) as this = +type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: bool, ?wrapNullableStrings: bool) as this = + let wrapNullableStrings = defaultArg wrapNullableStrings false + let pathToSchema = let dict = Collections.Generic.Dictionary() @@ -538,6 +540,8 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b typedefof> ProvidedTypeBuilder.MakeGenericType(baseGenTy, [ tyType ]) + else if wrapNullableStrings && tyType = typeof then + ProvidedTypeBuilder.MakeGenericType(typedefof>, [ tyType ]) else tyType diff --git a/tests/SwaggerProvider.Tests/v3/Schema.TestHelpers.fs b/tests/SwaggerProvider.Tests/v3/Schema.TestHelpers.fs index 0ad305b2..7c16e400 100644 --- a/tests/SwaggerProvider.Tests/v3/Schema.TestHelpers.fs +++ b/tests/SwaggerProvider.Tests/v3/Schema.TestHelpers.fs @@ -67,3 +67,52 @@ components: propYaml compileSchemaAndGetValueType schemaStr + +/// Compile a minimal v3 schema using the given DefinitionCompiler options. +let compilePropertyTypeWith (propYaml: string) (required: bool) (provideNullable: bool) (wrapNullableStrings: bool) : Type = + let requiredBlock = + if required then + " required:\n - Value\n" + else + "" + + let schemaStr = + sprintf + """openapi: "3.0.0" +info: + title: TypeMappingTest + version: "1.0.0" +paths: {} +components: + schemas: + TestType: + type: object +%s properties: + Value: +%s""" + requiredBlock + propYaml + + let settings = OpenApiReaderSettings() + settings.AddYamlReader() + + let readResult = + Microsoft.OpenApi.OpenApiDocument.Parse(schemaStr, settings = settings) + + let schema = + match readResult.Document with + | null -> failwith "Failed to parse OpenAPI schema: Document is null." + | doc -> doc + + let defCompiler = + DefinitionCompiler(schema, provideNullable, false, wrapNullableStrings) + + let opCompiler = OperationCompiler(schema, defCompiler, true, false, true) + opCompiler.CompileProvidedClients(defCompiler.Namespace) + + let types = defCompiler.Namespace.GetProvidedTypes() + let testType = types |> List.find(fun t -> t.Name = "TestType") + + match testType.GetDeclaredProperty("Value") with + | null -> failwith "Property 'Value' not found on TestType" + | prop -> prop.PropertyType diff --git a/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs index 443bdc0a..d8967b79 100644 --- a/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs @@ -279,3 +279,33 @@ let ``optional allOf $ref to integer alias resolves to Option``() = let ``optional allOf $ref to int64 alias resolves to Option``() = let ty = compileAllOfRefType " type: integer\n format: int64\n" false ty |> shouldEqual typeof + +// ── WrapNullableStrings option ─────────────────────────────────────────────── + +[] +let ``optional string without WrapNullableStrings maps to plain string``() = + let ty = compilePropertyType " type: string\n" false + ty |> shouldEqual typeof + +[] +let ``optional string with WrapNullableStrings maps to string option``() = + let ty = compilePropertyTypeWith " type: string\n" false false true + ty |> shouldEqual typeof + +[] +let ``required string with WrapNullableStrings still maps to plain string``() = + let ty = compilePropertyTypeWith " type: string\n" true false true + ty |> shouldEqual typeof + +[] +let ``optional string date-time with WrapNullableStrings maps to DateTimeOffset option``() = + // Non-string reference types (DateTimeOffset is a value type) unaffected by WrapNullableStrings + let ty = + compilePropertyTypeWith " type: string\n format: date-time\n" false false true + + ty |> shouldEqual typeof + +[] +let ``optional integer with WrapNullableStrings still maps to int32 option``() = + let ty = compilePropertyTypeWith " type: integer\n" false false true + ty |> shouldEqual typeof