Skip to main content

Build APIs using class-based views.

Project description

plain.api

Build APIs using class-based views.

Overview

This package includes lightweight view classes for building APIs using the same patterns as regular HTML views. It also provides an APIKey model and support for generating OpenAPI documents.

APIView accepts shorthand return types — return a dict or list and it's converted to a JsonResponse. (The base View only accepts Response objects; this coercion is specific to APIView.)

# app/api/views.py
from plain.api.views import APIView


class HelloWorldView(APIView):
    def get(self):
        return {"message": "Hello, world!"}

More complex responses can use the JsonResponse class, and you can return different status codes by returning an int (ex. 404) or a tuple of (status_code, data).

Here is a more complete example that shows how to build a custom API with authentication and authorization:

# app/api/views.py
from plain.api.views import APIKeyView, APIView
from plain.auth import get_request_user, set_request_user
from plain.http import JsonResponse
from plain.views.exceptions import ResponseException

from app.users.models import User
from app.pullrequests.models import PullRequest


# An example base class that will be used across your custom API
class BaseAPIView(APIView, APIKeyView):
    def use_api_key(self):
        super().use_api_key()

        if user := User.query.filter(api_key=self.api_key).first():
            set_request_user(self.request, user)
        else:
            raise ResponseException(
                JsonResponse(
                    {"error": "API key not associated with a user."},
                    status_code=403,
                )
            )


# An endpoint that returns the current user
class UserView(BaseAPIView):
    def get(self):
        user = get_request_user(self.request)
        return {
            "uuid": user.uuid,
            "username": user.username,
            "time_zone": str(user.time_zone),
        }


# An endpoint that filters querysets based on the user
class PullRequestView(BaseAPIView):
    def get(self):
        try:
            pull = (
                PullRequest.query.all()
                .visible_to_user(get_request_user(self.request))
                .get(uuid=self.url_kwargs["uuid"])
            )
        except PullRequest.DoesNotExist:
            return None

        return {
            "uuid": pull.uuid,
            "state": pull.state,
            "number": pull.number,
            "host_url": pull.host_url,
            "host_created_at": pull.host_created_at,
            "host_updated_at": pull.host_updated_at,
            "host_merged_at": pull.host_merged_at,
            "author": {
                "uuid": pull.author.uuid,
                "display_name": pull.author.display_name,
            },
        }

URLs work like they do everywhere else, though it's generally recommended to put everything together into an app.api package and api namespace.

# app/api/urls.py
from plain.urls import Router, path

from . import views


class APIRouter(Router):
    namespace = "api"
    urls = [
        path("user/", views.UserView),
        path("pullrequests/<uuid:uuid>/", views.PullRequestView),
    ]

Authentication and authorization

Handling authentication in the API is pretty straightforward. If you use 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.

class BaseAPIView(APIView, APIKeyView):
    def use_api_key(self):
        from plain.auth import get_request_user, set_request_user
        from app.users.models import User

        super().use_api_key()

        if user := User.query.filter(api_key=self.api_key).first():
            set_request_user(self.request, user)
        else:
            raise ResponseException(
                JsonResponse(
                    {"error": "API key not associated with a user."},
                    status_code=403,
                )
            )

When 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.

class PullRequestView(BaseAPIView):
    def get(self):
        from plain.auth import get_request_user

        try:
            pull = (
                PullRequest.query.all()
                .visible_to_user(get_request_user(self.request))
                .get(uuid=self.url_kwargs["uuid"])
            )
        except PullRequest.DoesNotExist:
            return None

        # ...return the authorized data here

PUT, POST, and PATCH

One way to handle PUT, POST, and PATCH endpoints is to use standard forms. 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.

class UserForm(ModelForm):
    class Meta:
        model = User
        fields = [
            "username",
            "time_zone",
        ]

class UserView(BaseAPIView):
    def patch(self):
        from plain.auth import get_request_user

        form = UserForm(
            request=self.request,
            instance=get_request_user(self.request),
        )

        if form.is_valid():
            user = form.save()
            return {
                "uuid": user.uuid,
                "username": user.username,
                "time_zone": str(user.time_zone),
            }
        else:
            return {"errors": form.errors}

If you don't want to use Plain's forms, you could also use a third-party schema/validation library like Pydantic or Marshmallow. But depending on your use case, you may not need to use forms or fancy validation at all!

DELETE

Deletes 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.

