Plain is headed towards 1.0! Subscribe for development updates →

  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```