Skip to content

Commit 391e01d

Browse files
committed
chore(housekeeping): Automate HouseKeeping API docs
- Add OpenApiSpex dependency for API documentation - Add API annotations for HouseKeeping API so API documentation can be autogenerated and exported to yaml using Signed-off-by: nedimtokic <nedim.tokic@secomind.com>
1 parent ee3fde3 commit 391e01d

File tree

9 files changed

+772
-8
lines changed

9 files changed

+772
-8
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[
2-
import_deps: [:phoenix, :ecto, :skogsra, :astarte_generators],
2+
import_deps: [:phoenix, :ecto, :skogsra, :astarte_generators, :open_api_spex],
33
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
44
]
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
#
2+
# This file is part of Astarte.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
19+
defmodule Astarte.HousekeepingWeb.ApiSpec do
20+
@moduledoc false
21+
@behaviour OpenApiSpex.OpenApi
22+
23+
alias OpenApiSpex.{
24+
Components,
25+
Contact,
26+
Info,
27+
MediaType,
28+
OpenApi,
29+
Paths,
30+
Reference,
31+
RequestBody,
32+
Response,
33+
Schema,
34+
SecurityScheme,
35+
Server,
36+
ServerVariable,
37+
Tag
38+
}
39+
40+
alias Astarte.HousekeepingWeb.ApiSpec.Schemas.Errors
41+
alias Astarte.HousekeepingWeb.ApiSpec.Schemas.Realm
42+
alias Astarte.HousekeepingWeb.Router
43+
44+
@impl OpenApiSpex.OpenApi
45+
def spec do
46+
%OpenApi{
47+
# Populate the Server info from a phoenix endpoint
48+
servers: [
49+
%Server{
50+
url: "{base_url}/v1",
51+
variables: %{
52+
base_url: %ServerVariable{
53+
default: "http://localhost:4001",
54+
description: """
55+
The base URL you're serving Astarte from. This should point to the base path from which Housekeeping is served.
56+
In case you are running a local installation, this is likely `http://localhost:4001`.
57+
In case you have a standard Astarte installation, it is most likely `https://<your host>/housekeeping`.
58+
"""
59+
}
60+
}
61+
}
62+
],
63+
info: %Info{
64+
title: to_string(Application.spec(:astarte_housekeeping, :description)),
65+
version: to_string(Application.spec(:astarte_housekeeping, :vsn)),
66+
description: """
67+
APIs for Administration activities such as Realm creation and Astarte configuration.
68+
This API is usually accessible only to system administrators, and is not meant for the average user of Astarte,
69+
which should refer to Realm Management API instead.
70+
""",
71+
contact: %Contact{email: "info@ispirata.com"}
72+
},
73+
# Populate the paths from a phoenix router
74+
paths: Router |> Paths.from_router() |> strip_path_prefix("/v1"),
75+
components: %Components{
76+
schemas: component_schemas(),
77+
requestBodies: component_request_bodies(),
78+
responses: component_responses(),
79+
securitySchemes: component_security_schemes()
80+
},
81+
tags: [
82+
%Tag{
83+
name: "realm",
84+
description: "APIs for managing Realms."
85+
}
86+
]
87+
}
88+
# Discover request/response schemas from path specs
89+
|> OpenApiSpex.resolve_schema_modules()
90+
|> drop_empty_callbacks()
91+
end
92+
93+
defp component_schemas do
94+
%{
95+
"Realm" => Realm.Realm.schema(),
96+
"RealmPatch" => Realm.RealmPatch.schema(),
97+
"GenericError" => Errors.GenericError.schema(),
98+
"MissingTokenError" => Errors.MissingTokenError.schema(),
99+
"InvalidTokenError" => Errors.InvalidTokenError.schema(),
100+
"InvalidAuthPathError" => Errors.InvalidAuthPathError.schema(),
101+
"AuthorizationPathNotMatchedError" => Errors.AuthorizationPathNotMatchedError.schema()
102+
}
103+
end
104+
105+
defp component_request_bodies do
106+
%{
107+
"createRealmBody" => create_realm_request_body(),
108+
"updateRealmBody" => update_realm_request_body()
109+
}
110+
end
111+
112+
defp create_realm_request_body do
113+
%RequestBody{
114+
content: %{
115+
"application/json" => %MediaType{
116+
schema: %Schema{
117+
properties: %{
118+
data: %Reference{"$ref": "#/components/schemas/Realm"}
119+
}
120+
}
121+
}
122+
},
123+
required: true,
124+
description: "Realm JSON configuration object."
125+
}
126+
end
127+
128+
defp update_realm_request_body do
129+
%RequestBody{
130+
content: %{
131+
"application/merge-patch+json" => %MediaType{
132+
schema: %Schema{
133+
type: :object,
134+
properties: %{
135+
data: %Reference{"$ref": "#/components/schemas/RealmPatch"}
136+
}
137+
}
138+
}
139+
},
140+
required: true,
141+
description:
142+
"A JSON Merge Patch containing the property changes that should be applied to the realm. Explicitly set a property to null to remove it."
143+
}
144+
end
145+
146+
defp component_responses do
147+
%{
148+
"Unauthorized" => unauthorized_response(),
149+
"AuthorizationPathNotMatched" => authorization_path_not_matched_response()
150+
}
151+
end
152+
153+
defp component_security_schemes do
154+
%{
155+
"JWT" => %SecurityScheme{
156+
type: "apiKey",
157+
name: "Authorization",
158+
in: "header",
159+
description: """
160+
To access APIs a valid JWT token must be passed in all requests
161+
in the `Authorization` header. This token should be signed with
162+
the private key provided upon Housekeeping installation.
163+
164+
165+
The following syntax must be used in the `Authorization` header :
166+
`Bearer xxxxxx.yyyyyyy.zzzzzz`
167+
"""
168+
}
169+
}
170+
end
171+
172+
defp unauthorized_response do
173+
%Response{
174+
description: "Token/Realm doesn't exist or operation not allowed.",
175+
content: %{
176+
"application/json" => %MediaType{
177+
schema: %Schema{
178+
oneOf: [
179+
Errors.MissingTokenError,
180+
Errors.InvalidTokenError,
181+
Errors.InvalidAuthPathError
182+
]
183+
}
184+
}
185+
}
186+
}
187+
end
188+
189+
defp authorization_path_not_matched_response do
190+
object_response("Authorization path not matched.", %{
191+
data: %Reference{"$ref": "#/components/schemas/AuthorizationPathNotMatchedError"}
192+
})
193+
end
194+
195+
defp object_response(description, properties) do
196+
schema_response(description, %Schema{type: :object, properties: properties})
197+
end
198+
199+
defp schema_response(description, schema) do
200+
%Response{
201+
description: description,
202+
content: %{
203+
"application/json" => %MediaType{schema: schema}
204+
}
205+
}
206+
end
207+
208+
defp strip_path_prefix(openapi_paths, prefix) do
209+
openapi_paths
210+
|> Enum.map(fn {path, path_item} ->
211+
path =
212+
path
213+
|> String.replace_prefix(prefix, "")
214+
215+
{path, path_item}
216+
end)
217+
|> Map.new()
218+
end
219+
220+
defp drop_empty_callbacks(%OpenApi{} = spec) do
221+
paths =
222+
spec.paths
223+
|> Enum.map(fn {path, path_item} ->
224+
{path, drop_empty_callbacks_from_path_item(path_item)}
225+
end)
226+
|> Map.new()
227+
228+
%{spec | paths: paths}
229+
end
230+
231+
defp drop_empty_callbacks_from_path_item(path_item) do
232+
path_item
233+
|> Map.from_struct()
234+
|> Enum.map(fn
235+
{method, %OpenApiSpex.Operation{callbacks: callbacks} = operation} when callbacks == %{} ->
236+
{method, %{operation | callbacks: nil}}
237+
238+
other ->
239+
other
240+
end)
241+
|> then(&struct(path_item.__struct__, &1))
242+
end
243+
end
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#
2+
# This file is part of Astarte.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
19+
defmodule Astarte.HousekeepingWeb.ApiSpec.Schemas.Errors do
20+
@moduledoc false
21+
22+
defmodule GenericError do
23+
@moduledoc false
24+
25+
require OpenApiSpex
26+
27+
alias OpenApiSpex.Schema
28+
29+
OpenApiSpex.schema(%{
30+
title: "GenericError",
31+
type: :object,
32+
properties: %{
33+
errors: %Schema{
34+
type: :object,
35+
properties: %{
36+
detail: %Schema{type: :string}
37+
}
38+
}
39+
}
40+
})
41+
end
42+
43+
defmodule MissingTokenError do
44+
@moduledoc false
45+
46+
require OpenApiSpex
47+
48+
alias OpenApiSpex.Schema
49+
50+
OpenApiSpex.schema(%{
51+
title: "MissingTokenError",
52+
type: :object,
53+
properties: %{
54+
errors: %Schema{
55+
type: :object,
56+
properties: %{
57+
detail: %Schema{type: :string, description: "Short error description"}
58+
}
59+
}
60+
},
61+
example: %{
62+
errors: %{detail: "Missing authorization token"}
63+
}
64+
})
65+
end
66+
67+
defmodule InvalidTokenError do
68+
@moduledoc false
69+
70+
require OpenApiSpex
71+
72+
alias OpenApiSpex.Schema
73+
74+
OpenApiSpex.schema(%{
75+
title: "InvalidTokenError",
76+
type: :object,
77+
properties: %{
78+
errors: %Schema{
79+
type: :object,
80+
properties: %{
81+
detail: %Schema{type: :string, description: "Short error description"}
82+
}
83+
}
84+
},
85+
example: %{
86+
errors: %{detail: "Invalid JWT token"}
87+
}
88+
})
89+
end
90+
91+
defmodule InvalidAuthPathError do
92+
@moduledoc false
93+
94+
require OpenApiSpex
95+
96+
alias OpenApiSpex.Schema
97+
98+
OpenApiSpex.schema(%{
99+
title: "InvalidAuthPathError",
100+
type: :object,
101+
properties: %{
102+
errors: %Schema{
103+
type: :object,
104+
properties: %{
105+
detail: %Schema{type: :string, description: "Short error description"}
106+
}
107+
}
108+
},
109+
example: %{
110+
errors: %{detail: "Authorization failed due to an invalid path"}
111+
}
112+
})
113+
end
114+
115+
defmodule AuthorizationPathNotMatchedError do
116+
@moduledoc false
117+
118+
require OpenApiSpex
119+
120+
alias OpenApiSpex.Schema
121+
122+
OpenApiSpex.schema(%{
123+
title: "AuthorizationPathNotMatchedError",
124+
type: :object,
125+
properties: %{
126+
errors: %Schema{
127+
type: :object,
128+
properties: %{
129+
detail: %Schema{
130+
type: :string,
131+
description: "Detailed error message including the method and path"
132+
}
133+
}
134+
}
135+
},
136+
example: %{
137+
errors: %{
138+
detail: "Unauthorized access to GET /api/v1/some_path. Please verify your permissions"
139+
}
140+
}
141+
})
142+
end
143+
end

0 commit comments

Comments
 (0)