class PullRequestView(BaseAPIView):
    def delete(self):
        from plain.auth import get_request_user

        try:
            pull = (
                PullRequest.query.all()
                .visible_to_user(get_request_user(self.request))
                .get(uuid=self.url_kwargs["uuid"])
            )
        except PullRequest.DoesNotExist:
            return None

        pull.delete()

        return 204

API keys

The provided APIKey model includes randomly generated, unique API tokens that are automatically parsed by APIKeyView. The tokens can optionally be named and include an expires_at date.

Associating 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.

# app/users/models.py
from plain import postgres
from plain.postgres import types
from plain.api.models import APIKey


@postgres.register_model
class User(postgres.Model):
    # other fields...
    api_key: APIKey = types.ForeignKeyField(
        APIKey,
        on_delete=postgres.CASCADE,
        allow_null=True,
        required=False,
    )

    model_options = postgres.Options(
        constraints=[
            postgres.UniqueConstraint(
                fields=["api_key"],
                condition=postgres.Q(api_key__isnull=False),
                name="unique_user_api_key",
            ),
        ],
    )

Generating API keys is something you will need to do in your own code, wherever it makes sense to do so.

user = User.query.first()
user.api_key = APIKey.query.create()
user.save()

To use API keys in your views, you can inherit from APIKeyView and customize the use_api_key method to associate the request with a user (or any other object) using set_request_user().

# app/api/views.py
from plain.api.views import APIKeyView, APIView
from plain.auth import set_request_user
from plain.views.exceptions import ResponseException

from app.users.models import User


class BaseAPIView(APIView, APIKeyView):
    def use_api_key(self):
        super().use_api_key()

        if user := User.query.filter(api_key=self.api_key).first():
            set_request_user(self.request, user)
        else:
            raise ResponseException(
                JsonResponse(
                    {"error": "API key not associated with a user."},
                    status_code=403,
                )
            )

OpenAPI

You can use a combination of decorators to help generate an OpenAPI document for your API.

To define root level schema, use the @openapi.schema decorator on your Router class.

from plain.urls import Router, path
from plain.api import openapi
from plain.assets.views import AssetView
from . import views


@openapi.schema({
    "openapi": "3.0.0",
    "info": {
        "title": "PullApprove API",
        "version": "4.0.0",
    },
    "servers": [
        {
            "url": "https://4.pullapprove.com/api/",
            "description": "PullApprove API",
        }
    ],
})
class APIRouter(Router):
    namespace = "api"
    urls = [
        # ...your API routes
    ]

You can then define additional schema on a view class, or a specific view method.

class CurrentUserAPIView(BaseAPIView):
    @openapi.schema({
        "summary": "Get current user",
    })
    def get(self):
        from plain.auth import get_request_user

        user = get_request_user(self.request)
        if not user:
            raise NotFoundError404

        return schemas.UserSchema.from_user(user, self.request)

While 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.

class TeamAccountAPIView(BaseAPIView):
    @openapi.request_form(TeamAccountForm)
    @openapi.response_typed_dict(200, TeamAccountSchema)
    def patch(self):
        form = TeamAccountForm(request=self.request, instance=self.team_account)

        if form.is_valid():
            team_account = form.save()
            return TeamAccountSchema.from_team_account(
                team_account, self.request
            )
        else:
            return {"errors": form.errors}

    @cached_property
    def team_account(self):
        try:
            if self.organization:
                return TeamAccount.query.get(
                    team__organization=self.organization, uuid=self.url_kwargs["uuid"]
                )

            user = get_request_user(self.request)
            if user:
                return TeamAccount.query.get(
                    team__organization__in=user.organizations.all(),
                    uuid=self.url_kwargs["uuid"],
                )
        except TeamAccount.DoesNotExist:
            raise NotFoundError404


class TeamAccountForm(ModelForm):
    class Meta:
        model = TeamAccount
        fields = ["is_reviewer", "is_admin"]


class TeamAccountSchema(TypedDict):
    uuid: UUID
    account: AccountSchema
    is_admin: bool
    is_reviewer: bool
    api_url: str

    @classmethod
    def from_team_account(cls, team_account, request) -> "TeamAccountSchema":
        return cls(
            uuid=team_account.uuid,
            is_admin=team_account.is_admin,
            is_reviewer=team_account.is_reviewer,
            api_url=request.build_absolute_uri(
                reverse("api:team_account", uuid=team_account.uuid)
            ),
            account=AccountSchema.from_account(team_account.account, request),
        )

