diff --git a/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj b/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj index d3e7433..f1e24e3 100644 --- a/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj +++ b/tests/SwaggerProvider.Tests/SwaggerProvider.Tests.fsproj @@ -20,6 +20,7 @@ + diff --git a/tests/SwaggerProvider.Tests/v3/Schema.DefinitionPathTests.fs b/tests/SwaggerProvider.Tests/v3/Schema.DefinitionPathTests.fs new file mode 100644 index 0000000..e2a72f1 --- /dev/null +++ b/tests/SwaggerProvider.Tests/v3/Schema.DefinitionPathTests.fs @@ -0,0 +1,109 @@ +module SwaggerProvider.Tests.v3_Schema_DefinitionPathTests + +/// Unit tests for DefinitionPath.Parse — the function that splits a JSON Reference +/// path (e.g. "#/components/schemas/My.Namespace.TypeName") into its namespace list, +/// requested type name, and PascalCase candidate name. + +open SwaggerProvider.Internal.v3.Compilers +open Xunit +open FsUnitTyped + +// ── Prefix constant ─────────────────────────────────────────────────────────── + +[] +let ``DefinitionPrefix is the OpenAPI component schema reference prefix``() = + DefinitionPath.DefinitionPrefix |> shouldEqual "#/components/schemas/" + +// ── Simple (un-namespaced) names ────────────────────────────────────────────── + +[] +let ``simple name has empty namespace``() = + let result = DefinitionPath.Parse "#/components/schemas/Pet" + result.Namespace |> shouldEqual [] + +[] +let ``simple name preserves RequestedTypeName exactly``() = + let result = DefinitionPath.Parse "#/components/schemas/Pet" + result.RequestedTypeName |> shouldEqual "Pet" + +[] +let ``simple PascalCase name has matching ProvidedTypeNameCandidate``() = + let result = DefinitionPath.Parse "#/components/schemas/Pet" + result.ProvidedTypeNameCandidate |> shouldEqual "Pet" + +[] +let ``simple camelCase name is PascalCased in ProvidedTypeNameCandidate``() = + let result = DefinitionPath.Parse "#/components/schemas/petModel" + result.ProvidedTypeNameCandidate |> shouldEqual "PetModel" + +[] +let ``simple camelCase name preserves original casing in RequestedTypeName``() = + let result = DefinitionPath.Parse "#/components/schemas/petModel" + result.RequestedTypeName |> shouldEqual "petModel" + +// ── One-level namespaced names ──────────────────────────────────────────────── + +[] +let ``one-level namespace is extracted``() = + let result = DefinitionPath.Parse "#/components/schemas/My.Pet" + result.Namespace |> shouldEqual [ "My" ] + +[] +let ``one-level namespace leaves type name after the dot``() = + let result = DefinitionPath.Parse "#/components/schemas/My.Pet" + result.RequestedTypeName |> shouldEqual "Pet" + +[] +let ``one-level namespace applies PascalCase to ProvidedTypeNameCandidate``() = + let result = DefinitionPath.Parse "#/components/schemas/my.petModel" + result.ProvidedTypeNameCandidate |> shouldEqual "PetModel" + +// ── Multi-level namespaced names ────────────────────────────────────────────── + +[] +let ``two-level namespace is fully extracted``() = + let result = DefinitionPath.Parse "#/components/schemas/A.B.TypeName" + result.Namespace |> shouldEqual [ "A"; "B" ] + result.RequestedTypeName |> shouldEqual "TypeName" + +[] +let ``three-level namespace is fully extracted``() = + let result = DefinitionPath.Parse "#/components/schemas/A.B.C.TypeName" + result.Namespace |> shouldEqual [ "A"; "B"; "C" ] + result.RequestedTypeName |> shouldEqual "TypeName" + +[] +let ``deep namespace preserves all namespace segments``() = + let result = + DefinitionPath.Parse "#/components/schemas/Com.Example.Api.Models.Response" + + result.Namespace |> shouldEqual [ "Com"; "Example"; "Api"; "Models" ] + result.RequestedTypeName |> shouldEqual "Response" + +// ── Names containing non-alphanumeric / non-dot characters ─────────────────── +// Hyphens and underscores are valid in JSON schema names but are NOT dot-separators, +// so the function should find no namespace when no dot precedes them. + +[] +let ``name containing only a hyphen has no namespace``() = + let result = DefinitionPath.Parse "#/components/schemas/my-type" + result.Namespace |> shouldEqual [] + +[] +let ``name with hyphen does not extract a spurious namespace``() = + let result = DefinitionPath.Parse "#/components/schemas/Api.my-type" + // The dot before "my-type" is in the scanned definition-name segment after + // the prefix; the hyphen stops the scan, so LastIndexOf('.') finds that dot. + result.Namespace |> shouldEqual [ "Api" ] + +// ── Error handling ──────────────────────────────────────────────────────────── + +[] +let ``definition not starting with prefix throws``() = + let act = fun () -> DefinitionPath.Parse "notADefinitionPath" |> ignore + act |> shouldFail + +[] +let ``swagger 2 definitions path does not start with v3 prefix and throws``() = + let act = fun () -> DefinitionPath.Parse "#/definitions/Pet" |> ignore + act |> shouldFail diff --git a/tests/SwaggerProvider.Tests/v3/Schema.TestHelpers.fs b/tests/SwaggerProvider.Tests/v3/Schema.TestHelpers.fs index 18b0316..0550fe2 100644 --- a/tests/SwaggerProvider.Tests/v3/Schema.TestHelpers.fs +++ b/tests/SwaggerProvider.Tests/v3/Schema.TestHelpers.fs @@ -5,9 +5,10 @@ open System open Microsoft.OpenApi.Reader open SwaggerProvider.Internal.v3.Compilers -/// Parse and compile a full OpenAPI v3 schema string, then return all provided types. -/// Pass asAsync=true to generate Async<'T> operation return types, or false for Task<'T>. -let compileV3Schema (schemaStr: string) (asAsync: bool) = +/// Core: parse, validate, and compile an OpenAPI v3 schema string. +/// `provideNullable` controls whether optional value-type properties use Nullable. +/// `asAsync` controls whether operation return types are Async<'T> or Task<'T>. +let private compileV3SchemaCore (schemaStr: string) (provideNullable: bool) (asAsync: bool) = let settings = OpenApiReaderSettings() settings.AddYamlReader() @@ -30,11 +31,16 @@ let compileV3Schema (schemaStr: string) (asAsync: bool) = | null -> failwith "Failed to parse OpenAPI schema: Document is null." | doc -> doc - let defCompiler = DefinitionCompiler(schema, false, false) + let defCompiler = DefinitionCompiler(schema, provideNullable, false) let opCompiler = OperationCompiler(schema, defCompiler, true, false, asAsync) opCompiler.CompileProvidedClients(defCompiler.Namespace) defCompiler.Namespace.GetProvidedTypes() +/// Parse and compile a full OpenAPI v3 schema string, then return all provided types. +/// Pass asAsync=true to generate Async<'T> operation return types, or false for Task<'T>. +let compileV3Schema (schemaStr: string) (asAsync: bool) = + compileV3SchemaCore schemaStr false asAsync + /// Parse and compile a full OpenAPI v3 schema string, then return the .NET type of /// the `Value` property on the `TestType` component schema. let compileSchemaAndGetValueType(schemaStr: string) : Type = @@ -45,17 +51,16 @@ let compileSchemaAndGetValueType(schemaStr: string) : Type = | null -> failwith "Property 'Value' not found on TestType" | prop -> prop.PropertyType -/// Compile a minimal v3 schema where TestType.Value is defined by `propYaml`. -let compilePropertyType (propYaml: string) (required: bool) : Type = +/// Build the minimal v3 schema string for a TestType.Value property. +let private buildPropertySchema (propYaml: string) (required: bool) = let requiredBlock = if required then " required:\n - Value\n" else "" - let schemaStr = - sprintf - """openapi: "3.0.0" + sprintf + """openapi: "3.0.0" info: title: TypeMappingTest version: "1.0.0" @@ -67,7 +72,21 @@ components: %s properties: Value: %s""" - requiredBlock - propYaml + requiredBlock + propYaml - compileSchemaAndGetValueType schemaStr +/// Compile a minimal v3 schema where TestType.Value is defined by `propYaml`. +let compilePropertyType (propYaml: string) (required: bool) : Type = + compileSchemaAndGetValueType(buildPropertySchema propYaml required) + +/// Compile a minimal v3 schema with configurable DefinitionCompiler options. +/// Returns the .NET type of the `Value` property on `TestType`. +let compilePropertyTypeWith (provideNullable: bool) (propYaml: string) (required: bool) : Type = + let types = + compileV3SchemaCore (buildPropertySchema propYaml required) provideNullable false + + 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 443bdc0..072937f 100644 --- a/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/v3/Schema.TypeMappingTests.fs @@ -279,3 +279,38 @@ 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 + +// ── PreferNullable=true: optional value types use Nullable ───────────────── +// When provideNullable=true, the DefinitionCompiler wraps optional value types +// in Nullable instead of Option. + +[] +let ``PreferNullable: optional boolean maps to Nullable``() = + let ty = compilePropertyTypeWith true " type: boolean\n" false + + ty |> shouldEqual typeof> + +[] +let ``PreferNullable: optional integer maps to Nullable``() = + let ty = compilePropertyTypeWith true " type: integer\n" false + + ty |> shouldEqual typeof> + +[] +let ``PreferNullable: optional int64 maps to Nullable``() = + let ty = + compilePropertyTypeWith true " type: integer\n format: int64\n" false + + ty |> shouldEqual typeof> + +[] +let ``PreferNullable: required integer is not wrapped (Nullable only for optional)``() = + let ty = compilePropertyTypeWith true " type: integer\n" true + ty |> shouldEqual typeof + +[] +let ``PreferNullable: optional string is not wrapped (reference type)``() = + // Reference types like string are not wrapped in Nullable since they are + // already nullable by nature — same behaviour as Option mode. + let ty = compilePropertyTypeWith true " type: string\n" false + ty |> shouldEqual typeof