Skip to content

Commit 4fe8bc5

Browse files
committed
WIP: Add OpenAPI tutorial
Signed-off-by: jld3103 <jld3103yt@gmail.com>
1 parent 36e797e commit 4fe8bc5

3 files changed

Lines changed: 331 additions & 0 deletions

File tree

developer_manual/basics/controllers.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,7 @@ The following policy for instance allows images, audio and videos from other dom
717717
718718
}
719719
720+
.. _ocscontroller:
720721

721722
OCS
722723
^^^

developer_manual/client_apis/OCS/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The old documentation is still kept as it provides some additional documentation
1010

1111
.. toctree::
1212

13+
ocs-openapi
1314
ocs-api-overview
1415
ocs-share-api
1516
ocs-sharee-api
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
===================
2+
OCS OpenAPI support
3+
===================
4+
5+
This page explains you how you can add OpenAPI support to your app such that you can automatically generated an OpenAPI specification for your server code.
6+
7+
Please read the whole tutorial before starting to adjust your app.
8+
9+
Don't be afraid that you don't know everything from the start.
10+
The openapi-extractor tool gives you many warnings and fails if there is something utterly broken that would not work.
11+
Just let it run, it will tell you if there is something wrong.
12+
Psalm will also help you validate your changes.
13+
14+
Requirements and prerequisites
15+
------------------------------
16+
17+
- Psalm has been setup for the app
18+
- The app supports at least Nextcloud 28
19+
- Your APIs are exposed via OCS (see :ref:`OCS <ocscontroller>`). It is possible to also expose APIs in different ways, but those will not be explained here because they are considered legacy.
20+
21+
Best practices
22+
--------------
23+
24+
- Be as explicit and concrete as possible about types. The closer you narrow down a type without violating any constraints the better the resulting specification will be.
25+
- For empty data you should always use `null` if possible. If your API is well designed it should always work. If for some reason you can not do that, you should use `new \stdClass()` and not `[]`. The problem with `[]` in PHP is that will be converted into `[]` in JSON, but you want to have `{}` in JSON if the data you return is empty. For empty arrays that should be arrays it is not a problem.
26+
- Do not throw exceptions, return valid responses with an error message instead.
27+
- Do not use the `addHeader` method for setting headers for your responses. Right now it is not possible for psalm to trace headers you set this way, so they will not be validated by psalm.
28+
- Try to think about what is best for an API consumer, not what is the easiest way to make it work.
29+
- Always set all descriptions for parameters and methods. It improves the documentation and makes it easier to understand what your API does.
30+
- Set descriptions for Controllers. Those are also included in the specification. There you can explain what the APIs that are in a given controller do or even give examples an how to connect multiple API endpoints together.
31+
32+
How to add OpenAPI support to your OCS API
33+
------------------------------------------
34+
35+
Let's imagine you built a Todo list app for Nextcloud and have this controller:
36+
37+
.. code-block:: php
38+
39+
class TodoApiController extends OCSController {
40+
#[NoAdminRequired]
41+
public function create(string $title, string $description = "", string $image = ""): DataResponse {
42+
$todo = $this->service->createTodo($title, $description, $image);
43+
44+
return $this->formatTodo($todo);
45+
}
46+
47+
#[NoAdminRequired]
48+
public function get(int $id): DataResponse {
49+
try {
50+
$todo = $this->service->getTodo($id);
51+
} catch (NotFoundException $e) {
52+
return new DataResponse(["error" => "Todo not found"], Http::STATUS_NOT_FOUND);
53+
}
54+
55+
return $this->formatTodo($todo);
56+
}
57+
58+
#[NoAdminRequired]
59+
public function update(int $id, string $etag, string $title = null, string $description = null, string $image = null): DataResponse {
60+
try {
61+
$todo = $this->service->updateTodo($id, $etag, $title, $description, $image);
62+
} catch (NotFoundException $e) {
63+
return new DataResponse(["error" => "Todo not found"], Http::STATUS_NOT_FOUND);
64+
} catch (ForbiddenException $e) {
65+
return new DataResponse(["error" => "ETag does not match"], Http::STATUS_BAD_REQUEST);
66+
}
67+
68+
return $this->formatTodo($todo);
69+
}
70+
71+
#[NoAdminRequired]
72+
public function delete(int $id): DataResponse {
73+
try {
74+
$todo = $this->service->deleteTodo($id);
75+
} catch (NotFoundException $e) {
76+
return new DataResponse(["error" => "Todo not found"], Http::STATUS_NOT_FOUND);
77+
}
78+
79+
return new DataResponse(null);
80+
}
81+
82+
private function formatTodo(Todo $todo): DataResponse() {
83+
return new DataResponse([
84+
"id" => $todo->id,
85+
"title" => $todo->title,
86+
"description" => $todo->description,
87+
"image" => $todo->image,
88+
], Http::STATUS_OK, [
89+
"ETag" => $todo->etag,
90+
]);
91+
}
92+
}
93+
94+
What you want to do now is to firstly create the correct parameter annotations and add descriptions. It could look like this:
95+
96+
.. code-block:: php
97+
98+
/**
99+
* Create a new Todo
100+
*
101+
* @param string $title The title of the new Todo item
102+
* @param string|null $description The description of the new Todo item. Can be left empty
103+
* @param string|null $image The base64-encoded image of the new Todo item. Can be left empty
104+
*/
105+
#[NoAdminRequired]
106+
public function create(string $title, string $description = null, string $image = null): DataResponse {
107+
...
108+
}
109+
110+
/**
111+
* Get a Todo item
112+
*
113+
* @param int $id ID of the Todo item
114+
*/
115+
#[NoAdminRequired]
116+
public function get(int $id): DataResponse {
117+
...
118+
}
119+
120+
/**
121+
* Update a Todo item
122+
*
123+
* @param int $id ID of the Todo item
124+
* @param string $etag ETag of the Todo item. If it does not match the ETag that is stored on the server the request will be rejected
125+
* @param string|null $title The new title of the Todo item. Can be left empty to not update the title
126+
* @param string|null $description The new description of the Todo item. Can be left empty to not update the description
127+
* @param string|null $image The new base64-encoded image of the Todo item. Can be left empty to not update the image
128+
*/
129+
#[NoAdminRequired]
130+
public function update(int $id, string $etag, string $title = null, string $description = null, string $image = null): DataResponse {
131+
...
132+
}
133+
134+
/**
135+
* Delete a Todo item
136+
*
137+
* @param int $id ID of the Todo item
138+
*/
139+
#[NoAdminRequired]
140+
public function delete(int $id): DataResponse {
141+
...
142+
}
143+
144+
The next step is to add the return types.
145+
This is the most important step to get your API documented.
146+
It is best to start with helper methods that are used multiple times like the `formatTodo` method in this example:
147+
148+
.. code-block:: php
149+
150+
/**
151+
* @return DataResponse<Http::STATUS_OK, array{id: int, title: string, description: ?string, image: ?string}, array{ETag: string}>
152+
*/
153+
private function formatTodo(Todo $todo): DataResponse() {
154+
...
155+
}
156+
157+
Afterwards you can add the return types to all the other methods.
158+
You are required to add a description for every returned status code.
159+
160+
.. code-block:: php
161+
162+
/**
163+
* ...
164+
*
165+
* @return DataResponse<Http::STATUS_OK, array{id: int, title: string, description: ?string, image: ?string}, array{ETag: string}>
166+
*
167+
* 200: Todo item created
168+
*/
169+
#[NoAdminRequired]
170+
public function create(string $title, string $description = "", string $image = ""): DataResponse {
171+
...
172+
}
173+
174+
/**
175+
* ...
176+
*
177+
* @return DataResponse<Http::STATUS_OK, array{id: int, title: string, description: ?string, image: ?string}, array{ETag: string}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
178+
*
179+
* 200: Todo item returned
180+
* 404: Todo item not found
181+
*/
182+
#[NoAdminRequired]
183+
public function get(int $id): DataResponse {
184+
...
185+
}
186+
187+
/**
188+
* ...
189+
*
190+
* @return DataResponse<Http::STATUS_OK, array{id: int, title: string, description: ?string, image: ?string}, array{ETag: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: string}, array{}>
191+
*
192+
* 200: Todo item created
193+
* 400: ETag of the Todo item does not match
194+
* 404: Todo item not found
195+
*/
196+
#[NoAdminRequired]
197+
public function update(int $id, string $etag, string $title = null, string $description = null, string $image = null): DataResponse {
198+
...
199+
}
200+
201+
/**
202+
* ...
203+
*
204+
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
205+
*
206+
* 200: Todo item deleted
207+
* 404: Todo item not found
208+
*/
209+
#[NoAdminRequired]
210+
public function delete(int $id): DataResponse {
211+
...
212+
}
213+
214+
How to add response definitions to share type definitions
215+
---------------------------------------------------------
216+
217+
In the previous steps we have been re-using the same data structure multiple times, but it was copied every time.
218+
This is tedious and error prone, therefore we want create some shared type definitions.
219+
Create a new file called `ResponseDefinitions.php` in the `lib` folder of your app.
220+
It will only work with that file name at that location.
221+
222+
.. code-block:: php
223+
224+
/**
225+
* @psalm-type TodoItem = array{
226+
* id: int,
227+
* title: string,
228+
* description: ?string,
229+
* image: ?string,
230+
* }
231+
*/
232+
class ResponseDefinitions {}
233+
234+
The name of every type definition has to start with the app ID.
235+
236+
To import and use this type definition you have to import it in your controller:
237+
238+
.. code-block:: php
239+
240+
/**
241+
* @psalm-import-type TodoItem from ResponseDefinitions
242+
*/
243+
class TodoApiController extends OCSController {
244+
...
245+
}
246+
247+
Now you can replace every occurrence of `array{id: int, title: string, description: ?string, image: ?string}` with `TodoItem`.
248+
249+
How to handle exceptions
250+
------------------------
251+
252+
Sometimes want to end with an exception instead of returning a response.
253+
It is better to not do it, but when migrating existing APIs this case appears sometimes.
254+
For this example our `update` will throw an exception when the ETag does not match:
255+
256+
.. code-block:: php
257+
258+
#[NoAdminRequired]
259+
public function update(int $id, string $etag, string $title = null, string $description = null, string $image = null): DataResponse {
260+
...
261+
} catch (ForbiddenException $e) {
262+
throw new OCSBadRequestException("ETag does not match");
263+
}
264+
...
265+
}
266+
267+
Adding the annotation for that is quite simple:
268+
269+
.. code-block:: php
270+
271+
/**
272+
* ...
273+
*
274+
* @throws OCSBadRequestException ETag of the Todo item does not match
275+
*/
276+
#[NoAdminRequired]
277+
public function update(int $id, string $etag, string $title = null, string $description = null, string $image = null): DataResponse {
278+
...
279+
}
280+
281+
The description after the exception class name works exactly like the description for the status codes we added earlier.
282+
Note that the resulting response will be in plain text and no longer in JSON.
283+
Therefore it is not recommended to use exceptions to indicate errors.
284+
285+
How to expose Capabilities
286+
--------------------------
287+
288+
Imagine we take the same Todo app of the previous example and want to expose some capabilities to let clients know what they can expect.
289+
290+
.. code-block:: php
291+
292+
class Capabilities implements ICapability {
293+
public function getCapabilities() {
294+
return [
295+
"todo" => [
296+
"supported-operations" => ["create", "read", "update", "delete"],
297+
"emojis-supported" => true,
298+
],
299+
];
300+
}
301+
}
302+
303+
All you have to do is add the correct return type annotation which would look like this:
304+
305+
.. code-block:: php
306+
307+
class Capabilities implements ICapability {
308+
/**
309+
* @return array{todo: array{supported-operation: string[], emojis-supported: bool}}
310+
*/
311+
public function getCapabilities() {
312+
return [
313+
"todo" => [
314+
"supported-operations" => ["create", "read", "update", "delete"],
315+
"emojis-supported" => true,
316+
],
317+
];
318+
}
319+
}
320+
321+
It will automatically appear in the generated specification.
322+
323+
How to generate the specification
324+
---------------------------------
325+
326+
The specification is generated by the `openapi-extractor <https://github.com/nextcloud/openapi-extractor>`_.
327+
Run the `generate-spec` script inside the root folder of your app and if you did everything right you will have a new file called `openapi.json`.
328+
If it fails somewhere it will tell you what is wrong and often times also how to fix it.
329+
Additionally you should run psalm to check for any problems.

0 commit comments

Comments
 (0)