diff --git a/cli/example/multipart-form-data.yaml b/cli/example/multipart-form-data.yaml new file mode 100644 index 00000000..2a8af853 --- /dev/null +++ b/cli/example/multipart-form-data.yaml @@ -0,0 +1,68 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Multipart form data + +paths: + "/api": + post: + responses: + 200: + description: Response + requestBody: + $ref: "#/components/requestBodies/Multipart" + "/noMaybes": + post: + responses: + 200: + description: Response + requestBody: + $ref: "#/components/requestBodies/MultipartNoMaybes" +components: + requestBodies: + Multipart: + # https://swagger.io/docs/specification/v3_0/describing-request-body/multipart-requests/ + content: + multipart/form-data: # Media type + schema: # Request payload + type: object + required: + - id + properties: # Request parts + id: # Part 1 (string value) + type: string + format: uuid + address: # Part2 (object) + type: object + properties: + street: + type: string + city: + type: string + profileImage: # Part 3 (an image) + type: string + format: binary + MultipartNoMaybes: + # https://swagger.io/docs/specification/v3_0/describing-request-body/multipart-requests/ + content: + multipart/form-data: # Media type + schema: # Request payload + type: object + required: + - id + - address + - profileImage + properties: # Request parts + id: # Part 1 (string value) + type: string + format: uuid + address: # Part2 (object) + type: object + properties: + street: + type: string + city: + type: string + profileImage: # Part 3 (an image) + type: string + format: binary diff --git a/cli/example/src/Example.elm b/cli/example/src/Example.elm index b0218761..70e7ef9d 100644 --- a/cli/example/src/Example.elm +++ b/cli/example/src/Example.elm @@ -11,6 +11,7 @@ import GithubV3RestApi.Api import GithubV3RestApi.Types import MarioPartyStats.Api import MarioPartyStats.Types +import MultipartFormData.Api import NullableEnum.Json import OpenApi.Common import PatreonApi.Api @@ -45,6 +46,9 @@ init () = let _ = SimpleRef.Json.decodeForbidden + + _ = + MultipartFormData.Api.api in ( {} , Cmd.batch diff --git a/cli/src/TestGenScript.elm b/cli/src/TestGenScript.elm index 51d594ce..2c3e70b1 100644 --- a/cli/src/TestGenScript.elm +++ b/cli/src/TestGenScript.elm @@ -51,6 +51,10 @@ run = marioPartyStats = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/MarioPartyStats.json") + multipartFormData : OpenApi.Config.Input + multipartFormData = + OpenApi.Config.inputFrom (OpenApi.Config.File "./example/multipart-form-data.yaml") + nullableEnum : OpenApi.Config.Input nullableEnum = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/nullable-enum.yaml") @@ -136,6 +140,7 @@ run = |> OpenApi.Config.withInput cookieAuth |> OpenApi.Config.withInput ifconfigOvh |> OpenApi.Config.withInput marioPartyStats + |> OpenApi.Config.withInput multipartFormData |> OpenApi.Config.withInput nullableEnum |> OpenApi.Config.withInput overridingGlobalSecurity |> OpenApi.Config.withInput realworldConduit diff --git a/src/JsonSchema/Generate.elm b/src/JsonSchema/Generate.elm index f9d2f61d..d8a483b0 100644 --- a/src/JsonSchema/Generate.elm +++ b/src/JsonSchema/Generate.elm @@ -1,4 +1,4 @@ -module JsonSchema.Generate exposing (schemaToDeclarations) +module JsonSchema.Generate exposing (multipartFormDataRequestBodyToDeclarations, schemaToDeclarations) import CliMonad exposing (CliMonad) import Common @@ -16,6 +16,43 @@ import NonEmpty import SchemaUtils +multipartFormDataRequestBodyToDeclarations : Common.UnsafeName -> Json.Schema.Definitions.Schema -> CliMonad (List CliMonad.Declaration) +multipartFormDataRequestBodyToDeclarations name schema = + SchemaUtils.schemaToType [] schema + |> CliMonad.andThen + (\{ type_, documentation } -> + type_ + |> SchemaUtils.typeToAnnotationWithNullable + |> CliMonad.map + (\annotation -> + let + typeName : Common.TypeName + typeName = + Common.toTypeName name + in + if (Elm.ToString.annotation annotation).signature == typeName then + [] + + else + [ { moduleName = Common.Types Common.RequestBody + , name = typeName + , declaration = + Elm.alias typeName annotation + |> (case documentation of + Nothing -> + identity + + Just doc -> + Elm.withDocumentation doc + ) + |> Elm.expose + , group = "Aliases" + } + ] + ) + ) + + schemaToDeclarations : Common.Component -> Common.UnsafeName -> Json.Schema.Definitions.Schema -> CliMonad (List CliMonad.Declaration) schemaToDeclarations component name schema = SchemaUtils.schemaToType [] schema diff --git a/src/OpenApi/Generate.elm b/src/OpenApi/Generate.elm index 2b72010d..f6dee69a 100644 --- a/src/OpenApi/Generate.elm +++ b/src/OpenApi/Generate.elm @@ -86,6 +86,14 @@ type ContentSchema | StringContent Mime | BytesContent Mime | Base64Content Mime + | MultipartContent (List { name : Common.UnsafeName, required : Bool, part : MultipartPart }) + + +{-| -} +type MultipartPart + = JsonPart Common.Type + | StringPart + | BytesPart type alias AuthorizationInfo = @@ -467,7 +475,13 @@ requestBodyToDeclarations name reference = |> CliMonad.andThen (JsonSchema.Generate.schemaToDeclarations Common.RequestBody name) Nothing -> - CliMonad.fail "The request body doesn't contain a json content" + case Dict.get "multipart/form-data" content of + Just formData -> + getSchema "multipart/form-data" formData + |> CliMonad.andThen (JsonSchema.Generate.multipartFormDataRequestBodyToDeclarations name) + + Nothing -> + CliMonad.fail "The request body contains neither a json nor a multipart/form-data content" Nothing -> CliMonad.fail "Could not convert reference to concrete value" @@ -571,10 +585,88 @@ contentSchemaToBodyBuilder bodyContent = |> Gen.Base64.fromBytes |> Gen.Maybe.withDefault (Elm.string "") ) + + MultipartContent parts -> + let + allRequired : Bool + allRequired = + List.all .required parts + in + parts + |> CliMonad.combineMap + (\part -> + part + |> buildPart utils + |> CliMonad.map + (\partMaker config -> + let + partValue : Elm.Expression + partValue = + config + |> Elm.get "body" + |> Elm.get (Common.toValueName part.name) + in + if allRequired then + partMaker partValue + + else if part.required then + Elm.maybe (Just (partMaker partValue)) + + else + Gen.Maybe.map partMaker partValue + ) + ) + |> CliMonad.map + (\fs config -> + if allRequired then + fs + |> List.map (\x -> x config) + |> Elm.list + |> utils.multipartBody + + else + fs + |> List.map (\x -> x config) + |> Gen.List.filterMap Gen.Basics.identity + |> utils.multipartBody + ) in perPackageMap toBody perPackageBindings +buildPart : Bindings -> { required : Bool, part : MultipartPart, name : Common.UnsafeName } -> CliMonad (Elm.Expression -> Elm.Expression) +buildPart utils part = + case part.part of + JsonPart partType -> + SchemaUtils.typeToEncoder partType + |> CliMonad.map + (\encoder field -> + field + |> encoder + |> Gen.Json.Encode.call_.encode + (Elm.int 0) + |> utils.stringPart + (Elm.string (Common.toValueName part.name)) + ) + + StringPart -> + CliMonad.succeed + (\field -> + utils.stringPart + (Elm.string (Common.toValueName part.name)) + field + ) + + BytesPart -> + CliMonad.succeed + (\field -> + utils.bytesPart + (Elm.string (Common.toValueName part.name)) + (Elm.string "application/octet-stream") + field + ) + + type alias Bindings = { bytesBody : Elm.Expression -> Elm.Expression -> Elm.Expression , emptyBody : Elm.Expression @@ -650,6 +742,10 @@ contentSchemaToBodyParams contentSchema = CliMonad.succeed (Just Gen.Bytes.annotation_.bytes) |> CliMonad.withRequiredPackage "elm/bytes" |> CliMonad.withRequiredPackage Common.base64PackageName + + MultipartContent parts -> + multipartPartsToAnnotation parts + |> CliMonad.map Just in annotation |> CliMonad.map @@ -1185,6 +1281,38 @@ lamderaProgramTestTasks ({ method, functionName, resolver, errorTypeAnnotation, ] +multipartPartsToAnnotation : List { name : Common.UnsafeName, required : Bool, part : MultipartPart } -> CliMonad Elm.Annotation.Annotation +multipartPartsToAnnotation parts = + parts + |> CliMonad.combineMap + (\{ name, required, part } -> + let + partAnnotation : CliMonad Elm.Annotation.Annotation + partAnnotation = + case part of + JsonPart type_ -> + SchemaUtils.typeToAnnotationWithNullable type_ + + StringPart -> + CliMonad.succeed Elm.Annotation.string + + BytesPart -> + CliMonad.succeed Gen.Bytes.annotation_.bytes + |> CliMonad.withRequiredPackage "elm/bytes" + in + CliMonad.map + (\partType -> + if required then + ( Common.toValueName name, partType ) + + else + ( Common.toValueName name, Elm.Annotation.maybe partType ) + ) + partAnnotation + ) + |> CliMonad.map Elm.Annotation.record + + operationToGroup : OpenApi.Operation.Operation -> String operationToGroup operation = case OpenApi.Operation.tags operation of @@ -1724,13 +1852,18 @@ contentToContentSchema content = stringContent "text/plain" htmlSchema Nothing -> - let - msg : String - msg = - "The content doesn't have an application/json, text/html or text/plain option, it has " ++ String.join ", " (Dict.keys content) - in - fallback - |> Maybe.withDefault (CliMonad.fail msg) + case Dict.get "multipart/form-data" content of + Just multipartSchema -> + multipartContent multipartSchema + + Nothing -> + let + msg : String + msg = + "The content doesn't have an application/json, multipart/form-data, text/html or text/plain option, it has " ++ String.join ", " (Dict.keys content) + in + fallback + |> Maybe.withDefault (CliMonad.fail msg) stringContent : String -> OpenApi.MediaType.MediaType -> CliMonad ContentSchema stringContent mime htmlSchema = @@ -1789,6 +1922,48 @@ contentToContentSchema content = default Nothing +multipartContent : OpenApi.MediaType.MediaType -> CliMonad ContentSchema +multipartContent mediaType = + case OpenApi.MediaType.schema mediaType of + Nothing -> + CliMonad.fail "Missing schema" + + Just schema -> + SchemaUtils.schemaToType [] (OpenApi.Schema.get schema) + |> CliMonad.andThen + (\{ type_ } -> + case type_ of + Common.Object fields -> + fields + |> List.map + (\( fieldName, field ) -> + { name = fieldName + , required = field.required + , part = + case field.type_ of + Common.Basic Common.String { format } -> + case format of + Just "binary" -> + BytesPart + + _ -> + StringPart + + Common.Bytes -> + BytesPart + + _ -> + JsonPart field.type_ + } + ) + |> MultipartContent + |> CliMonad.succeed + + _ -> + CliMonad.fail ("Schema with a type of " ++ SchemaUtils.typeToString type_ ++ " not supported") + ) + + toConfigParamAnnotation : { operation : OpenApi.Operation.Operation , successAnnotation : Elm.Annotation.Annotation @@ -2591,6 +2766,9 @@ operationToTypesExpectAndResolver effectTypes method pathUrl operation = , toMsg = toMsg } |> CliMonad.succeed + + MultipartContent _ -> + CliMonad.fail "operationToTypesExpectAndResolver: branch 'MultipartContent _' not implemented" ) (OpenApi.Response.content response |> contentToContentSchema @@ -2668,6 +2846,9 @@ errorResponsesToType functionName errorResponses = Base64Content _ -> CliMonad.succeed Elm.Annotation.string + + MultipartContent parts -> + multipartPartsToAnnotation parts ) Nothing -> @@ -2762,6 +2943,9 @@ errorResponsesToErrorDecoders functionName errorResponses = EmptyContent -> CliMonad.succeed (Gen.Json.Decode.succeed Elm.unit) + + MultipartContent _ -> + CliMonad.todo "Multipart errors are not supported yet" ) Nothing -> diff --git a/src/SchemaUtils.elm b/src/SchemaUtils.elm index aad8f6c4..a89ef217 100644 --- a/src/SchemaUtils.elm +++ b/src/SchemaUtils.elm @@ -11,6 +11,7 @@ module SchemaUtils exposing , typeToAnnotationWithNullable , typeToDecoder , typeToEncoder + , typeToString ) import CliMonad exposing (CliMonad)