1# plain.api
2
3**Build APIs using class-based views.**
4
5This package includes lightweight view classes for building APIs using the same patterns as regular HTML views. It also provides an [`APIKey` model](#api-keys) and support for generating [OpenAPI](#openapi) documents.
6
7Because [Views](/plain/plain/views/README.md) can convert built-in types to responses, an API view can simply return a dict or list to send a JSON response back to the client. More complex responses can use the [`JsonResponse`](/plain/plain/http/response.py#JsonResponse) class.
8
9```python
10# app/api/views.py
11from plain.api.views import APIKeyView, APIView
12from plain.http import JsonResponse
13from plain.views.exeptions import ResponseException
14
15from app.users.models import User
16from app.pullrequests.models import PullRequest
17
18
19# An example base class that will be used across your custom API
20class BaseAPIView(APIView, APIKeyView):
21 def use_api_key(self):
22 super().use_api_key()
23
24 if user := self.api_key.users.first():
25 self.request.user = user
26 else:
27 raise ResponseException(
28 JsonResponse(
29 {"error": "API key not associated with a user."},
30 status_code=403,
31 )
32 )
33
34
35# An endpoint that returns the current user
36class UserView(BaseAPIView):
37 def get(self):
38 return {
39 "uuid": self.request.user.uuid,
40 "username": self.request.user.username,
41 "time_zone": str(self.request.user.time_zone),
42 }
43
44
45# An endpoint that filters querysets based on the user
46class PullRequestView(BaseAPIView):
47 def get(self):
48 try:
49 pull = (
50 PullRequest.objects.all()
51 .visible_to_user(self.request.user)
52 .get(uuid=self.url_kwargs["uuid"])
53 )
54 except PullRequest.DoesNotExist:
55 return None
56
57 return {
58 "uuid": pull.uuid,
59 "state": pull.state,
60 "number": pull.number,
61 "host_url": pull.host_url,
62 "host_created_at": pull.host_created_at,
63 "host_updated_at": pull.host_updated_at,
64 "host_merged_at": pull.host_merged_at,
65 "author": {
66 "uuid": pull.author.uuid,
67 "display_name": pull.author.display_name,
68 },
69 }
70```
71
72URLs work like they do everywhere else, though it's generally recommended to put everything together into an `app.api` package and `api` namespace.
73
74```python
75# app/api/urls.py
76from plain.urls import Router, path
77
78from . import views
79
80
81class APIRouter(Router):
82 namespace = "api"
83 urls = [
84 path("user/", views.UserView),
85 path("pullrequests/<uuid:uuid>/", views.PullRequestView),
86 ]
87```
88
89## Authentication and authorization
90
91Handling authentication in the API is pretty straightforward. If you use [API keys](#api-keys), then the `APIKeyView` will parse the `Authorization: Bearer <token>` header and set `self.api_key`. You will then customize the `use_api_key` method to associate the request with a user (or team, for example), depending on how your app works.
92
93```python
94class BaseAPIView(APIView, APIKeyView):
95 def use_api_key(self):
96 super().use_api_key()
97
98 if user := self.api_key.users.first():
99 self.request.user = user
100 else:
101 raise ResponseException(
102 JsonResponse(
103 {"error": "API key not associated with a user."},
104 status_code=403,
105 )
106 )
107```
108
109When it comes to authorizing actions, typically you will factor this in to the queryset to only return objects that the user is allowed to see. If a response method (`get`, `post`, etc.) returns `None`, then the view will return a 404 response. Other status codes can be returned with an int (ex. `403`) or a `JsonResponse` object.
110
111```python
112class PullRequestView(BaseAPIView):
113 def get(self):
114 try:
115 pull = (
116 PullRequest.objects.all()
117 .visible_to_user(self.request.user)
118 .get(uuid=self.url_kwargs["uuid"])
119 )
120 except PullRequest.DoesNotExist:
121 return None
122
123 # ...return the authorized data here
124```
125
126## `PUT`, `POST`, and `PATCH`
127
128One way to handle PUT, POST, and PATCH endpoints is to use standard [forms](/plain/plain/forms/README.md). This will use the same validation and error handling as an HTML form, but will parse the input from the JSON request instead of HTML form data.
129
130```python
131class UserForm(ModelForm):
132 class Meta:
133 model = User
134 fields = [
135 "username",
136 "time_zone",
137 ]
138
139class UserView(BaseAPIView):
140 def patch(self):
141 form = UserForm(
142 request=self.request,
143 instance=self.request.user,
144 )
145
146 if form.is_valid():
147 user = form.save()
148 return {
149 "uuid": user.uuid,
150 "username": user.username,
151 "time_zone": str(user.time_zone),
152 }
153 else:
154 return {"errors": form.errors}
155```
156
157If you don't want to use Plain's forms, you could also use a third-party schema/validation library like [Pydantic](https://docs.pydantic.dev/latest/) or [Marshmallow](https://marshmallow.readthedocs.io/en/3.x-line/). But depending on your use case, you may not need to use forms or fancy validation at all!
158
159## `DELETE`
160
161Deletes can be handled in the `delete` method of the view. Most of the time this just means getting the object, deleting it, and returning a 204.
162
163```python
164class PullRequestView(BaseAPIView):
165 def delete(self):
166 try:
167 pull = (
168 PullRequest.objects.all()
169 .visible_to_user(self.request.user)
170 .get(uuid=self.url_kwargs["uuid"])
171 )
172 except PullRequest.DoesNotExist:
173 return None
174
175 pull.delete()
176
177 return 204
178```
179
180## API keys
181
182The provided [`APIKey` model](./models.py) includes randomly generated, unique API tokens that are automatically parsed by `APIKeyView`. The tokens can optionally be named and include an `expires_at` date.
183
184Associating an `APIKey` with a user (or team, for example) is up to you. Most likely you will want to use a `ForeignKey` or a `ManyToManyField`.
185
186```python
187# app/users/models.py
188from plain import models
189from plain.api.models import APIKey
190
191
192@models.register_model
193class User(models.Model):
194 # other fields...
195 api_key = models.ForeignKey(
196 APIKey,
197 on_delete=models.CASCADE,
198 related_name="users",
199 allow_null=True,
200 required=False,
201 )
202
203 class Meta:
204 constraints = [
205 models.UniqueConstraint(
206 fields=["api_key"],
207 condition=models.Q(api_key__isnull=False),
208 name="unique_user_api_key",
209 ),
210 ]
211```
212
213Generating API keys is something you will need to do in your own code, wherever it makes sense to do so.
214
215```python
216user = User.objects.first()
217user.api_key = APIKey.objects.create()
218user.save()
219```
220
221To use API keys in your views, you can inherit from `APIKeyView` and customize the [`use_api_key` method](./views.py#use_api_key) to set the `request.user` attribute (or any other attribute) to the object associated with the API key.
222
223```python
224# app/api/views.py
225from plain.api.views import APIKeyView, APIView
226
227
228class BaseAPIView(APIView, APIKeyView):
229 def use_api_key(self):
230 super().use_api_key()
231
232 if user := self.api_key.users.first():
233 self.request.user = user
234 else:
235 raise ResponseException(
236 JsonResponse(
237 {"error": "API key not associated with a user."},
238 status_code=403,
239 )
240 )
241```
242
243## OpenAPI
244
245You can use a combination of decorators to help generate an [OpenAPI](https://www.openapis.org/) document for your API.
246
247To define root level schema, use the `@openapi.schema` decorator on your `Router` class.
248
249```python
250from plain.urls import Router, path
251from plain.api import openapi
252from plain.assets.views import AssetView
253from . import views
254
255
256@openapi.schema({
257 "openapi": "3.0.0",
258 "info": {
259 "title": "PullApprove API",
260 "version": "4.0.0",
261 },
262 "servers": [
263 {
264 "url": "https://4.pullapprove.com/api/",
265 "description": "PullApprove API",
266 }
267 ],
268})
269class APIRouter(Router):
270 namespace = "api"
271 urls = [
272 # ...your API routes
273 ]
274```
275
276You can then define additional schema on a view class, or a specific view method.
277
278```python
279class CurrentUserAPIView(BaseAPIView):
280 @openapi.schema({
281 "summary": "Get current user",
282 })
283 def get(self):
284 if self.request.user:
285 user = self.request.user
286 else:
287 raise Http404
288
289 return schemas.UserSchema.from_user(user, self.request)
290```
291
292While you can attach any raw schema you like, there are a couple helpers to generate schema for API input (`@openapi.request_form`) and output (`@openapi.response_typed_dict`). These are intentionally specific, leaving room for custom decorators to be written for the input/output types of your choice.
293
294```python
295class TeamAccountAPIView(BaseAPIView):
296 @openapi.request_form(TeamAccountForm)
297 @openapi.response_typed_dict(200, TeamAccountSchema)
298 def patch(self):
299 form = TeamAccountForm(request=self.request, instance=self.team_account)
300
301 if form.is_valid():
302 team_account = form.save()
303 return TeamAccountSchema.from_team_account(
304 team_account, self.request
305 )
306 else:
307 return {"errors": form.errors}
308
309 @cached_property
310 def team_account(self):
311 try:
312 if self.organization:
313 return TeamAccount.objects.get(
314 team__organization=self.organization, uuid=self.url_kwargs["uuid"]
315 )
316
317 if self.request.user:
318 return TeamAccount.objects.get(
319 team__organization__in=self.request.user.organizations.all(),
320 uuid=self.url_kwargs["uuid"],
321 )
322 except TeamAccount.DoesNotExist:
323 raise Http404
324
325
326class TeamAccountForm(ModelForm):
327 class Meta:
328 model = TeamAccount
329 fields = ["is_reviewer", "is_admin"]
330
331
332class TeamAccountSchema(TypedDict):
333 uuid: UUID
334 account: AccountSchema
335 is_admin: bool
336 is_reviewer: bool
337 api_url: str
338
339 @classmethod
340 def from_team_account(cls, team_account, request) -> "TeamAccountSchema":
341 return cls(
342 uuid=team_account.uuid,
343 is_admin=team_account.is_admin,
344 is_reviewer=team_account.is_reviewer,
345 api_url=request.build_absolute_uri(
346 reverse("api:team_account", uuid=team_account.uuid)
347 ),
348 account=AccountSchema.from_account(team_account.account, request),
349 )
350```
351
352To generate the OpenAPI JSON, run the following command (including swagger.io validation):
353
354```bash
355plain api generate-openapi --validate
356```
357
358### Deploying
359
360To build the JSON when you deploy, add a `build.run` command to your `pyproject.toml` file:
361
362```toml
363[tool.plain.build.run]
364openapi = {cmd = "plain api generate-openapi --validate > app/assets/openapi.json"}
365```
366
367You will typically want `app/assets/openapi.json` to be included in your `.gitignore` file.
368
369Then you can use an [`AssetView`](/plain/plain/assets/views.py#AssetView) to serve the `openapi.json` file.
370
371```python
372from plain.urls import Router, path
373from plain.assets.views import AssetView
374from . import views
375
376class APIRouter(Router):
377 namespace = "api"
378 urls = [
379 # ...your API routes
380 path("openapi.json", AssetView.as_view(asset_path="openapi.json")),
381 ]
382```