Build User login/signup & RESTful APIs in 100 lines of Python
Tags: backend, python, django, restapi
Published: 2024-02-23 05:17:39

For most applications, **user** is a must, we always need to develop user login, signup, query and update APIs. This post will walk you through a concise and flexible 100+ lines of Python code to implement such APIs.

We will use [UtilMeta framework](https://github.com/utilmeta/utilmeta-py) to build these APIs, It's an open-source Python meta backend framework, which supports the integration of Python frameworks like Django, Flask, FastAPI, and efficiently builds declarative RESTful APIs based on the Python type annotation standard

## 0. Installation

You can install UtilMeta framework using the following command

```shell
pip install utilmeta
```

> UtilMeta requires Python >= 3.8

## 1. Create project

We use the `meta setup` command to create a new project.
```shell
meta setup demo-user
```

We will use Django as runtime framework of this project, so you can enter `django` when prompted to select backend

After the project is created, we need to configure the database connection of the service, open `server.py`, and insert the following code after the declaration of `service`

```python
service = UtilMeta(
    __name__,
    name='demo-user',
    backend=django,
)

# new +++++
from utilmeta.core.server.backends.django import DjangoSettings
from utilmeta.core.orm import DatabaseConnections, Database

service.use(DjangoSettings(
    secret_key='YOUR_SECRET_KEY',
))

service.use(DatabaseConnections({
    'default': Database(
        name='db',
        engine='sqlite3',
    )
}))
```

In the inserted code, we declare the configuration information of Django and the configuration of the database connection. Because Django uses the **app** to manage the data model, we use the following command to create an app named `user`

```shell
meta add user
```

You can see that a new folder named `user` has been created in our project folder, which includes
```
/user
    /migrations
    api.py
    models.py
    schema.py
```

The `migrations` folder is where Django handles the database migrations of the models

Once the app is created, we insert a line into the `DjangoSettings` of  `server.py` to specify the app.

```python
service.use(DjangoSettings(
    secret_key='YOUR_SECRET_KEY',
    apps=['user']
))
```

So far, we have completed the configuration and initialization of the project.

## 2. Write user model

The user APIs depend on the "**user**", so before developing the API, we should write the user’s data model. We open `user/models.py` and write
```python
from django.db import models
from utilmeta.core.orm.backends.django.models import AbstractSession, PasswordField

class User(models.Model):
    username = models.CharField(max_length=20, unique=True)
    password = PasswordField(max_length=100)
    signup_time = models.DateTimeField(auto_now_add=True)

class Session(AbstractSession):
    user = models.ForeignKey(
        User, related_name='sessions', 
        null=True, default=None, 
        on_delete=models.CASCADE
    )
```

We write a `User` model firstly that contains

* `username`: The username field which is required to be unique ( `unique=True` )
* `password`:  The password field using  `PasswordField` to auto-encrypt the input password (with pbkdf2)
* `signup_time`: The signup time field

In addition to the `User` model, we have also written a `Session` model for users to record user sessions and login state. We will implement user login and authentication through this model.

### Connect database

After we write the data model, we can use the migration command provided by Django to easily create the corresponding data table. Since we use SQLite, we do not need to install the database software in advance. We only need to run the following two commands to complete the creation of the database.

```shell
meta makemigrations
meta migrate
```

When you see the following output, you have finished creating the database

```
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying user.0001_initial... OK
```

The database migration command created a SQLite database named `db` in the project folder according to the database configuration in `server.py` , where the table of User and Session models has been created

## 3. Session and Authentication

After writing the user models, we can start to develop the authentication logic. We create a  new file named `auth.py` in the user folder and write the configuration of session and user authentication.
```python
from utilmeta.core import auth
from utilmeta.core.auth.session.db import DBSessionSchema, DBSession
from .models import Session, User

USER_ID = '_user_id'

class SessionSchema(DBSessionSchema):
    def get_session_data(self):
        data = super().get_session_data()
        data.update(user_id=self.get(USER_ID))
        return data

session_config = DBSession(
    session_model=Session,
    engine=SessionSchema,
    cookie=DBSession.Cookie(
        name='sessionid',
        age=7 * 24 * 3600,
        http_only=True
    )
)

user_config = auth.User(
    user_model=User,
    authentication=session_config,
    key=USER_ID,
    login_fields=User.username,
    password_field=User.password,
)
```

In this code, `SessionSchema` is the core engine that processes and stores Session data, `session_config`  declares the Session configuration with `Session` model and engine we just wrote, and configures the corresponding Cookie policy

> We use session store based on database to simply our tutorial, in practice, we often use cache+db as the store, you can find more in [Session Authentication](https://docs.utilmeta.com/py/en/guide/auth/#session)

We also declare the user authentication configuration `user_config` with the following params

* `user_model`: Specify the user model for authentication, which is the User model I wrote in the previous section.
* `authentication`: Specify the authentication method. We pass `session_config` in to declare that user authentication is performed using Session.
* `key`: Specify the key of the current user ID in the session data
* `login_fields`: Fields that can be used for login, such as username, email, etc., which need to be unique.
* `password_field`: The user’s password field. Declaring these allows UtilMeta to automatically handle the login verification logic for you.

## 4. Write user API

### Signup API

First, we will write the user signup API. It should receive the user name and password fields, complete the signup after verifying that the user name is not occupied, and return the newly registered user data.

We open the `user/api.py` and write

```python
from datetime import datetime
from utilmeta.core import api, orm
from utilmeta.utils import exceptions
from .models import User
from . import auth

class SignupSchema(orm.Schema[User]):
    username: str
    password: str
    
class UserSchema(orm.Schema[User]):
    id: int
    username: str
    signup_time: datetime

@auth.session_config.plugin
class UserAPI(api.API):
    @api.post
    def signup(self, data: SignupSchema = request.Body) -> UserSchema:
        if User.objects.filter(username=data.username).exists():
            raise exceptions.BadRequest('Username exists')
        data.save()
        auth.user_config.login_user(
            request=self.request,
            user=data.get_instance()
        )
        return UserSchema.init(data.pk)
```

We use `@api` decorator to define the API functions that provide API service, which contains HTTP methods like GET / POST / PUT / PATCH / DELETE, we are using the POST method in the signup API. You can use the first param in the decorator to specify the API's path, if empty like the above example, the API's path will be the function's name (`signup`)

We declared the request body of signup API as  `SignupSchema`. so that UtilMeta will automatically parse and covert request body to a `SignupSchema` instance, the invalid request will be rejected by UtilMeta and with a 400 response

The logic in the signup API function is

1. Detect whether the in `username` the request has been registered
2. Call `data.save()` method to save the signup data
3. Login the registered user to the current request using `login_user`
4. Returns after initializing the new user’s data to a UserSchema instance using `UserSchema.init(data.pk)`

> UtilMeta has developed an efficient declarative ORM mechanism, We use `orm.Schema[User]` to define a Schema class with the `User` model injected, so that we can use the methods of the schema class to create, update, and serialize data. You can find more in [Data Query and ORM Document](https://docs.utilmeta.com/py/zh/guide/schema-query/)

We can also find that a decorator named `@auth.session_config.plugin` is plugin to the `UserAPI` class. This is where the Session configuration is applied to the API. This plugin can save the Session data after each request and patch the response with corresponding `Set-Cookie` header

### Login & Logout API

Next, we'll write the user’s login and logout APIs

```python
from datetime import datetime
from utilmeta.core import api, orm, request
from utilmeta.utils import exceptions
from .models import User
from . import auth
import utype

class LoginSchema(utype.Schema):
    username: str
    password: str

@auth.session_config.plugin
class UserAPI(api.API):
    @api.post
    def signup(self): ...

    # new ++++
    @api.post
    def login(self, data: LoginSchema = request.Body) -> UserSchema:
        user = auth.user_config.login(
            request=self.request,
            ident=data.username,
            password=data.password
        )
        if not user:
            raise exceptions.PermissionDenied('Username of password wrong')
        return UserSchema.init(user)

    @api.post
    def logout(self, session: auth.SessionSchema = auth.session_config):
        session.flush()
```

In the login API, we call the `login()` method in our authentication configuration to complete the login simply. Since we have configured the login field and password field, UtilMeta can help us complete the password verification and login automatically. If the login is successful, the corresponding user instance is returned. So we can throw an error if the `login()` result is None, and after a successful login, we can call `UserSchema.init` to return the login user data to the client.

For the logout API, we just need to empty the session data of current request, We use the declared `session_config` as the default of the function parameter to receive the Session object of the current request and use `session.flush()` to empty it.

### Get & Update user data

When we understand the usage of declarative ORM, it is very simple to write the get & update API of User

```python
from datetime import datetime
from utilmeta.core import api, orm, request
from utilmeta.utils import exceptions
from .models import User
from . import auth
import utype

class UserUpdateSchema(orm.Schema[User]):
    id: int = orm.Field(no_input=True)
    username: str = orm.Field(required=False)
    password: str = orm.Field(required=False)

@auth.session_config.plugin
class UserAPI(api.API):
    @api.post
    def signup(self): ...
    @api.post
    def login(self): ...
    @api.post
    def logout(self): ...

    # new ++++
    def get(self, user: User = auth.user_config) -> UserSchema:
        return UserSchema.init(user)

    def put(self, data: UserUpdateSchema = request.Body, 
            user: User = auth.user_config) -> UserSchema:
        data.id = user.pk
        data.save()
        return UserSchema.init(data.pk)
```

After we declare the user authentication configuration, we can declare `user: User = auth.user_config` in the API function parameters to get the instance of the current request user in any API that requires user login. If the request is not logged in, UtilMeta will automatically process and return a `401 Unauthorized` response

In the `get` API, we directly serialize the current request user using `UserSchema` and return it to the client
In the `put` API, we assign the current request user's ID to the `id` field of `UserUpdateSchema`, and return the updated user data after saving.

Since we can’t allow the requesting user to arbitrarily specify the user ID to be updated, we use the `no_input=True` option for `id` field, which is actually a common practice, that is a user can only update his own information.

> If your API function using the name of HTTP methods (such as get/put/patch/post/delete), it will bind the method and mount the same route of the API class, these methods are called **core methods** of the API class

At this point, our API is all developed.
### Mount API

To provide access to our developed UserAPI, we need to mount it on the root API of the service. Let’s go back to `server.py` and modify the declaration of the RootAPI.
```python
# new +++
service.setup()
from user.api import UserAPI

class RootAPI(api.API):
    user: UserAPI
	
service.mount(RootAPI, route='/api')
```

We mount the developed UserAPI to the RootAPI's `user` property, which means that the `UserAPI`'s path is mounted to `/api/user`, the endpoints in `UserAPI` will follow the path, like

* `GET /api/user`: Get the current user of the request
* `PUT /api/user`: Update the current user of the request 
* `POST /api/user/login`: User login
* `POST /api/user/logout`: User logout
* `POST /api/user/signup`: User signup

This mounting syntax is convenient for defining tree-like API structure

> You should call `service.setup()` before import any Django models to complete the setup of Django

## 5. Run the API

Run the API service using the following command in the project folder
```
meta run
```

Or you can use
```shell
python server.py
```

When you see the following output, the service has started successfully

```
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.
```

> You can alter the `host` and `port` params of UtilMeta service in `server.py`  to change the address of the API service

## 6. Debug API

After starting our API service, we can debug the APIs using the client in UtilMeta, let's create a new file named `test.py` in the project directory and write
```python
from server import service

if __name__ == '__main__':
    with service.get_client(live=True) as client:
        r1 = client.post('user/signup', data={
            'username': 'user1',
            'password': '123123'
        })
        r1.print()
        r2 = client.get('user')
        r2.print()
```

It contains the debug code for the signup and gets APIs, when we started the service and run `test.py`, we can see the following output like
```json
Response [200 OK] "POST /api/user/signup"
application/json (76)
{'username': 'user1', 'id': 1, 'signup_time': '2024-01-29T12:29:33.684594'}

Response [200 OK] "GET /api/user"
application/json (76)
{'username': 'user1', 'id': 1, 'signup_time': '2024-01-29T12:29:33.684594'}
```

It means our signup and get APIs worked,

> In the `with` code block, client will store the  `Set-Cookie` in the response the send in the following requests, so we can see the session works just like the browser

We can also test the login, logout, and update APIs, the complete examples are as follows
```python
from server import service

if __name__ == '__main__':
    with service.get_client(live=True) as client:
        r1 = client.post('user/signup', data={
            'username': 'user1',
            'password': '123123'
        })
        r1.print()
        # Response [200 OK] "POST /api/user/signup"
        # application/json (75)
        # {'username': 'user1', 'id': 1, 'signup_time': '2024-01-29T13:29:03.336134'}
        r2 = client.get('user')
        r2.print()
        # Response [200 OK] "GET /api/user"
        # application/json (75)
        # {'username': 'user1', 'id': 1, 'signup_time': '2024-01-29T13:29:03.336134'}
        r3 = client.post('user/logout')
        r3.print()
        # Response [200 OK] "POST /api/user/logout"
        # text/html (0)
        r4 = client.get('user')
        r4.print()
        # Response [401 Unauthorized] "GET /api/user"
        # text/html (0)
        r5 = client.post('user/login', data={
            'username': 'user1',
            'password': '123123'
        })
        # Response [200 OK] "POST /api/user/login"
        # application/json (75)
        # {'username': 'user1', 'id': 1, 'signup_time': '2024-01-29T13:29:03.336134'}
        r5.print()
        r6 = client.get('user')
        r6.print()
        # Response [200 OK] "GET /api/user"
        # application/json (75)
        # {'username': 'user1', 'id': 1, 'signup_time': '2024-01-29T13:29:03.336134'}
        r7 = client.put('user', data={
            'username': 'user-updated',
            'password': '123456'
        })
        r7.print()
        # Response [200 OK] "PUT /api/user"
        # application/json (82)
        # {'username': 'user-updated', 'id': 1, 'signup_time': '2024-01-29T13:44:30.095711'}
```

## References

* [Source Code](https://github.com/utilmeta/utilmeta-py/tree/main/examples/user_auth)of this post
* [UtilMeta](https://utilmeta.com/py): framework used in this post

If you have any problem, you are welcome to join the [discord server](https://discord.gg/JdmEkFS6dS) of UtilMeta to discuss