Building a barebone Web API service in Python without a web framework
John Owolabi Idogun
Posted on September 10, 2022
Introduction
Have you ever wondered how Python web frameworks work under the hood? Are you interested in just playing with python and not the complexities of its web frameworks such as Django, Flask, FastAPI, and so on? Do you know that with just Python, you can have some functionalities built? Do you want to explore the popular Python WSGI HTTP Server for UNIX, Gunicorn 'Green Unicorn'? If these sound interesting to you, welcome onboard! We will be exploiting the capabilities of gunicorn to serve our "sketchy", barebone, and "not-recommended-for-production" web API service with the following features:
Getting users added to the app
Allowing users to check balances, deposit and withdraw money to other app users and out of the app.
NOTE: This application does not incorporate any real database. It stores data in memory.
Assumptions
It is assumed you have a basic understanding of Python, and how the web works.
Source code
The entire source code for this article can be accessed via:
I used the .fish version of activate because my default shell is fish.
Install dpendencies and run the application
This app does not need fancy framework, the only required dependency is gunicorn which serves the application. Other dependencies are not required, they…
As with every project, we need to create it. Open up your terminal/cmd/PowerShell and navigate to the directory where the project will be housed. Then create the project. For me, I used poetry to bootstrap a typical python project like so:
sirneij@pop-os ~/D/Projects> poetry new peer_to_peer
I named the project peer_to_peer. Change the directory into it. If poetry was used, you should have a file structure like:
server.py does exactly what its name implies. It is the entry point of the app. models.py will hold the app's "model" or more appropriately, in-memory database. urls.py routes all requests to the appropriate logic. handlers.py houses the logic of each route.
Step 3: Design the app's database
Let's employ the database-first approach by defining the kind of data our app needs. It will be a Python class with a single field which is a list of dictionaries, _user_data.
This field was made "private" but a "getter" property, user_data, gets its value for the "outside world" to use. We also defined a "setter", set_user_data method that appends data to it. There was a return_user method which searches the "database" for a user via the "unique" username (or name) and returns such user in case it's found. Pretty basic!
The simple script above is the app's entry point. It's a two-liner housed by the app function. This function takes the environ and start_reponse arguments, the requirements for gunicorn apps as defined here. The environ serves as the "request" object which contains all the details of all incoming requests to the app. As for the start_reponse, it defines the response's status code and headers. This function returns an Iterator of bytes. For data consistency, we passed only one instance of the User model defined previously to the url_hadndlers, housed in the urls.py file. The content of which is shown below:
# peer_to_peer/urls.py
importjsonfrompeer_to_peer.handlersimport(add_money,add_user,check_balance,index,transfer_money_out,transfer_money_to_user,)frompeer_to_peer.modelsimportUserdefurl_handlers(environ,start_reponse,user:User):path=environ.get('PATH_INFO')ifpath.endswith('/'):path=path[:-1]ifpath=='':context=index(environ,user)data=json.dumps(context.get('data'))ifcontext.get('data')elsejson.dumps(context.get('error'))status=context['status']elifpath=='/add-user':context=add_user(environ,user)data=json.dumps(context.get('data'))ifcontext.get('data')elsejson.dumps(context.get('error'))status=context['status']elifpath=='/add-money':context=add_money(environ,user)data=json.dumps(context.get('data'))ifcontext.get('data')elsejson.dumps(context.get('error'))status=context['status']elifpath=='/check-balance':context=check_balance(environ,user)data=json.dumps(context.get('data'))ifcontext.get('data')elsejson.dumps(context.get('error'))status=context['status']elifpath=='/transfer-money-to-user':context=transfer_money_to_user(environ,user)data=json.dumps(context.get('data'))ifcontext.get('data')elsejson.dumps(context.get('error'))status=context['status']elifpath=='/transfer-money-out':context=transfer_money_out(environ,user)data=json.dumps(context.get('data'))ifcontext.get('data')elsejson.dumps(context.get('error'))status=context['status']else:data,status=json.dumps({'error':'404 Not Found'}),'400 Not FOund'data=data.encode('utf-8')content_type='application/json'ifint(status.split('')[0])<400else'text/plain'response_headers=[('Content-Type',content_type),('Content-Length',str(len(data)))]start_reponse(status,response_headers)returndata
If you are familiar with any of the Python web frameworks mentioned earlier, this is equivalent to how requests are being routed. The environ contains, among many other things, the PATH_INFO which represents the URL entered into your browser. From the path info, we tried calling different logic as contained in the handlers.py, to be discussed soon. For each path, we turn the data or error returned into JSON using python's JSON module and then extract the status of the request. Later on, the data were encoded to be utf-8-compliant and the headers were set accordingly. Now to the handlers.py:
# peer_to_peer/handlers.py
fromtypingimportAnyfromurllibimportparsefrompeer_to_peer.modelsimportUserdefindex(environ,user:User)->dict[str,Any]:"""Display the in-memory data to users."""request_params=dict(parse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query))context={'status':'200 Ok'}ifrequest_paramsandrequest_params.get('name').replace('\'','').lower()=='admin':user_data=user.user_datafordatainuser_data:ifdata:ifdata.get('password'):data.pop('password')context['data']=user_dataelifrequest_paramsandrequest_params.get('name').replace('\'','').lower():current_user=user.return_user(request_params.get('name').replace('\'','').lower())context['data']=current_userelse:context['data']='You cannot just view this page without a `name` query parameter.'returncontextdefadd_user(environ,user:User)->dict[str,Any]:"""Use query parameters to add users to the in-memory data."""ifparse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query)=='':return{'error':'405 Method Not Allowed','status':'405 Method Not Allowed'}request_meta_query=dict(parse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query))ifnotrequest_meta_query:return{'error':'Query must be provided.','status':'405 Method Not Allowed'}user_data=user.user_dataifnotany(d.get('username')==request_meta_query.get('name').replace('\'','').lower()fordinuser_data):ifall(notdatafordatainuser_data):user.set_user_data({'id':1,'username':request_meta_query.get('name').replace('\'','').lower(),'password':request_meta_query.get('password'),})else:user.set_user_data({'id':user_data[-1]['id']+1,'username':request_meta_query.get('name').replace('\'','').lower(),'password':request_meta_query.get('password'),})else:return{'error':f'A user with username, {request_meta_query.get("name")}, already exists.','status':'409 Conflict',}context={'data':user_data[-1],'status':'200 Ok'}returncontextdefadd_money(environ,user:User)->dict[str,Any]:"""Use query parameters to add money to a user's account to the in-memory data."""ifparse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query)=='':return{'error':'405 Method Not Allowed','status':'405 Method Not Allowed'}request_meta_query=dict(parse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query))ifnotrequest_meta_query:return{'error':'Query must be provided.','status':'405 Method Not Allowed'}context={'status':'200 Ok'}user_data=user.return_user(request_meta_query.get('name').replace('\'','').lower())ifuser_data:ifuser_data['password']==request_meta_query.get('password'):user_data['balance']=user_data.get('balance',0.0)+float(request_meta_query.get('amount'))context['data']=user_dataelse:return{'error':'You are not authorized to add money to this user\'s balance.','status':'401 Unauthorized',}else:return{'error':'A user with that name does not exist.','status':'404 Not Found'}returncontextdefcheck_balance(environ,user:User)->dict[str,Any]:"""Use query parameters to check a user's account balance to the in-memory data."""ifparse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query)=='':return{'error':'405 Method Not Allowed','status':'405 Method Not Allowed'}request_meta_query=dict(parse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query))ifnotrequest_meta_query:return{'error':'Query must be provided.','status':'405 Method Not Allowed'}context={'status':'200 Ok'}user_data=user.return_user(request_meta_query.get('name').replace('\'','').lower())ifuser_data:password=request_meta_query.get('password')ifpassword:ifuser_data['password']==password:context['data']={'balance':user_data.get('balance',0.0)}else:return{'error':'You are not authorized to check this user\'s balance.','status':'401 Unauthorized',}else:return{'error':'You must provide the user\'s password to check balance.','status':'401 Unauthorized',}else:return{'error':'A user with that name does not exist.','status':'404 Not Found'}returncontextdeftransfer_money_to_user(environ,user:User)->dict[str,Any]:"""Use query parameters to transfer money from a user to another."""ifparse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query)=='':return{'error':'405 Method Not Allowed','status':'405 Method Not Allowed'}request_meta_query=dict(parse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query))ifnotrequest_meta_query:return{'error':'Query must be provided.','status':'405 Method Not Allowed'}context={'status':'200 Ok'}user_data=user.return_user(request_meta_query.get('from_name').replace('\'','').lower())ifuser_data:ifuser_data['password']==request_meta_query.get('from_password'):ifrequest_meta_query.get('amount')anduser_data.get('balance',0.0)>=float(request_meta_query.get('amount')):beneficiary=user.return_user(request_meta_query.get('to_name').replace('\'','').lower())ifbeneficiary:beneficiary['balance']=beneficiary.get('balance',0.0)+float(request_meta_query['amount'])user_data['balance']=user_data['balance']-float(request_meta_query['amount'])context['data']=f'A sum of ${float(request_meta_query["amount"])} was successfully transferred to {request_meta_query.get("to_name")}.'else:return{'error':'The user you want to credit does not exist.','status':'404 Not Found',}else:return{'error':'You either have insufficient funds or did not include `amount` as query parameter.','status':'404 Not Found',}else:return{'error':'You are not authorized to access this user\'s account.','status':'401 Unauthorized',}else:return{'error':'A user with that name does not exist.','status':'404 Not Found'}returncontextdeftransfer_money_out(environ,user:User)->dict[str,Any]:"""Use query parameters to transfer money out of this app."""ifparse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query)=='':return{'error':'405 Method Not Allowed','status':'405 Method Not Allowed'}request_meta_query=dict(parse.parse_qsl(parse.urlsplit(environ.get('RAW_URI')).query))ifnotrequest_meta_query:return{'error':'Query must be provided.','status':'405 Method Not Allowed'}context={'status':'200 Ok'}user_data=user.return_user(request_meta_query.get('name').replace('\'','').lower())ifuser_data:ifuser_data.get('password')==request_meta_query.get('password'):ifrequest_meta_query.get('amount')anduser_data.get('balance')>=float(request_meta_query.get('amount')):user_data['balance']=user_data['balance']-float(request_meta_query['amount'])context['data']=f'A sum of ${float(request_meta_query["amount"])} was successfully transferred to {request_meta_query.get("to_bank")}.'else:return{'error':'You either have insufficient funds or did not include `amount` as query parameter.','status':'404 Not Found',}else:return{'error':'You are not authorized to access this user\'s account.','status':'401 Unauthorized',}else:return{'error':'A user with that name does not exist.','status':'404 Not Found'}returncontext
That's lengthy! However, taking a closer look will reveal that these lines are basic python codes. Each route gets and parses the query parameter(s) included by the user(s). If no parameter was included, an appropriate error will be returned. In case there is/are query parameter(s), different simple logic that manipulates the in-memory data we have was implemented. All pretty simple if looked at.
Step 5: Running the app
Having gone through the app's build-up, we can now run it using: