Skip to content

Commit 5e8ae28

Browse files
authored
Support generating OpenAPI spec files with Mix tasks (#200)
* Chore: move OpenAPI spec generation from controller to OpenAPI module We strive to be able to generate the OpenAPI spec with Mix commands, and not only by querying an HTTP route. As a preliminar step for further developments, move the spec generation to the OpenAPI module in order to have a more reusable `spec` function instead of having it tied to the controller. Notes: - Even though the OpenAPI module is not strictly related to HTTP connections, the spec function accepts a `conn` argument since it was already needed and provided to the `modify_open_api` function, if the user specifies it in the opts. The `conn` argument is nil-able since we want to support spec generation from the CLI. - The OpenAPI title is now configurable with the `:open_api_title` opt. - The OpenAPI version is now configurable with the `:open_api_version` opt. - The OpenAPI server list was previously populated by getting the endpoint from a live `conn`. Since we want to support spec generation from the CLI and without access to an active connection, a new option `:phoenix_endpoint` is supported to statically provide a reference to the endpoint. Another option `:open_api_servers` is added if the user instead wants to specify a custom list of URLs. Signed-off-by: Davide Briani <davide@briani.dev> * Feat: add `spec/0` on Router to support OpenAPI generation via CLI If OpenApiSpex is present, the `spec/0` function is exposed on the Router. This allows a user who has already configured AshJsonApi ```elixir defmodule MyAppWeb.AshJsonApi use AshJsonApi.Router, domains: [...], open_api: "/open_api" end ``` to directly generate the OpenAPI spec by running one the Mix tasks from OpenApiSpex: ```sh mix openapi.spec.json --spec MyAppWeb.AshJsonApi mix openapi.spec.yaml --spec MyAppWeb.AshJsonApi ``` Signed-off-by: Davide Briani <davide@briani.dev> * Doc: describe how to customize OpenAPI spec and generate via CLI Add some details about the available options to customize the values of the OpenAPI spec. Add a section to describe how the spec files can be generated with Mix tasks. Signed-off-by: Davide Briani <davide@briani.dev> * Doc: remove reference to `title` opt for OpenApiSpex.Plug.SwaggerUI The option is not supported by OpenApiSpex.Plug.SwaggerUI and has no effect. To configure the title of the OpenAPI spec, a specific option has been added on AshJsonApi.Router. Signed-off-by: Davide Briani <davide@briani.dev> --------- Signed-off-by: Davide Briani <davide@briani.dev>
1 parent 228bfcb commit 5e8ae28

File tree

6 files changed

+128
-66
lines changed

6 files changed

+128
-66
lines changed

documentation/topics/open-api.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ Finally, you can use utilities provided by `open_api_spex` to show UIs for your
2020
forward "/api/swaggerui",
2121
OpenApiSpex.Plug.SwaggerUI,
2222
path: "/api/open_api",
23-
title: "Myapp's JSON-API - Swagger UI",
2423
default_model_expand_depth: 4
2524

2625
forward "/api/redoc",
@@ -54,7 +53,6 @@ forward "/api/swaggerui",
5453
to: OpenApiSpex.Plug.SwaggerUI,
5554
init_opts: [
5655
path: "/api/open_api",
57-
title: "Myapp's JSON-API - Swagger UI",
5856
default_model_expand_depth: 4
5957
]
6058

@@ -71,6 +69,28 @@ Now you can go to `/api/swaggerui` and `/api/redoc`!
7169

7270
## Customize values in the OpenAPI documentation
7371

72+
To customize the main values of the OpenAPI spec, a few options are available:
73+
74+
```elixir
75+
use AshJsonApi.Router,
76+
domains: [...],
77+
open_api: "/open_api",
78+
open_api_title: "Title",
79+
open_api_version: "1.0.0",
80+
open_api_servers: ["http://domain.com/api/v1"]
81+
```
82+
83+
If `:open_api_servers` is not specified, a default server is automatically derived from your app's Phoenix endpoint, as retrieved from inbound connections on the `open_api` HTTP route.
84+
85+
In case an active connection is not available, for example when generating the OpenAPI spec via CLI, you can explicitely specify a reference to the Phoenix endpoint:
86+
87+
```elixir
88+
use AshJsonApi.Router,
89+
domains: [...],
90+
open_api: "/open_api",
91+
phoenix_endpoint: MyAppWeb.Endpoint
92+
```
93+
7494
To override any value in the OpenApi documentation you can use the `:modify_open_api` options key:
7595

7696
```elixir
@@ -87,6 +107,35 @@ To override any value in the OpenApi documentation you can use the `:modify_open
87107
end
88108
```
89109

110+
## Generate spec files via CLI
111+
112+
You can write the OpenAPI spec file to disk using the Mix tasks provided by [OpenApiSpex](https://github.com/open-api-spex/open_api_spex).
113+
114+
Supposing you have setup AshJsonApi as:
115+
116+
```elixir
117+
defmodule MyAppWeb.AshJsonApi
118+
use AshJsonApi.Router, domains: [...], open_api: "/open_api"
119+
end
120+
```
121+
122+
you can generate the files with:
123+
124+
```sh
125+
mix openapi.spec.json --spec MyAppWeb.AshJsonApi
126+
mix openapi.spec.yaml --spec MyAppWeb.AshJsonApi
127+
```
128+
129+
To generate the YAML file you need to add the ymlr dependency.
130+
131+
```elixir
132+
def deps do
133+
[
134+
{:ymlr, "~> 2.0"}
135+
]
136+
end
137+
```
138+
90139
## Known issues/limitations
91140

92141
### Swagger UI
Lines changed: 4 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
if Code.ensure_loaded?(OpenApiSpex) do
22
defmodule AshJsonApi.Controllers.OpenApi do
3-
alias OpenApiSpex.{Info, OpenApi, SecurityScheme, Server}
4-
53
@moduledoc false
64
def init(options) do
75
options
@@ -19,60 +17,13 @@ if Code.ensure_loaded?(OpenApiSpex) do
1917
|> Plug.Conn.halt()
2018
end
2119

22-
defp modify(spec, conn, opts) do
23-
case opts[:modify] do
24-
modify when is_function(modify) ->
25-
modify.(spec, conn, opts)
26-
27-
{m, f, a} ->
28-
apply(m, f, [spec, conn, opts | a])
29-
30-
_ ->
31-
spec
32-
end
33-
end
34-
3520
@doc false
3621
def spec(conn, opts) do
37-
domains = List.wrap(opts[:domain] || opts[:domains])
38-
39-
servers =
40-
if conn.private[:phoenix_endpoint] do
41-
[
42-
Server.from_endpoint(conn.private.phoenix_endpoint)
43-
]
44-
else
45-
[]
46-
end
22+
phoenix_endpoint = opts[:phoenix_endpoint] || conn[:private][:phoenix_endpoint]
4723

48-
%OpenApi{
49-
info: %Info{
50-
title: "Open API Specification",
51-
version: "1.1"
52-
},
53-
servers: servers,
54-
paths: AshJsonApi.OpenApi.paths(domains, domains),
55-
tags: AshJsonApi.OpenApi.tags(domains),
56-
components: %{
57-
responses: AshJsonApi.OpenApi.responses(),
58-
schemas: AshJsonApi.OpenApi.schemas(domains),
59-
securitySchemes: %{
60-
"api_key" => %SecurityScheme{
61-
type: "apiKey",
62-
description: "API Key provided in the Authorization header",
63-
name: "api_key",
64-
in: "header"
65-
}
66-
}
67-
},
68-
security: [
69-
%{
70-
# API Key security applies to all operations
71-
"api_key" => []
72-
}
73-
]
74-
}
75-
|> modify(conn, opts)
24+
opts
25+
|> Keyword.put(:phoenix_endpoint, phoenix_endpoint)
26+
|> AshJsonApi.OpenApi.spec(conn)
7627
end
7728
end
7829
end

lib/ash_json_api/controllers/router.ex

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ defmodule AshJsonApi.Controllers.Router do
3737
AshJsonApi.Controllers.Schema.call(conn, domains: domains)
3838

3939
open_api_request?(conn, open_api) ->
40-
open_api_opts = open_api_opts(opts)
41-
42-
apply(AshJsonApi.Controllers.OpenApi, :call, [conn, open_api_opts])
40+
apply(AshJsonApi.Controllers.OpenApi, :call, [conn, opts])
4341

4442
conn.method == "GET" && Enum.any?(json_schema, &(&1 == conn.path_info)) ->
4543
AshJsonApi.Controllers.Schema.call(conn, opts)
@@ -89,12 +87,6 @@ defmodule AshJsonApi.Controllers.Router do
8987
end
9088
end
9189

92-
defp open_api_opts(opts) do
93-
opts
94-
|> Keyword.put(:modify, Keyword.get(opts, :modify_open_api))
95-
|> Keyword.delete(:modify_open_api)
96-
end
97-
9890
defp open_api_request?(conn, open_api) do
9991
AshJsonApi.OpenApiSpexChecker.has_open_api?() && conn.method == "GET" &&
10092
Enum.any?(open_api, &(&1 == conn.path_info))

lib/ash_json_api/json_schema/open_api.ex

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ if Code.ensure_loaded?(OpenApiSpex) do
3434
alias Ash.Resource.{Actions, Relationships}
3535

3636
alias OpenApiSpex.{
37+
Info,
3738
MediaType,
39+
OpenApi,
3840
Operation,
3941
Parameter,
4042
PathItem,
@@ -43,13 +45,75 @@ if Code.ensure_loaded?(OpenApiSpex) do
4345
RequestBody,
4446
Response,
4547
Schema,
48+
SecurityScheme,
49+
Server,
4650
Tag
4751
}
4852

4953
@dialyzer {:nowarn_function, {:action_description, 3}}
5054
@dialyzer {:nowarn_function, {:relationship_resource_identifiers, 1}}
5155
@dialyzer {:nowarn_function, {:resource_object_schema, 1}}
5256

57+
def spec(opts \\ [], conn \\ nil) do
58+
domains = List.wrap(opts[:domain] || opts[:domains])
59+
title = opts[:open_api_title] || "Open API Specification"
60+
version = opts[:open_api_version] || "1.1"
61+
62+
servers =
63+
cond do
64+
is_list(opts[:open_api_servers]) ->
65+
Enum.map(opts[:open_api_servers], &%OpenApiSpex.Server{url: &1})
66+
67+
opts[:phoenix_endpoint] != nil ->
68+
[Server.from_endpoint(opts[:phoenix_endpoint])]
69+
70+
true ->
71+
[]
72+
end
73+
74+
%OpenApi{
75+
info: %Info{
76+
title: title,
77+
version: version
78+
},
79+
servers: servers,
80+
paths: AshJsonApi.OpenApi.paths(domains, domains),
81+
tags: AshJsonApi.OpenApi.tags(domains),
82+
components: %{
83+
responses: AshJsonApi.OpenApi.responses(),
84+
schemas: AshJsonApi.OpenApi.schemas(domains),
85+
securitySchemes: %{
86+
"api_key" => %SecurityScheme{
87+
type: "apiKey",
88+
description: "API Key provided in the Authorization header",
89+
name: "api_key",
90+
in: "header"
91+
}
92+
}
93+
},
94+
security: [
95+
%{
96+
# API Key security applies to all operations
97+
"api_key" => []
98+
}
99+
]
100+
}
101+
|> modify(conn, opts)
102+
end
103+
104+
defp modify(spec, conn, opts) do
105+
case opts[:modify_open_api] do
106+
modify when is_function(modify) ->
107+
modify.(spec, conn, opts)
108+
109+
{m, f, a} ->
110+
apply(m, f, [spec, conn, opts | a])
111+
112+
_ ->
113+
spec
114+
end
115+
end
116+
53117
@doc """
54118
Common responses to include in the API Spec.
55119
"""

lib/ash_json_api/router.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ defmodule AshJsonApi.Router do
4343
end
4444

4545
match(_, to: AshJsonApi.Controllers.Router, init_opts: Keyword.put(opts, :domains, domains))
46+
47+
if Code.ensure_loaded?(OpenApiSpex) do
48+
def spec do
49+
AshJsonApi.OpenApi.spec(unquote(opts))
50+
end
51+
end
4652
end
4753
end
4854
end

test/acceptance/open_api_test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,15 +239,15 @@ defmodule Test.Acceptance.OpenApiTest do
239239
end
240240
end
241241

242-
def modify(spec, _conn, _opts) do
242+
def modify_open_api(spec, _conn, _opts) do
243243
%{spec | info: %{spec.info | title: "foobar"}}
244244
end
245245

246246
setup do
247247
api_spec =
248248
AshJsonApi.Controllers.OpenApi.spec(%{private: %{}},
249249
domains: [Blogs],
250-
modify: {__MODULE__, :modify, []}
250+
modify_open_api: {__MODULE__, :modify_open_api, []}
251251
)
252252

253253
%{open_api_spec: api_spec}

0 commit comments

Comments
 (0)