To generate the OpenAPI JSON, run the following command (including swagger.io validation):

plain api generate-openapi --validate

Deploying

To build the JSON when you deploy, add a build.run command to your pyproject.toml file:

[tool.plain.build.run]
openapi = {cmd = "plain api generate-openapi --validate > app/assets/openapi.json"}

You will typically want app/assets/openapi.json to be included in your .gitignore file.

Then you can use an AssetView to serve the openapi.json file.

from plain.urls import Router, path
from plain.assets.views import AssetView
from . import views

class APIRouter(Router):
    namespace = "api"
    urls = [
        # ...your API routes
        path("openapi.json", AssetView.as_view(asset_path="openapi.json")),
    ]

Settings

Setting Default Env var
API_OPENAPI_ROUTER "" PLAIN_API_OPENAPI_ROUTER

See default_settings.py for more details.

FAQs

How do I make an API key optional?

You can set api_key_required = False on your view class to make API key authentication optional. The self.api_key will be None if no valid key is provided.

class PublicAPIView(APIView, APIKeyView):
    api_key_required = False

    def get(self):
        if self.api_key:
            # Authenticated request
            return {"status": "authenticated"}
        else:
            # Anonymous request
            return {"status": "anonymous"}

Can I use plain.api without plain.postgres?

Yes. The APIKey model requires plain.postgres, but you can use APIView without it. If you try to use APIKeyView without plain.postgres installed, you will need to override the get_api_key method to provide your own API key lookup logic.

How do I return different status codes?

You can return status codes in several ways:

  • Return an int: return 204 (for no content)
  • Return None: automatically returns 404
  • Return a JsonResponse with a custom status code: return JsonResponse({"error": "Bad request"}, status_code=400)
  • Raise an exception: raise NotFoundError404 or raise ForbiddenError403

How do I access the request body?

You can access the parsed JSON body using self.request.json(). For form data, use self.request.POST.

class CreateItemView(APIView):
    def post(self):
        data = self.request.json()
        name = data.get("name")
        return {"created": name}

Installation

Install the plain.api package from PyPI:

uv add plain.api

Typically you will want to create an api package to contain all of the views and URLs for your app's API.

plain create api

The app.api package should be added to your app's INSTALLED_APPS setting in app/settings.py:

# app/settings.py
INSTALLED_APPS = [
    # ...other apps
    "app.api",
]

Then create a your API URL router and your first API view.

# app/api/urls.py
from plain.urls import Router, path
from plain.api.views import APIView


class ExampleAPIView(APIView):
    def get(self):
        return {"message": "Hello, world!"}


class APIRouter(Router):
    namespace = "api"
    urls = [
        path("example/", ExampleAPIView),
    ]

The APIRouter can then be included in your app's URLs.

# app/urls.py
from plain.urls import include, path

from .api.urls import APIRouter


class AppRouter(Router):
    namespace = "app"
    urls = [
        # ...other routes
        include("api/", APIRouter),
    ]

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

plain_api-0.31.1.tar.gz (23.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

plain_api-0.31.1-py3-none-any.whl (28.6 kB view details)

Uploaded Python 3

File details

Details for the file plain_api-0.31.1.tar.gz.

File metadata

  • Download URL: plain_api-0.31.1.tar.gz
  • Upload date:
  • Size: 23.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.10 {"installer":{"name":"uv","version":"0.11.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for plain_api-0.31.1.tar.gz
Algorithm Hash digest
SHA256 d9cce1a6db683d7a657b855301a63af8515e29f23bf8a73e006924007c0463c2
MD5 d10c16e360350d8ba05bc770223ff642
BLAKE2b-256 855c6cfa2f3353286d377eb9c67151cef06f29e162d5ea6e43a5cd9c9e1bc4fd

See more details on using hashes here.

File details

Details for the file plain_api-0.31.1-py3-none-any.whl.

File metadata

  • Download URL: plain_api-0.31.1-py3-none-any.whl
  • Upload date:
  • Size: 28.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.10 {"installer":{"name":"uv","version":"0.11.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for plain_api-0.31.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5a9f743b0e1e26f03c71d76414cfc2bb44f7bb1476a9e5ddbb2b6a420c2f0a7b
MD5 900e0ae0540435149b4765e33e5a1d95
BLAKE2b-256 be8ac96be5707786e01a52c0dc86c2903872af1e09cf3455b42a46af2270b13f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page