Skip to content

Commit c77def9

Browse files
authored
chore(pairing): Automate pairing API docs (#1925)
- Add OpenApiSpex dependency for API documentation - Add API annotations for Pairing API so API documentation can be autogenerated and exported to yaml using `mix openapi.spec.yaml --spec Astarte.PairingWeb.ApiSpec --start-app=false --vendor-extensions=false` Signed-off-by: nedimtokic <nedim.tokic@secomind.com>
1 parent 9ae525a commit c77def9

File tree

10 files changed

+1107
-5
lines changed

10 files changed

+1107
-5
lines changed

apps/astarte_pairing/.formatter.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
:ecto,
55
:phoenix,
66
:skogsra,
7-
:astarte_generators
7+
:astarte_generators,
8+
:open_api_spex
89
]
910
]
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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.PairingWeb.ApiSpec do
20+
@moduledoc false
21+
@behaviour OpenApiSpex.OpenApi
22+
23+
alias OpenApiSpex.{
24+
Components,
25+
Contact,
26+
ExternalDocumentation,
27+
Info,
28+
MediaType,
29+
OpenApi,
30+
Paths,
31+
Response,
32+
Schema,
33+
SecurityScheme,
34+
Server,
35+
ServerVariable,
36+
Tag
37+
}
38+
39+
alias Astarte.PairingWeb.ApiSpec.Schemas.Errors
40+
alias Astarte.PairingWeb.Router
41+
42+
@impl OpenApiSpex.OpenApi
43+
def spec do
44+
%OpenApi{
45+
# Populate the Server info from a phoenix endpoint
46+
servers: [
47+
%Server{
48+
url: "{base_url}/v1",
49+
variables: %{
50+
base_url: %ServerVariable{
51+
default: "http://localhost:4003",
52+
description: """
53+
The base URL you're serving Astarte from. This should point to the base path from which Pairing API is served.
54+
In case you are running a local installation, this is likely `http://localhost:4003`.
55+
In case you have a standard Astarte installation, it is most likely `https://<your host>/pairing`.
56+
"""
57+
}
58+
}
59+
}
60+
],
61+
info: %Info{
62+
title: to_string(Application.spec(:astarte_pairing, :description)),
63+
version: to_string(Application.spec(:astarte_pairing, :vsn)),
64+
description: "Control device registration, authentication and authorization",
65+
contact: %Contact{email: "info@ispirata.com"}
66+
},
67+
# Populate the paths from a phoenix router
68+
paths: Router |> Paths.from_router() |> strip_path_prefix("/v1"),
69+
components: %Components{
70+
schemas: %{
71+
"MissingTokenResponse" => Errors.MissingTokenResponse.schema(),
72+
"InvalidTokenResponse" => Errors.InvalidTokenResponse.schema(),
73+
"InvalidAuthPathResponse" => Errors.InvalidAuthPathResponse.schema(),
74+
"UnauthorizedResponse" => Errors.UnauthorizedResponse.schema(),
75+
"AuthorizationPathNotMatchedResponse" =>
76+
Errors.AuthorizationPathNotMatchedResponse.schema()
77+
},
78+
responses: %{
79+
"Unauthorized" => %Response{
80+
description: "Token/Realm doesn't exist or operation not allowed.",
81+
content: %{
82+
"application/json" => %MediaType{
83+
schema: %Schema{
84+
oneOf: [
85+
Errors.MissingTokenResponse,
86+
Errors.InvalidTokenResponse,
87+
Errors.InvalidAuthPathResponse,
88+
Errors.UnauthorizedResponse
89+
]
90+
}
91+
}
92+
}
93+
},
94+
"AuthorizationPathNotMatched" => %Response{
95+
description: "Authorization path not matched.",
96+
content: %{
97+
"application/json" => %MediaType{
98+
schema: %Schema{
99+
type: :object,
100+
properties: %{
101+
data: Errors.AuthorizationPathNotMatchedResponse
102+
}
103+
}
104+
}
105+
}
106+
}
107+
},
108+
securitySchemes: %{
109+
"JWT" => %SecurityScheme{
110+
type: "apiKey",
111+
name: "Authorization",
112+
in: "header",
113+
description: """
114+
For accessing the agent API a valid JWT token must be passed in all the queries in the 'Authorization' header.
115+
The following syntax must be used in the 'Authorization' header : Bearer xxxxxx.yyyyyyy.zzzzzz
116+
"""
117+
},
118+
"CredentialsSecret" => %SecurityScheme{
119+
type: "apiKey",
120+
name: "Authorization",
121+
in: "header",
122+
description: """
123+
For accessing the device API a valid Credentials Secret must be passed in all the queries in the 'Authorization' header.
124+
The following syntax must be used in the 'Authorization' header : Bearer xxxxxxxxxxxxxxxxxxxxx
125+
"""
126+
}
127+
}
128+
},
129+
tags: [
130+
%Tag{
131+
name: "agent",
132+
description: "Device registration and credentials secret emission",
133+
externalDocs: %ExternalDocumentation{
134+
description: "Find out more",
135+
url: "https://docs.astarte-platform.org/astarte/1.0/050-pairing_mechanism.html"
136+
}
137+
},
138+
%Tag{
139+
name: "device",
140+
description: "Device credentials emission and info",
141+
externalDocs: %ExternalDocumentation{
142+
description: "Find out more",
143+
url: "https://docs.astarte-platform.org/astarte/1.0/050-pairing_mechanism.html"
144+
}
145+
}
146+
]
147+
}
148+
# Discover request/response schemas from path specs
149+
|> OpenApiSpex.resolve_schema_modules()
150+
|> drop_empty_callbacks()
151+
end
152+
153+
defp strip_path_prefix(openapi_paths, prefix) do
154+
openapi_paths
155+
|> Enum.map(fn {path, path_item} ->
156+
{String.replace_prefix(path, prefix, ""), path_item}
157+
end)
158+
|> Map.new()
159+
end
160+
161+
defp drop_empty_callbacks(%OpenApi{} = spec) do
162+
paths =
163+
spec.paths
164+
|> Enum.map(fn {path, path_item} ->
165+
{path, drop_empty_callbacks_from_path_item(path_item)}
166+
end)
167+
|> Map.new()
168+
169+
%{spec | paths: paths}
170+
end
171+
172+
defp drop_empty_callbacks_from_path_item(path_item) do
173+
path_item
174+
|> Map.from_struct()
175+
|> Enum.map(fn
176+
{method, %OpenApiSpex.Operation{callbacks: callbacks} = operation} when callbacks == %{} ->
177+
{method, %{operation | callbacks: nil}}
178+
179+
other ->
180+
other
181+
end)
182+
|> then(&struct(path_item.__struct__, &1))
183+
end
184+
end
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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.PairingWeb.ApiSpec.Schemas.Agent do
20+
@moduledoc false
21+
alias OpenApiSpex.Schema
22+
23+
defmodule IntrospectionEntry do
24+
@moduledoc false
25+
require OpenApiSpex
26+
27+
OpenApiSpex.schema(%{
28+
title: "IntrospectionEntry",
29+
type: :object,
30+
properties: %{
31+
major: %Schema{
32+
type: :integer,
33+
minimum: 0,
34+
description: "The major version of the interface"
35+
},
36+
minor: %Schema{
37+
type: :integer,
38+
minimum: 0,
39+
description: "The minor version of the interface"
40+
}
41+
},
42+
required: [:major, :minor]
43+
})
44+
end
45+
46+
defmodule DeviceRegistrationRequest do
47+
@moduledoc false
48+
require OpenApiSpex
49+
50+
OpenApiSpex.schema(%{
51+
title: "DeviceRegistrationRequest",
52+
type: :object,
53+
properties: %{
54+
data: %Schema{
55+
type: :object,
56+
properties: %{
57+
hw_id: %Schema{
58+
type: :string
59+
},
60+
initial_introspection: %Schema{
61+
type: :object,
62+
description: """
63+
An optional object specifying the initial introspection for the device. The keys
64+
of the object are the interface names, while the values are objects with the "major"
65+
and "minor" properties, specifying the major and minor version of the interface
66+
that is going to be supported by the device.
67+
""",
68+
additionalProperties: IntrospectionEntry
69+
}
70+
},
71+
required: [:hw_id]
72+
}
73+
},
74+
example: %{
75+
data: %{
76+
hw_id: "YHjKs3SMTgqq09eD7fzm6w",
77+
initial_introspection: %{
78+
"org.astarte-platform.genericsensors.Values" => %{
79+
major: 1,
80+
minor: 0
81+
},
82+
"org.astarte-platform.genericsensors.AvailableSensors" => %{
83+
major: 0,
84+
minor: 1
85+
}
86+
}
87+
}
88+
},
89+
required: [:data]
90+
})
91+
end
92+
93+
defmodule DeviceRegistrationResponse do
94+
@moduledoc false
95+
require OpenApiSpex
96+
97+
OpenApiSpex.schema(%{
98+
title: "DeviceRegistrationResponse",
99+
type: :object,
100+
properties: %{
101+
credentials_secret: %Schema{
102+
type: :string
103+
}
104+
},
105+
example: %{
106+
credentials_secret: "TTkd5OgB13X/3qU0LXU7OCxyTXz5QHM2NY1IgidtPOs="
107+
}
108+
})
109+
end
110+
end

0 commit comments

Comments
 (0)