diff --git a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs index 818b2063..e6baccc6 100644 --- a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs @@ -441,6 +441,17 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b else DefinitionPath.DefinitionPrefix + refId + // Helper for the allOf/oneOf/anyOf single-ref collapse pattern. + // When `schemas` has exactly one entry that is a $ref and the outer schema has no + // own properties, collapse directly to the referenced type; otherwise fall back to + // compileNewObject so composite/inline schemas are handled as usual. + let compileSingleRefOrNewObject(schemas: System.Collections.Generic.IList) = + match schemas.[0] with + | :? OpenApiSchemaReference as schemaRef when not(isNull schemaRef.Reference) -> + ns.ReleaseNameReservation tyName + compileByPath <| getFullPath schemaRef.Reference.Id + | _ -> compileNewObject() + let tyType = match schemaObj with | null -> failwithf $"Cannot compile object '%s{tyName}' when schema is 'null'" @@ -472,39 +483,26 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b compileBySchema ns (ns.ReserveUniqueName tyName "Item") elSchema true ns.RegisterType false ProvidedTypeBuilder.MakeGenericType(typedefof>, [ typeof; elTy ]) - // Handle allOf with single reference (e.g., nullable reference to another type) + // Handle allOf/oneOf/anyOf with a single $ref and no own properties: + // collapse the wrapper to the referenced type directly. | _ when not(isNull schemaObj.AllOf) && schemaObj.AllOf.Count = 1 && (schemaObj.Properties |> isNull || schemaObj.Properties.Count = 0) -> - match schemaObj.AllOf.[0] with - | :? OpenApiSchemaReference as schemaRef when not(isNull schemaRef.Reference) -> - ns.ReleaseNameReservation tyName - compileByPath <| getFullPath schemaRef.Reference.Id - | _ -> compileNewObject() - // Handle oneOf with single reference (resolves to the referenced type) + compileSingleRefOrNewObject schemaObj.AllOf | _ when not(isNull schemaObj.OneOf) && schemaObj.OneOf.Count = 1 && (schemaObj.Properties |> isNull || schemaObj.Properties.Count = 0) -> - match schemaObj.OneOf.[0] with - | :? OpenApiSchemaReference as schemaRef when not(isNull schemaRef.Reference) -> - ns.ReleaseNameReservation tyName - compileByPath <| getFullPath schemaRef.Reference.Id - | _ -> compileNewObject() - // Handle anyOf with single reference (resolves to the referenced type) + compileSingleRefOrNewObject schemaObj.OneOf | _ when not(isNull schemaObj.AnyOf) && schemaObj.AnyOf.Count = 1 && (schemaObj.Properties |> isNull || schemaObj.Properties.Count = 0) -> - match schemaObj.AnyOf.[0] with - | :? OpenApiSchemaReference as schemaRef when not(isNull schemaRef.Reference) -> - ns.ReleaseNameReservation tyName - compileByPath <| getFullPath schemaRef.Reference.Id - | _ -> compileNewObject() + compileSingleRefOrNewObject schemaObj.AnyOf | _ when resolvedType.IsNone || resolvedType = Some JsonSchemaType.Object diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index ebc37e87..f994952b 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -7,6 +7,7 @@ open System.Text.Json open Microsoft.FSharp.Quotations open Microsoft.FSharp.Quotations.ExprShape +open Microsoft.FSharp.Quotations.Patterns open Microsoft.OpenApi open ProviderImplementation.ProvidedTypes open FSharp.Data.Runtime.NameUtils @@ -319,13 +320,29 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, | _ -> failwithf $"Function '%s{providedMethodName}' does not support functions as arguments.") // Makes argument a string // TODO: Make body an exception + // NOTE: avoid `let x = ...` in quotation literals — they share a single Var + // object across all calls, causing "duplicate key" exceptions in ProvidedTypes + // when the same helper is called for multiple parameters in one operation. + // Instead, build the call expression directly without an intermediate binding. + let toParamMethod = + match <@@ RuntimeHelpers.toParam(null) @@> with + | Call(None, m, _) -> m + | _ -> failwith "Cannot extract toParam MethodInfo" + let coerceString exp = - let obj = Expr.Coerce(exp, typeof) |> Expr.Cast - <@ let x = (%obj) in RuntimeHelpers.toParam x @> + let obj = Expr.Coerce(exp, typeof) + Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast + + let toQueryParamsMethod = + match <@@ RuntimeHelpers.toQueryParams "" null (%this) @@> with + | Call(None, m, _) -> m + | _ -> failwith "Cannot extract toQueryParams MethodInfo" let rec coerceQueryString name expr = let obj = Expr.Coerce(expr, typeof) - <@ let o = (%%obj: obj) in RuntimeHelpers.toQueryParams name o (%this) @> + + Expr.Call(toQueryParamsMethod, [ Expr.Value name; obj; this ]) + |> Expr.Cast<(string * string) list> // Partitions arguments based on their locations let path, queryParams, headers = diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index 9c4c9c9e..f3eaae67 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -1113,6 +1113,162 @@ let ``ignoreOperationId=true does not generate the original operationId as metho methodNames |> shouldNotContain "ListAllPets" methodNames |> shouldNotContain "GetPetById" +// ── text/plain request body ─────────────────────────────────────────────────── + +let private textPlainBodySchema = + """openapi: "3.0.0" +info: + title: TextPlainBodyTest + version: "1.0.0" +paths: + /echo: + post: + operationId: echoText + requestBody: + required: true + content: + text/plain: + schema: + type: string + responses: + "200": + description: OK +components: + schemas: {} +""" + +[] +let ``text/plain request body generates a method``() = + let types = compileTaskSchema textPlainBodySchema + let method = findMethod types "EchoText" + method.IsSome |> shouldEqual true + +[] +let ``text/plain request body parameter is named textPlain``() = + let types = compileTaskSchema textPlainBodySchema + let method = (findMethod types "EchoText").Value + let paramNames = method.GetParameters() |> Array.map(fun p -> p.Name) + paramNames |> shouldContain "textPlain" + +[] +let ``text/plain request body has CancellationToken as last parameter``() = + let types = compileTaskSchema textPlainBodySchema + let method = (findMethod types "EchoText").Value + let lastParam = method.GetParameters() |> Array.last + lastParam.ParameterType |> shouldEqual typeof + +// ── application/octet-stream response ──────────────────────────────────────── + +let private octetStreamResponseSchema = + """openapi: "3.0.0" +info: + title: OctetStreamResponseTest + version: "1.0.0" +paths: + /file: + get: + operationId: downloadFile + responses: + "200": + description: File contents + content: + application/octet-stream: + schema: + type: string + format: binary +components: + schemas: {} +""" + +[] +let ``octet-stream response generates a method``() = + let types = compileTaskSchema octetStreamResponseSchema + let method = findMethod types "DownloadFile" + method.IsSome |> shouldEqual true + +[] +let ``octet-stream response produces Task return type``() = + let types = compileTaskSchema octetStreamResponseSchema + let method = (findMethod types "DownloadFile").Value + method.ReturnType.IsGenericType |> shouldEqual true + + method.ReturnType.GetGenericTypeDefinition() + |> shouldEqual typedefof> + + method.ReturnType.GetGenericArguments()[0] + |> shouldEqual typeof + +[] +let ``octet-stream response has CancellationToken as its only parameter``() = + let types = compileTaskSchema octetStreamResponseSchema + let method = (findMethod types "DownloadFile").Value + let parameters = method.GetParameters() + parameters.Length |> shouldEqual 1 + parameters[0].ParameterType |> shouldEqual typeof + +// ── Path-level parameters (inherited from PathItem) ─────────────────────────── + +let private pathLevelParamSchema = + """openapi: "3.0.0" +info: + title: PathLevelParamTest + version: "1.0.0" +paths: + /users/{userId}/posts: + parameters: + - name: userId + in: path + required: true + schema: + type: integer + get: + operationId: getUserPosts + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + type: string + post: + operationId: createUserPost + requestBody: + required: true + content: + application/json: + schema: + type: string + responses: + "201": + description: Created +components: + schemas: {} +""" + +[] +let ``path-level parameter is inherited by GET operation``() = + let types = compileTaskSchema pathLevelParamSchema + let method = (findMethod types "GetUserPosts").Value + let paramNames = method.GetParameters() |> Array.map(fun p -> p.Name) + paramNames |> shouldContain "userId" + +[] +let ``path-level parameter is required with correct type``() = + let types = compileTaskSchema pathLevelParamSchema + let method = (findMethod types "GetUserPosts").Value + let userIdParam = method.GetParameters() |> Array.find(fun p -> p.Name = "userId") + userIdParam.ParameterType |> shouldEqual typeof + userIdParam.IsOptional |> shouldEqual false + +[] +let ``path-level parameter is inherited by POST operation``() = + let types = compileTaskSchema pathLevelParamSchema + let method = (findMethod types "CreateUserPost").Value + let paramNames = method.GetParameters() |> Array.map(fun p -> p.Name) + paramNames |> shouldContain "userId" + // ── asAsync=true: octet-stream response produces Async ───────────── let private octetStreamResponseAsyncSchema =