Skip to content

Commit 4ad9b6c

Browse files
committed
feat: custom header version handler
1 parent 6b94745 commit 4ad9b6c

7 files changed

Lines changed: 644 additions & 74 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ node_modules
99

1010
# Other
1111
.direnv
12+
.devenv
1213
dist
1314
generated
1415
.elm-pages

cli/src/Cli.elm

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import FatalError exposing (FatalError)
2121
import Json.Decode
2222
import Json.Encode
2323
import Json.Value
24+
import List.Extra
2425
import OpenApi
2526
import OpenApi.Common.Internal
2627
import OpenApi.Config
@@ -40,6 +41,7 @@ type alias CliOptions =
4041
{ entryFilePath : OpenApi.Config.Path
4142
, outputDirectory : String
4243
, outputModuleName : Maybe String
44+
, responseVersionHeader : Maybe String
4345
, effectTypes : List OpenApi.Config.EffectType
4446
, server : OpenApi.Config.Server
4547
, autoConvertSwagger : OpenApi.Config.AutoConvertSwagger
@@ -72,6 +74,17 @@ program =
7274
(Cli.Option.optionalKeywordArg "module-name"
7375
|> Cli.Option.withDescription "The Elm module name. Defaults to `OAS info.title`."
7476
)
77+
|> Cli.OptionsParser.with
78+
(Cli.Option.optionalKeywordArg "response-version-header"
79+
|> Cli.Option.withDescription
80+
([ "Expose a response version header through `OpenApi.Common.responseVersionFromError`."
81+
, "If passed without a value, defaults to `OpenAPI-Hash`."
82+
, "Your application can forward that value to JavaScript or a port."
83+
, "Comparison or reload behavior is handled by your app."
84+
]
85+
|> formatOptionDescription
86+
)
87+
)
7588
|> Cli.OptionsParser.with
7689
(Cli.Option.optionalKeywordArg "effect-types"
7790
|> Cli.Option.validateMap effectTypesValidation
@@ -96,6 +109,9 @@ program =
96109
, " - taskrisky: as above, but using Http.riskyTask"
97110
, " cannot be used for dillonkearns/elm-pages"
98111
, " - taskrecord: the input to Http.task"
112+
, ""
113+
, "Response version extraction is not supported for"
114+
, "dillonkearns/elm-pages effect types."
99115
]
100116
|> formatOptionDescription
101117
)
@@ -337,6 +353,19 @@ serverValidation server =
337353
Ok <| OpenApi.Config.Single input
338354

339355

