From ad64fbb95fe3af6621cfbf7c335123b2619441d5 Mon Sep 17 00:00:00 2001 From: Neyts Zupan Date: Wed, 15 Apr 2026 20:51:02 +0100 Subject: [PATCH] Add --no-enum-sort flag to preserve spec-defined enum order By default, enum variants are sorted alphabetically (existing behaviour). Passing --no-enum-sort preserves the order defined in the OpenAPI spec, which is useful when enum order is meaningful (e.g. status workflows). --- cli/src/Cli.elm | 6 ++ docs/USAGE.md | 2 + src/CliMonad.elm | 12 ++- src/Common.elm | 1 - src/OpenApi/Config.elm | 27 +++++- src/OpenApi/Generate.elm | 3 +- src/SchemaUtils.elm | 32 ++++--- tests/Test/OpenApi/Generate.elm | 161 +++++++++++++++++++++++++++++++- 8 files changed, 224 insertions(+), 20 deletions(-) diff --git a/cli/src/Cli.elm b/cli/src/Cli.elm index 82f9e597..8243d79d 100644 --- a/cli/src/Cli.elm +++ b/cli/src/Cli.elm @@ -51,6 +51,7 @@ type alias CliOptions = , writeMergedTo : Maybe String , noElmFormat : Bool , keepGoing : Bool + , noEnumSort : Bool } @@ -186,6 +187,10 @@ program = (Cli.Option.flag "keep-going" |> Cli.Option.withDescription "If a route can't be generated, skip it instead of erroring out." ) + |> Cli.OptionsParser.with + (Cli.Option.flag "no-enum-sort" + |> Cli.Option.withDescription "Don't sort enum variants alphabetically, preserve the order from the spec." + ) ) @@ -388,6 +393,7 @@ parseCliOptions cliOptions = |> OpenApi.Config.withAutoConvertSwagger cliOptions.autoConvertSwagger |> OpenApi.Config.withNoElmFormat cliOptions.noElmFormat |> OpenApi.Config.withKeepGoing cliOptions.keepGoing + |> OpenApi.Config.withNoEnumSort cliOptions.noEnumSort |> maybe OpenApi.Config.withSwaggerConversionUrl cliOptions.swaggerConversionUrl |> maybe OpenApi.Config.withSwaggerConversionCommand (cliOptions.swaggerConversionCommand diff --git a/docs/USAGE.md b/docs/USAGE.md index 46fe3394..e0a79eb2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -25,7 +25,9 @@ - `[--effect-types ]`: A list of which kind of APIs to generate. Each item should be of the form `package.type`. If `package` is omitted it defaults to `elm/http`. If `type` is omitted it defaults to `cmd,task`. The options for package are: `elm/http`, `dillonkearns/elm-pages`, `lamdera/program-test`. The options for type are: `cmd`, `cmdrisky`, `cmdrecord`, `task`, `taskrisky`, `taskrecord`. - `[--server ]`: The base URL for the OpenAPI server. If not specified this will be extracted from the OAS or default to root of the web application. You can pass in an object to define multiple servers, like `{"dev": "http://localhost", "prod": "https://example.com"}`. This will add a `server` parameter to functions and define a `Servers` module with your servers. You can pass in an empty object if you have fully dynamic servers. - `[--no-elm-format]`: Don't run elm-format on the outputs. +- `[--no-enum-sort]`: Don't sort enum variants alphabetically, preserve the order from the spec. - `[--keep-going]`: If a route can't be generated, skip it instead of erroring out. + ## Example outputs: Assume we have an OAS file named `my-cool-company-oas.json` and it has a field `"title": "My Coool Company"` and we run the CLI like so diff --git a/src/CliMonad.elm b/src/CliMonad.elm index 8b95916a..d5d25a81 100644 --- a/src/CliMonad.elm +++ b/src/CliMonad.elm @@ -8,6 +8,7 @@ module CliMonad exposing , withPath, withWarning, withExtendedWarning, withRequiredPackage , todo, todoWithDefault , withFormat + , noEnumSort , nameToAnnotation, refToAnnotation, refToEncoder, refToDecoder ) @@ -22,6 +23,7 @@ module CliMonad exposing @docs withPath, withWarning, withExtendedWarning, withRequiredPackage @docs todo, todoWithDefault @docs withFormat +@docs noEnumSort ## Utils @@ -88,6 +90,7 @@ type alias Input = , formats : FastDict.Dict InternalFormatName InternalFormat , warnOnMissingEnums : Bool , keepGoing : Bool + , noEnumSort : Bool } @@ -130,6 +133,7 @@ run : , formats : List OpenApi.Config.Format , warnOnMissingEnums : Bool , keepGoing : Bool + , noEnumSort : Bool } -> CliMonad (List Declaration) -> @@ -153,6 +157,7 @@ run oneOfDeclarations input (CliMonad x) = |> FastDict.fromList , warnOnMissingEnums = input.warnOnMissingEnums , keepGoing = input.keepGoing + , noEnumSort = input.noEnumSort } res : Result Message ( List Declaration, Output, Cache ) @@ -575,6 +580,11 @@ getApiSpec = CliMonad (\input cache -> Ok ( input.openApi, emptyOutput, cache )) +noEnumSort : CliMonad Bool +noEnumSort = + CliMonad (\input cache -> Ok ( input.noEnumSort, emptyOutput, cache )) + + {-| If the user has chosen to keep going in the face of errors, this will convert an error into a warning. Otherwise this returns the input -} errorToWarning : CliMonad a -> CliMonad (Maybe a) @@ -664,7 +674,7 @@ enumName : List Common.UnsafeName -> CliMonad (Maybe Common.UnsafeName) enumName variants = CliMonad (\input cache -> - case FastDict.get (List.map Common.unwrapUnsafe variants) input.enums of + case FastDict.get (List.sort (List.map Common.unwrapUnsafe variants)) input.enums of Just { name } -> Ok ( Just name, emptyOutput, cache ) diff --git a/src/Common.elm b/src/Common.elm index 26d3c6cf..4eff3c34 100644 --- a/src/Common.elm +++ b/src/Common.elm @@ -584,7 +584,6 @@ unwrapUnsafe (UnsafeName name) = enum : ( String, List String ) -> Type enum variants = variants - |> NonEmpty.sort |> NonEmpty.map UnsafeName |> Enum diff --git a/src/OpenApi/Config.elm b/src/OpenApi/Config.elm index 2172d53b..19f0ce2f 100644 --- a/src/OpenApi/Config.elm +++ b/src/OpenApi/Config.elm @@ -1,9 +1,9 @@ module OpenApi.Config exposing ( Config, EffectType(..), effectTypeToPackage, Format, Input, Path(..), Server(..) , init, inputFrom, pathFromString - , withAutoConvertSwagger, AutoConvertSwagger(..), withEffectTypes, withFormat, withFormats, withGenerateTodos, withInput, withSwaggerConversionCommand, withSwaggerConversionUrl, withNoElmFormat, withKeepGoing + , withAutoConvertSwagger, AutoConvertSwagger(..), withEffectTypes, withFormat, withFormats, withGenerateTodos, withInput, withSwaggerConversionCommand, withSwaggerConversionUrl, withNoElmFormat, withKeepGoing, withNoEnumSort , withOutputModuleName, withOverrides, withServer, withWriteMergedTo, withWarnOnMissingEnums - , autoConvertSwagger, inputs, outputDirectory, swaggerConversionCommand, swaggerConversionUrl, noElmFormat, keepGoing + , autoConvertSwagger, inputs, outputDirectory, swaggerConversionCommand, swaggerConversionUrl, noElmFormat, keepGoing, noEnumSort , oasPath, overrides, writeMergedTo , toGenerationConfig, Generate, pathToString , defaultFormats @@ -20,13 +20,13 @@ module OpenApi.Config exposing # Creation @docs init, inputFrom, pathFromString -@docs withAutoConvertSwagger, AutoConvertSwagger, withEffectTypes, withFormat, withFormats, withGenerateTodos, withInput, withSwaggerConversionCommand, withSwaggerConversionUrl, withNoElmFormat, withKeepGoing +@docs withAutoConvertSwagger, AutoConvertSwagger, withEffectTypes, withFormat, withFormats, withGenerateTodos, withInput, withSwaggerConversionCommand, withSwaggerConversionUrl, withNoElmFormat, withKeepGoing, withNoEnumSort @docs withOutputModuleName, withOverrides, withServer, withWriteMergedTo, withWarnOnMissingEnums # Config properties -@docs autoConvertSwagger, inputs, outputDirectory, swaggerConversionCommand, swaggerConversionUrl, noElmFormat, keepGoing +@docs autoConvertSwagger, inputs, outputDirectory, swaggerConversionCommand, swaggerConversionUrl, noElmFormat, keepGoing, noEnumSort # Input properties @@ -84,6 +84,7 @@ type Config , dynamicFormats : List { format : String, basicType : Common.BasicType } -> List Format , noElmFormat : Bool , keepGoing : Bool + , noEnumSort : Bool } @@ -253,6 +254,7 @@ init initialOutputDirectory = , dynamicFormats = \_ -> [] , noElmFormat = False , keepGoing = False + , noEnumSort = False } |> Config @@ -575,6 +577,14 @@ withKeepGoing newKeepGoing (Config config) = Config { config | keepGoing = newKeepGoing } +{-| When `True`, enum variants are kept in the order they appear in the OpenAPI spec +instead of being sorted alphabetically. Defaults to `False`. +-} +withNoEnumSort : Bool -> Config -> Config +withNoEnumSort newNoEnumSort (Config config) = + Config { config | noEnumSort = newNoEnumSort } + + ------------- -- Getters -- @@ -623,6 +633,13 @@ keepGoing (Config config) = config.keepGoing +{-| Whether enum variants should be kept in spec-defined order instead of sorted alphabetically. +-} +noEnumSort : Config -> Bool +noEnumSort (Config config) = + config.noEnumSort + + {-| -} oasPath : Input -> Path oasPath (Input input) = @@ -656,6 +673,7 @@ type alias Generate = , formats : List Format , warnOnMissingEnums : Bool , keepGoing : Bool + , noEnumSort : Bool } @@ -690,6 +708,7 @@ toGenerationConfig formatsInput (Config config) augmentedInputs = , warnOnMissingEnums = input.warnOnMissingEnums , formats = config.staticFormats ++ config.dynamicFormats formatsInput , keepGoing = config.keepGoing + , noEnumSort = config.noEnumSort } , spec ) diff --git a/src/OpenApi/Generate.elm b/src/OpenApi/Generate.elm index f6dee69a..df7b0749 100644 --- a/src/OpenApi/Generate.elm +++ b/src/OpenApi/Generate.elm @@ -126,7 +126,7 @@ files : , warnings : List Message , requiredPackages : FastSet.Set String } -files { namespace, generateTodos, effectTypes, server, formats, warnOnMissingEnums, keepGoing } apiSpec = +files { namespace, generateTodos, effectTypes, server, formats, warnOnMissingEnums, keepGoing, noEnumSort } apiSpec = case extractEnums apiSpec of Err e -> Err e @@ -156,6 +156,7 @@ files { namespace, generateTodos, effectTypes, server, formats, warnOnMissingEnu , formats = formats , warnOnMissingEnums = warnOnMissingEnums , keepGoing = keepGoing + , noEnumSort = noEnumSort } |> Result.map (\{ declarations, warnings, requiredPackages } -> diff --git a/src/SchemaUtils.elm b/src/SchemaUtils.elm index a89ef217..4fc86e5c 100644 --- a/src/SchemaUtils.elm +++ b/src/SchemaUtils.elm @@ -185,6 +185,23 @@ schemaToType seen schema = } ) + enumType : NonEmpty String -> CliMonad { type_ : Common.Type, documentation : Maybe String } + enumType decodedEnums = + CliMonad.noEnumSort + |> CliMonad.map + (\noSort -> + { type_ = + Common.enum + (if noSort then + decodedEnums + + else + NonEmpty.sort decodedEnums + ) + , documentation = subSchema.description + } + ) + singleTypeToType : Json.Schema.Definitions.SingleType -> CliMonad { type_ : Common.Type, documentation : Maybe String } singleTypeToType singleType = let @@ -380,10 +397,7 @@ schemaToType seen schema = singleTypeToType Json.Schema.Definitions.StringType Ok (Just { decodedEnums, hasNull }) -> - CliMonad.succeed - { type_ = Common.enum decodedEnums - , documentation = subSchema.description - } + enumType decodedEnums |> (if hasNull then nullable @@ -466,10 +480,7 @@ schemaToType seen schema = CliMonad.succeed { type_ = Common.Value, documentation = subSchema.description } Ok (Just { decodedEnums, hasNull }) -> - CliMonad.succeed - { type_ = Common.enum decodedEnums - , documentation = subSchema.description - } + enumType decodedEnums |> (if hasNull then nullable @@ -486,10 +497,7 @@ schemaToType seen schema = nullable (singleTypeToType Json.Schema.Definitions.StringType) Ok (Just { decodedEnums }) -> - CliMonad.succeed - { type_ = Common.enum decodedEnums - , documentation = subSchema.description - } + enumType decodedEnums |> nullable Err e -> diff --git a/tests/Test/OpenApi/Generate.elm b/tests/Test/OpenApi/Generate.elm index 25e933c3..1f6ff691 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, noEnumSort, pr267, uuidArrayParam) import Ansi.Color import CliMonad @@ -130,6 +130,7 @@ fuzzTitle = , formats = OpenApi.Config.defaultFormats , warnOnMissingEnums = True , keepGoing = False + , noEnumSort = False } oas in @@ -259,6 +260,7 @@ pr267 = , formats = OpenApi.Config.defaultFormats , warnOnMissingEnums = True , keepGoing = False + , noEnumSort = False } oas in @@ -550,6 +552,7 @@ uuidArrayParam = , formats = OpenApi.Config.defaultFormats , warnOnMissingEnums = True , keepGoing = False + , noEnumSort = False } oas in @@ -584,6 +587,139 @@ uuidArrayParam = ) +noEnumSort : Test +noEnumSort = + Test.test "Enum variants preserve spec order when noEnumSort is True, and named enums still resolve in query params" <| + \() -> + let + oasString : String + oasString = + String.Multiline.here """ + openapi: "3.1.0" + info: + title: "Enum Order Test" + version: "1.0.0" + components: + schemas: + Fruit: + type: string + enum: + - cherry + - apple + - banana + paths: + /items: + get: + operationId: getItems + parameters: + - in: query + name: fruit + required: false + schema: + $ref: "#/components/schemas/Fruit" + - in: query + name: fruits + required: false + schema: + type: array + items: + $ref: "#/components/schemas/Fruit" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + count: + type: integer + required: + - count + """ + 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 + generate : + Bool + -> + 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 + } + generate noEnumSort_ = + OpenApi.Generate.files + { namespace = [ "Output" ] + , generateTodos = False + , effectTypes = [ OpenApi.Config.ElmHttpCmd ] + , server = OpenApi.Config.Default + , formats = OpenApi.Config.defaultFormats + , warnOnMissingEnums = True + , keepGoing = False + , noEnumSort = noEnumSort_ + } + oas + + moduleAsString : + List String + -> List { moduleName : List String, declarations : FastDict.Dict String { group : String, declaration : Elm.Declaration } } + -> String + moduleAsString name modules = + modules + |> List.filter (\m -> m.moduleName == name) + |> List.head + |> Maybe.map fileToString + |> Maybe.withDefault "" + in + case ( generate True, generate False ) of + ( Ok unsorted, Ok sorted ) -> + Expect.all + [ \_ -> + -- Unsorted: spec order cherry, apple, banana + moduleAsString [ "Output", "Types" ] unsorted.modules + |> expectContains "= Fruit__Cherry\n | Fruit__Apple\n | Fruit__Banana" + , \_ -> + -- Sorted: alphabetical apple, banana, cherry + moduleAsString [ "Output", "Types" ] sorted.modules + |> expectContains "= Fruit__Apple\n | Fruit__Banana\n | Fruit__Cherry" + , \_ -> + -- Named enum resolves in query params even when --no-enum-sort is set + moduleAsString [ "Output", "Api" ] unsorted.modules + |> expectOccurrenceCount 2 "Output.Types.fruitToString" + , \_ -> + -- And still resolves on the sorted path + moduleAsString [ "Output", "Api" ] sorted.modules + |> expectOccurrenceCount 2 "Output.Types.fruitToString" + ] + () + + ( Err e, _ ) -> + Expect.fail ("Error generating unsorted: " ++ Debug.toString e) + + ( _, Err e ) -> + Expect.fail ("Error generating sorted: " ++ Debug.toString e) + + yamlToJsonValueDecoder : Yaml.Decode.Decoder Json.Encode.Value yamlToJsonValueDecoder = Yaml.Decode.oneOf @@ -645,3 +781,26 @@ expectContains needle haystack = else Expect.fail ("Expected output to contain: " ++ needle) + + +expectOccurrenceCount : Int -> String -> String -> Expect.Expectation +expectOccurrenceCount expected needle haystack = + let + actual : Int + actual = + haystack + |> String.indexes needle + |> List.length + in + if actual == expected then + Expect.pass + + else + Expect.fail + ("Expected output to contain " + ++ String.fromInt expected + ++ " occurrences of " + ++ needle + ++ " but found " + ++ String.fromInt actual + )