From 51963945d3a01048ef859a09fd150bbc851752d2 Mon Sep 17 00:00:00 2001 From: Neyts Zupan Date: Fri, 6 Mar 2026 22:48:02 +0000 Subject: [PATCH 1/4] Support path-level parameters in code generation Merge path item parameters with operation parameters per the OpenAPI spec. Previously, parameters defined at the path level (shared across all operations) were silently ignored, causing path params like {orgId} to appear as literal strings in generated URLs. --- cli/example/path-level-params.yaml | 41 +++++++ cli/src/TestGenScript.elm | 53 +++++---- src/OpenApi/Generate.elm | 84 ++++++++++---- tests/Test/OpenApi/Generate.elm | 170 ++++++++++++++++++++++++++++- 4 files changed, 300 insertions(+), 48 deletions(-) create mode 100644 cli/example/path-level-params.yaml diff --git a/cli/example/path-level-params.yaml b/cli/example/path-level-params.yaml new file mode 100644 index 00000000..cbb8d569 --- /dev/null +++ b/cli/example/path-level-params.yaml @@ -0,0 +1,41 @@ +openapi: "3.1.0" +info: + title: "Path Level Params Test" + version: "1.0.0" +paths: + /orgs/{orgId}/teams/{teamId}/items: + parameters: + - $ref: '#/components/parameters/orgIdParam' + - in: path + name: teamId + required: true + schema: + type: string + get: + operationId: getItems + parameters: + - in: query + name: status + required: false + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + count: + type: integer + required: + - count +components: + parameters: + orgIdParam: + in: path + name: orgId + required: true + schema: + type: string diff --git a/cli/src/TestGenScript.elm b/cli/src/TestGenScript.elm index 4944fad4..4481dd23 100644 --- a/cli/src/TestGenScript.elm +++ b/cli/src/TestGenScript.elm @@ -39,10 +39,23 @@ run = binaryResponse = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/binary-response.yaml") + bug : Int -> OpenApi.Config.Input + bug n = + OpenApi.Config.inputFrom (OpenApi.Config.File ("./example/openapi-generator-bugs/" ++ String.fromInt n ++ ".yaml")) + cookieAuth : OpenApi.Config.Input cookieAuth = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/cookie-auth.yaml") + dbFahrplanApi : OpenApi.Config.Input + dbFahrplanApi = + OpenApi.Config.inputFrom (OpenApi.Config.File "./example/db-fahrplan-api-specification.yaml") + + gitHub : OpenApi.Config.Input + gitHub = + OpenApi.Config.inputFrom (OpenApi.Config.File "./example/github-spec.json") + |> OpenApi.Config.withWarnOnMissingEnums False + ifconfigOvh : OpenApi.Config.Input ifconfigOvh = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/ifconfig.ovh.json") @@ -60,6 +73,14 @@ run = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/overriding-global-security.yaml") |> OpenApi.Config.withOverrides [ OpenApi.Config.File "./example/overriding-global-security-override.yaml" ] + pathLevelParams : OpenApi.Config.Input + pathLevelParams = + OpenApi.Config.inputFrom (OpenApi.Config.File "./example/path-level-params.yaml") + + patreon : OpenApi.Config.Input + patreon = + OpenApi.Config.inputFrom (OpenApi.Config.File "./example/patreon.json") + realworldConduit : OpenApi.Config.Input realworldConduit = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/realworld-conduit.yaml") @@ -76,9 +97,9 @@ run = singleEnum = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/single-enum.yaml") - uuidArrayParam : OpenApi.Config.Input - uuidArrayParam = - OpenApi.Config.inputFrom (OpenApi.Config.File "./example/uuid-array-param.yaml") + telegramBot : OpenApi.Config.Input + telegramBot = + OpenApi.Config.inputFrom (OpenApi.Config.File "./example/telegram-bot.json") trustmark : OpenApi.Config.Input trustmark = @@ -93,31 +114,14 @@ run = |> OpenApi.Config.withOutputModuleName [ "Trustmark", "TradeCheck" ] |> OpenApi.Config.withEffectTypes [ OpenApi.Config.ElmHttpCmd ] + uuidArrayParam : OpenApi.Config.Input + uuidArrayParam = + OpenApi.Config.inputFrom (OpenApi.Config.File "./example/uuid-array-param.yaml") + viaggiatreno : OpenApi.Config.Input viaggiatreno = OpenApi.Config.inputFrom (OpenApi.Config.File "./example/viaggiatreno.yaml") - bug : Int -> OpenApi.Config.Input - bug n = - OpenApi.Config.inputFrom (OpenApi.Config.File ("./example/openapi-generator-bugs/" ++ String.fromInt n ++ ".yaml")) - - dbFahrplanApi : OpenApi.Config.Input - dbFahrplanApi = - OpenApi.Config.inputFrom (OpenApi.Config.File "./example/db-fahrplan-api-specification.yaml") - - gitHub : OpenApi.Config.Input - gitHub = - OpenApi.Config.inputFrom (OpenApi.Config.File "./example/github-spec.json") - |> OpenApi.Config.withWarnOnMissingEnums False - - patreon : OpenApi.Config.Input - patreon = - OpenApi.Config.inputFrom (OpenApi.Config.File "./example/patreon.json") - - telegramBot : OpenApi.Config.Input - telegramBot = - OpenApi.Config.inputFrom (OpenApi.Config.File "./example/telegram-bot.json") - profileConfig : OpenApi.Config.Config profileConfig = -- Slimmed config for profiling @@ -133,6 +137,7 @@ run = |> OpenApi.Config.withInput marioPartyStats |> OpenApi.Config.withInput nullableEnum |> OpenApi.Config.withInput overridingGlobalSecurity + |> OpenApi.Config.withInput pathLevelParams |> OpenApi.Config.withInput realworldConduit |> OpenApi.Config.withInput recursiveAllOfRefs |> OpenApi.Config.withInput simpleRef diff --git a/src/OpenApi/Generate.elm b/src/OpenApi/Generate.elm index b7853ff7..edceb098 100644 --- a/src/OpenApi/Generate.elm +++ b/src/OpenApi/Generate.elm @@ -273,6 +273,42 @@ stripTrailingSlash input = input +{-| Merge path-level parameters with operation-level parameters. +Per the OpenAPI spec, operation-level parameters override path-level +parameters with the same name and location. +-} +mergeParams : + List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) + -> List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) + -> CliMonad (List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter)) +mergeParams pathParams operationParams = + let + paramKey : OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter -> CliMonad String + paramKey param = + toConcreteParam param + |> CliMonad.map (\concrete -> OpenApi.Parameter.in_ concrete ++ ":" ++ OpenApi.Parameter.name concrete) + in + CliMonad.combineMap paramKey operationParams + |> CliMonad.map FastSet.fromList + |> CliMonad.andThen + (\operationParamKeys -> + pathParams + |> CliMonad.combineMap + (\param -> + paramKey param + |> CliMonad.map + (\key -> + if FastSet.member key operationParamKeys then + Nothing + + else + Just param + ) + ) + |> CliMonad.map (\filtered -> List.filterMap identity filtered ++ operationParams) + ) + + pathDeclarations : List OpenApi.Config.EffectType -> ServerInfo -> CliMonad (List CliMonad.Declaration) pathDeclarations effectTypes server = CliMonad.getApiSpec @@ -294,7 +330,7 @@ pathDeclarations effectTypes server = |> List.filterMap (\( method, getter ) -> Maybe.map (Tuple.pair method) (getter path)) |> CliMonad.combineMap (\( method, operation ) -> - toRequestFunctions server effectTypes method url operation + toRequestFunctions server effectTypes method url (OpenApi.Path.parameters path) operation |> CliMonad.errorToWarning ) |> CliMonad.map (List.filterMap identity >> List.concat) @@ -462,8 +498,17 @@ requestBodyToDeclarations name reference = |> CliMonad.withPath name -toRequestFunctions : ServerInfo -> List OpenApi.Config.EffectType -> String -> String -> OpenApi.Operation.Operation -> CliMonad (List CliMonad.Declaration) -toRequestFunctions server effectTypes method pathUrl operation = +toRequestFunctions : ServerInfo -> List OpenApi.Config.EffectType -> String -> String -> List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) -> OpenApi.Operation.Operation -> CliMonad (List CliMonad.Declaration) +toRequestFunctions server effectTypes method pathUrl pathLevelParams operation = + mergeParams pathLevelParams (OpenApi.Operation.parameters operation) + |> CliMonad.andThen + (\allParams -> + toRequestFunctionsHelp server effectTypes method pathUrl operation allParams + ) + + +toRequestFunctionsHelp : ServerInfo -> List OpenApi.Config.EffectType -> String -> String -> OpenApi.Operation.Operation -> List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) -> CliMonad (List CliMonad.Declaration) +toRequestFunctionsHelp server effectTypes method pathUrl operation allParams = let functionName : String functionName = @@ -1142,7 +1187,7 @@ toRequestFunctions server effectTypes method pathUrl operation = |> CliMonad.andThen (\params -> toConfigParamAnnotation - { operation = operation + { allParams = allParams , successAnnotation = successAnnotation , errorBodyAnnotation = bodyTypeAnnotation , errorTypeAnnotation = errorTypeAnnotation @@ -1152,8 +1197,8 @@ toRequestFunctions server effectTypes method pathUrl operation = } ) ) - (replacedUrl server auth pathUrl operation) - (operationToHeaderParams operation) + (replacedUrl server auth pathUrl allParams) + (operationToHeaderParams allParams) ) (operationToContentSchema operation) (operationToAuthorizationInfo operation) @@ -1181,10 +1226,9 @@ operationToGroup operation = "Operations" -operationToHeaderParams : OpenApi.Operation.Operation -> CliMonad (List (Elm.Expression -> ( Elm.Expression, Elm.Expression, Bool ))) -operationToHeaderParams operation = - operation - |> OpenApi.Operation.parameters +operationToHeaderParams : List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) -> CliMonad (List (Elm.Expression -> ( Elm.Expression, Elm.Expression, Bool ))) +operationToHeaderParams params = + params |> CliMonad.combineMap (\param -> toConcreteParam param @@ -1230,8 +1274,8 @@ operationToHeaderParams operation = |> CliMonad.map (List.filterMap identity) -replacedUrl : ServerInfo -> AuthorizationInfo -> String -> OpenApi.Operation.Operation -> CliMonad (Elm.Expression -> Elm.Expression) -replacedUrl server authInfo pathUrl operation = +replacedUrl : ServerInfo -> AuthorizationInfo -> String -> List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) -> CliMonad (Elm.Expression -> Elm.Expression) +replacedUrl server authInfo pathUrl params = let pathSegments : List String pathSegments = @@ -1309,8 +1353,7 @@ replacedUrl server authInfo pathUrl operation = MultipleServers _ -> Gen.Url.Builder.call_.crossOrigin (Elm.get "server" config) (Elm.list replacedSegments) allQueryParams in - operation - |> OpenApi.Operation.parameters + params |> CliMonad.combineMap (\param -> toConcreteParam param @@ -1761,7 +1804,7 @@ contentToContentSchema content = toConfigParamAnnotation : - { operation : OpenApi.Operation.Operation + { allParams : List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) , successAnnotation : Elm.Annotation.Annotation , errorBodyAnnotation : Elm.Annotation.Annotation , errorTypeAnnotation : Elm.Annotation.Annotation @@ -1820,7 +1863,7 @@ toConfigParamAnnotation options = , lamderaProgramTest = toAnnotation toMsgLamderaProgramTest } ) - (operationToUrlParams options.operation) + (operationToUrlParams options.allParams) type ServerInfo @@ -1886,13 +1929,8 @@ serverInfo server = |> CliMonad.succeed -operationToUrlParams : OpenApi.Operation.Operation -> CliMonad (List ( Common.UnsafeName, Elm.Annotation.Annotation )) -operationToUrlParams operation = - let - params : List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) - params = - OpenApi.Operation.parameters operation - in +operationToUrlParams : List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) -> CliMonad (List ( Common.UnsafeName, Elm.Annotation.Annotation )) +operationToUrlParams params = if List.isEmpty params then CliMonad.succeed [] diff --git a/tests/Test/OpenApi/Generate.elm b/tests/Test/OpenApi/Generate.elm index 4133219d..667385f7 100644 --- a/tests/Test/OpenApi/Generate.elm +++ b/tests/Test/OpenApi/Generate.elm @@ -1,4 +1,4 @@ -module Test.OpenApi.Generate exposing (fuzzInputName, fuzzTitle, issue48, pr267, uuidArrayParam) +module Test.OpenApi.Generate exposing (fuzzInputName, fuzzTitle, issue48, pathLevelParams, pr267, uuidArrayParam) import Ansi.Color import CliMonad @@ -587,6 +587,174 @@ uuidArrayParam = ) +pathLevelParams : Test +pathLevelParams = + Test.test "Path-level parameters are merged into operations" <| + \() -> + let + oasString : String + oasString = + String.Multiline.here """ + openapi: "3.1.0" + info: + title: "Path Level Params Test" + version: "1.0.0" + paths: + /orgs/{orgId}/teams/{teamId}/items: + parameters: + - $ref: '#/components/parameters/orgIdParam' + - in: path + name: teamId + required: true + schema: + type: string + get: + operationId: getItems + parameters: + - in: query + name: status + required: false + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + count: + type: integer + required: + - count + components: + parameters: + orgIdParam: + in: path + name: orgId + required: true + schema: + type: string + """ + in + case + oasString + |> Yaml.Decode.fromString yamlToJsonValueDecoder + |> Result.mapError Debug.toString + |> Result.andThen + (\json -> + json + |> Json.Decode.decodeValue OpenApi.decode + |> Result.mapError Debug.toString + ) + of + Err e -> + Expect.fail e + + Ok oas -> + let + genFiles : + Result + CliMonad.Message + { modules : + List + { moduleName : List String + , declarations : FastDict.Dict String { group : String, declaration : Elm.Declaration } + } + , warnings : List CliMonad.Message + , requiredPackages : FastSet.Set String + } + genFiles = + OpenApi.Generate.files + { namespace = [ "Output" ] + , generateTodos = False + , effectTypes = [ OpenApi.Config.ElmHttpCmd ] + , server = OpenApi.Config.Default + , formats = OpenApi.Config.defaultFormats + , warnOnMissingEnums = True + , keepGoing = False + } + oas + in + case genFiles of + Err e -> + Expect.fail ("Error in generation: " ++ Debug.toString e) + + Ok { modules } -> + case modules of + [ apiFile ] -> + let + apiFileString : String + apiFileString = + String.Multiline.here """ + module Output.Api exposing ( getItems ) + + {-| + @docs getItems + -} + + + import Dict + import Http + import Json.Decode + import OpenApi.Common + import Url.Builder + + + {- ## Operations -} + + + getItems : + { toMsg : Result (OpenApi.Common.Error e String) { count : Int } -> msg + , params : { orgId : String, teamId : String, status : Maybe String } + } + -> Cmd msg + getItems config = + Http.request + { url = + Url.Builder.absolute + [ "orgs" + , config.params.orgId + , "teams" + , config.params.teamId + , "items" + ] + (List.filterMap + Basics.identity + [ Maybe.map + (Url.Builder.string "status") + config.params.status + ] + ) + , method = "GET" + , headers = [] + , expect = + OpenApi.Common.expectJsonCustom + (Dict.fromList []) + (Json.Decode.succeed + (\\count -> { count = count } + ) |> OpenApi.Common.jsonDecodeAndMap + (Json.Decode.field "count" Json.Decode.int) + ) + config.toMsg + , body = Http.emptyBody + , timeout = Nothing + , tracker = Nothing + } + """ + in + expectEqualMultiline apiFileString (fileToString apiFile) + + _ -> + Expect.fail + ("Expected to generate 1 file but found " + ++ (List.length modules |> String.fromInt) + ++ ": " + ++ moduleNames modules + ) + + yamlToJsonValueDecoder : Yaml.Decode.Decoder Json.Encode.Value yamlToJsonValueDecoder = Yaml.Decode.oneOf From ddecb61fc2ca9ef09c0ef15e5c6897821d98c76f Mon Sep 17 00:00:00 2001 From: Leonardo Taglialegne Date: Sun, 8 Mar 2026 15:40:08 +0100 Subject: [PATCH 2/4] Avoid building the intermediate list --- src/OpenApi/Generate.elm | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/OpenApi/Generate.elm b/src/OpenApi/Generate.elm index edceb098..48f45ad7 100644 --- a/src/OpenApi/Generate.elm +++ b/src/OpenApi/Generate.elm @@ -289,23 +289,27 @@ mergeParams pathParams operationParams = |> CliMonad.map (\concrete -> OpenApi.Parameter.in_ concrete ++ ":" ++ OpenApi.Parameter.name concrete) in CliMonad.combineMap paramKey operationParams - |> CliMonad.map FastSet.fromList |> CliMonad.andThen - (\operationParamKeys -> + (\operationParamKeysList -> + let + operationParamKeys : FastSet.Set String + operationParamKeys = + FastSet.fromList operationParamKeysList + in pathParams - |> CliMonad.combineMap - (\param -> + |> CliMonad.foldl + (\param acc -> paramKey param |> CliMonad.map (\key -> if FastSet.member key operationParamKeys then - Nothing + acc else - Just param + param :: acc ) ) - |> CliMonad.map (\filtered -> List.filterMap identity filtered ++ operationParams) + (CliMonad.succeed operationParams) ) From eaad0f8a15abdc63327b4b0176774cc9a29f0861 Mon Sep 17 00:00:00 2001 From: Leonardo Taglialegne Date: Sun, 8 Mar 2026 15:41:30 +0100 Subject: [PATCH 3/4] Avoid splitting toRequestFunctions --- src/OpenApi/Generate.elm | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/OpenApi/Generate.elm b/src/OpenApi/Generate.elm index 48f45ad7..55f1fce9 100644 --- a/src/OpenApi/Generate.elm +++ b/src/OpenApi/Generate.elm @@ -504,15 +504,6 @@ requestBodyToDeclarations name reference = toRequestFunctions : ServerInfo -> List OpenApi.Config.EffectType -> String -> String -> List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) -> OpenApi.Operation.Operation -> CliMonad (List CliMonad.Declaration) toRequestFunctions server effectTypes method pathUrl pathLevelParams operation = - mergeParams pathLevelParams (OpenApi.Operation.parameters operation) - |> CliMonad.andThen - (\allParams -> - toRequestFunctionsHelp server effectTypes method pathUrl operation allParams - ) - - -toRequestFunctionsHelp : ServerInfo -> List OpenApi.Config.EffectType -> String -> String -> OpenApi.Operation.Operation -> List (OpenApi.Reference.ReferenceOr OpenApi.Parameter.Parameter) -> CliMonad (List CliMonad.Declaration) -toRequestFunctionsHelp server effectTypes method pathUrl operation allParams = let functionName : String functionName = @@ -1160,8 +1151,8 @@ toRequestFunctionsHelp server effectTypes method pathUrl operation allParams = ) ] in - CliMonad.andThen3 - (\contentSchema auth successAnnotation -> + CliMonad.andThen4 + (\contentSchema auth successAnnotation allParams -> CliMonad.andThen4 (\toBody configAnnotation replaced toHeaderParams -> CliMonad.map2 (++) @@ -1213,6 +1204,7 @@ toRequestFunctionsHelp server effectTypes method pathUrl operation allParams = SuccessReference ref -> CliMonad.refToAnnotation ref ) + (mergeParams pathLevelParams (OpenApi.Operation.parameters operation)) in operationToTypesExpectAndResolver functionName operation |> CliMonad.andThen step From 424069722f7ea906e9a0a38adcf41e709725cbfb Mon Sep 17 00:00:00 2001 From: Leonardo Taglialegne Date: Sun, 8 Mar 2026 15:46:15 +0100 Subject: [PATCH 4/4] Ignore unused export from CliMonad --- review/suppressed/NoUnused.Exports.json | 1 + 1 file changed, 1 insertion(+) diff --git a/review/suppressed/NoUnused.Exports.json b/review/suppressed/NoUnused.Exports.json index 179de1b4..46dd9b74 100644 --- a/review/suppressed/NoUnused.Exports.json +++ b/review/suppressed/NoUnused.Exports.json @@ -3,6 +3,7 @@ "automatically created by": "elm-review suppress", "learn more": "elm-review suppress --help", "suppressions": [ + { "count": 1, "filePath": "src/CliMonad.elm" }, { "count": 1, "filePath": "src/OpenApi/Common/Internal.elm" } ] }