356+
responseVersionHeaderValidation : Maybe String -> Maybe OpenApi.Config.ResponseVersionCheck
357+
responseVersionHeaderValidation maybeHeader =
358+
case maybeHeader of
359+
Nothing ->
360+
Nothing
361+
362+
Just "" ->
363+
Just { headerName = "OpenAPI-Hash" }
364+
365+
Just headerName ->
366+
Just { headerName = headerName }
367+
368+
340369
run : Pages.Script.Script
341370
run =
342371
Pages.Script.withCliOptions program
@@ -380,6 +409,8 @@ parseCliOptions cliOptions =
380409
|> OpenApi.Config.withOverrides cliOptions.overrides
381410
|> maybe OpenApi.Config.withWriteMergedTo cliOptions.writeMergedTo
382411
|> maybe OpenApi.Config.withOutputModuleName (Maybe.map (String.split ".") cliOptions.outputModuleName)
412+
|> maybe OpenApi.Config.withResponseVersionCheck
413+
(responseVersionHeaderValidation cliOptions.responseVersionHeader)
383414
|> iif (not (List.isEmpty cliOptions.effectTypes)) (OpenApi.Config.withEffectTypes cliOptions.effectTypes)
384415
in
385416
OpenApi.Config.init cliOptions.outputDirectory
@@ -517,7 +548,7 @@ withConfig config =
517548
|> withStep "Generate Elm modules"
518549
(\( (), apiSpecs, allFormats ) ->
519550
OpenApi.Config.toGenerationConfig (List.concat allFormats) config apiSpecs
520-
|> generateFilesFromOpenApiSpecs
551+
|> generateFilesFromOpenApiSpecs (OpenApi.Config.inputs config)
521552
)
522553
|> (if OpenApi.Config.noElmFormat config then
523554
identity
@@ -1060,7 +1091,39 @@ width =
10601091

10611092

10621093
generateFilesFromOpenApiSpecs :
1063-
List ( OpenApi.Config.Generate, OpenApi.OpenApi )
1094+
List OpenApi.Config.Input
1095+
-> List ( OpenApi.Config.Generate, OpenApi.OpenApi )
1096+
->
1097+
BackendTask.BackendTask
1098+
FatalError.FatalError
1099+
( List Elm.File
1100+
, { warnings : List OpenApi.Generate.Message
1101+
, requiredPackages : FastSet.Set String
1102+
}
1103+
)
1104+
generateFilesFromOpenApiSpecs inputConfigs configs =
1105+
let
1106+
responseVersionChecks : List OpenApi.Config.ResponseVersionCheck
1107+
responseVersionChecks =
1108+
inputConfigs
1109+
|> List.filterMap OpenApi.Config.responseVersionCheck
1110+
|> List.Extra.uniqueBy .headerName
1111+
in
1112+
case responseVersionChecks of
1113+
[] ->
1114+
generateFilesFromOpenApiSpecsWithResponseVersionCheck Nothing configs
1115+
1116+
[ responseVersionCheck ] ->
1117+
generateFilesFromOpenApiSpecsWithResponseVersionCheck (Just responseVersionCheck) configs
1118+
1119+
_ ->
1120+
BackendTask.fail
1121+
(FatalError.fromString "All generated inputs must use the same response-version-header configuration.")
1122+
1123+
1124+
generateFilesFromOpenApiSpecsWithResponseVersionCheck :
1125+
Maybe OpenApi.Config.ResponseVersionCheck
1126+
-> List ( OpenApi.Config.Generate, OpenApi.OpenApi )
10641127
->
10651128
BackendTask.BackendTask
10661129
FatalError.FatalError
@@ -1069,11 +1132,12 @@ generateFilesFromOpenApiSpecs :
10691132
, requiredPackages : FastSet.Set String
10701133
}
10711134
)
1072-
generateFilesFromOpenApiSpecs configs =
1135+
generateFilesFromOpenApiSpecsWithResponseVersionCheck responseVersionCheck configs =
10731136
configs
10741137
|> BackendTask.Extra.combineMap
10751138
(\( config, apiSpec ) ->
1076-
OpenApi.Generate.files config apiSpec
1139+
OpenApi.Generate.validateResponseVersionSupport responseVersionCheck config
1140+
|> Result.andThen (\_ -> OpenApi.Generate.files config apiSpec)
10771141
|> Result.mapError
10781142
(\err ->
10791143
err
@@ -1143,6 +1207,7 @@ generateFilesFromOpenApiSpecs configs =
11431207
OpenApi.Common.Internal.declarations
11441208
{ effectTypes = allEffectTypes
11451209
, requiresBase64 = List.any (\{ requiredPackages } -> FastSet.member Common.base64PackageName requiredPackages) result
1210+
, responseVersionCheck = responseVersionCheck
11461211
}
11471212
in
11481213
fileFromGroups moduleName (commonDeclarations ++ deduplicatedDeclarations) :: acc

docs/USAGE.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
- Technically the OAS allows for parts of a spec to be in separate files, but this isn't supported yet.
1515
- `[--output-dir <output dir>]`: The directory to output to. Defaults to `generated/`.
1616
- `[--module-name <module name>]`: The Elm module name. Default to `<OAS info.title>`.
17+
- `[--response-version-header <header name>]`: Expose a response version header through `OpenApi.Common.responseVersionFromError`.
18+
- If passed without a value, defaults to `OpenAPI-Hash`.
19+
- This lets your app forward the server's response version to JavaScript or a port.
20+
- Comparison, storage, and reload behavior stay in your application code.
1721
- `[--auto-convert-swagger]`: If passed in, and a Swagger doc is encountered, will attempt to convert it to an Open API file. If not passed in, and a Swagger doc is encountered, the user will be manually prompted to convert.
1822
- `[--swagger-conversion-url]`: The URL to use to convert a Swagger doc to an Open API file. Defaults to `https://converter.swagger.io/api/convert`.
1923
- `[--swagger-conversion-command]`: Instead of making an HTTP request to convert from Swagger to Open API, use this command.
@@ -22,6 +26,189 @@
2226
- `[--overrides <file path>]`: Load an additional file to override parts of the original Open API file.
2327
- This is most commonly used for malformed OAS files (e.g. missing `required` on a required field) but can be used for anything you want
2428
- `[--write-merged-to <file path>]`: Write the merged Open API spec to the given file (see `--overrides` for merging).
29+
30+
## Working with response versions
31+
32+
If your backend includes a response header like `OpenAPI-Hash`, you can ask the generator to expose that header through generated `OpenApi.Common` helpers:
33+
34+
```sh
35+
npx elm-open-api ./my-cool-company-oas.json --response-version-header OpenAPI-Hash
36+
```
37+
38+
When enabled, the generated `OpenApi.Common` module includes:
39+
40+
- `type alias ResponseVersion = { headerName : String, responseVersion : String }`
41+
- `responseVersionFromError : Error err body -> Maybe ResponseVersion`
42+
43+
This helper extracts the configured response version header from generated API errors that include response metadata, such as `BadBody`, `BadErrorBody`, and `UnknownBadStatus`.
44+
45+
Use this when you want your application to observe the response version itself, typically by forwarding the observed value to JavaScript, a port, or shared app state.
46+
47+
### Generic app setup
48+
49+
1. Enable the feature with `--response-version-header` or just `--response-version-header` to use the default `OpenAPI-Hash`.
50+
2. Route generated API errors through one shared error handler.
51+
3. In that shared handler, call `OpenApi.Common.responseVersionFromError`.
52+
4. When it returns `Just responseVersion`, forward that value to JavaScript, a port, or shared app state.
53+
5. Handle that forwarded value however your application needs.
54+
55+
Example shared handling:
56+
57+
```elm
58+
case result of
59+
Err err ->
60+
case OpenApi.Common.responseVersionFromError err of
61+
Just responseVersion ->
62+
forwardObservedResponseVersion err responseVersion
63+
64+
Nothing ->
65+
handleNormalApiError err
66+
67+
Ok value ->
68+
handleSuccess value
69+
```
70+
71+
Notes:
72+
73+
- This only exposes response versions from generated API errors that include response metadata.
74+
- If you need to inspect the header on every successful response too, use a JavaScript interceptor instead.
75+
- The generated SDK only exposes the observed response version. Any follow-up behavior is app-owned.
76+
- For cross-origin APIs, your backend must expose the response version header through CORS.
77+
78+
### Elm Land
79+
80+
Elm Land is a good fit for this feature when you already use a customized `Effect.elm` and `src/interop.js` for JavaScript interop.
81+
82+
Use this flow when:
83+
84+
- you want one shared application-level place to observe response versions from generated API errors
85+
- you want JavaScript or ports to receive that observed value
86+
- you do not want to thread extra config through every generated API call
87+
88+
#### 1. Enable the generator flag
89+
90+
```sh
91+
npx elm-open-api ./my-cool-company-oas.json --response-version-header OpenAPI-Hash
92+
```
93+
94+
#### 2. Customize `Effect.elm`
95+
96+
If you have not already customized it:
97+
98+
```sh
99+
elm-land customize effect
100+
```
101+
102+
Then add an effect that forwards the observed response version to JavaScript.
103+
104+
Example `src/Effect.elm`:
105+
106+
```elm
107+
port module Effect exposing
108+
( Effect
109+
, none
110+
, map
111+
, toCmd
112+
, reportResponseVersion
113+
)
114+
115+
import Json.Encode
116+
117+
type Effect msg
118+
= None
119+
| SendMessageToJavaScript
120+
{ tag : String
121+
, data : Json.Encode.Value
122+
}
123+
124+
port outgoing : { tag : String, data : Json.Encode.Value } -> Cmd msg
125+
126+
none : Effect msg
127+
none =
128+
None
129+
130+
reportResponseVersion :
131+
{ headerName : String, responseVersion : String }
132+
-> Effect msg
133+
reportResponseVersion payload =
134+
SendMessageToJavaScript
135+
{ tag = "REPORT_RESPONSE_VERSION"
136+
, data =
137+
Json.Encode.object
138+
[ ( "headerName", Json.Encode.string payload.headerName )
139+
, ( "responseVersion", Json.Encode.string payload.responseVersion )
140+
]
141+
}
142+
143+
map : (msg1 -> msg2) -> Effect msg1 -> Effect msg2
144+
map _ effect =
145+
case effect of
146+
None ->
147+
None
148+
149+
SendMessageToJavaScript message ->
150+
SendMessageToJavaScript message
151+
152+
toCmd : Effect msg -> Cmd msg
153+
toCmd effect =
154+
case effect of
155+
None ->
156+
Cmd.none
157+
158+
SendMessageToJavaScript message ->
159+
outgoing message
160+
```
161+
162+
#### 3. Handle generated API errors in one shared place
163+
164+
Use `OpenApi.Common.responseVersionFromError` inside your shared request result handler:
165+
166+
```elm
167+
case result of
168+
Err err ->
169+
case OpenApi.Common.responseVersionFromError err of
170+
Just responseVersion ->
171+
( model
172+
, Effect.reportResponseVersion
173+
{ headerName = responseVersion.headerName
174+
, responseVersion = responseVersion.responseVersion
175+
}
176+
)
177+
178+
Nothing ->
179+
handleNormalApiError model err
180+
181+
Ok value ->
182+
handleSuccess model value
183+
```
184+
185+
This becomes app-global when all generated API errors are funneled through one shared handler. It is not an implicit interceptor.
186+
187+
#### 4. Receive the forwarded version in `src/interop.js`
188+
189+
The generator stops at `OpenApi.Common.responseVersionFromError`. The app decides what, if anything, should happen next.
190+
191+
Example `src/interop.js`:
192+
193+
```js
194+
export const onReady = ({ app }) => {
195+
if (!app.ports || !app.ports.outgoing) return
196+
197+
app.ports.outgoing.subscribe(({ tag, data }) => {
198+
if (tag !== 'REPORT_RESPONSE_VERSION') return
199+
200+
handleObservedResponseVersion(data)
201+
})
202+
}
203+
```
204+
205+
Here `handleObservedResponseVersion` is application code. It might store the value, compare it, log it, or ignore it. That behavior is not generated by `elm-open-api`.
206+
207+
#### 5. Important caveats
208+
209+
- This flow reacts to generated API errors with response metadata, not every successful response.
210+
- If your users frequently keep tabs open for a long time and you need detection on every response, prefer a JavaScript interceptor.
211+
- For cross-origin APIs, make sure the response version header is exposed to the browser via CORS.
25212
## Example outputs:
26213

27214
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

0 commit comments

Comments
 (0)