DEV Community

Cover image for Build User login/signup & RESTful APIs in 100 lines of Python
Xulin Zhou
Xulin Zhou

Posted on • Originally published at python.plainenglish.io

Build User login/signup & RESTful APIs in 100 lines of Python

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

pip install utilmeta
Enter fullscreen mode Exit fullscreen mode

UtilMeta requires Python >= 3.8

1. Create project

We use the meta setup command to create a new project.

meta setup demo-user
Enter fullscreen mode Exit fullscreen mode

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

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',
    )
}))
Enter fullscreen mode Exit fullscreen mode

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

meta add user
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

service.use(DjangoSettings(
    secret_key='YOUR_SECRET_KEY',
    apps=['user']
))
Enter fullscreen mode Exit fullscreen mode

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

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
    )
Enter fullscreen mode Exit fullscreen mode

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.

meta makemigrations
meta migrate
Enter fullscreen mode Exit fullscreen mode

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

Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying user.0001_initial... OK
Enter fullscreen mode Exit fullscreen mode

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.

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,
)
Enter fullscreen mode Exit fullscreen mode

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

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

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)
Enter fullscreen mode Exit fullscreen mode

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

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

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()
Enter fullscreen mode Exit fullscreen mode

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

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)
Enter fullscreen mode Exit fullscreen mode

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.

# new +++
service.setup()
from user.api import UserAPI

class RootAPI(api.API):
    user: UserAPI

service.mount(RootAPI, route='/api')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or you can use

python server.py
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

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()
Enter fullscreen mode Exit fullscreen mode

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

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'}
Enter fullscreen mode Exit fullscreen mode

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

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'}
Enter fullscreen mode Exit fullscreen mode

References

If you have any problem, you are welcome to join the discord server of UtilMeta to discuss

Top comments (2)

Collapse
 
tbroyer profile image
Thomas Broyer • Edited

Signup, login and logout, with a username and password, that's "a good start" but it's far from being enough:

Collapse
 
voidzxl profile image
Xulin Zhou

Agreed, this post is just a beginner tutorial that only covered the simplest usage, I'll definitely write a "good practice" of real-world login & authentication post later