This project is still under early development and is not fully production-ready, you probably should well understand how it works if you want to use it in production.
Wouldn't it be nice to be able to handle HTTP requests inside PostgreSQL? This is the tool that does just that.
Classic backend implementation using PostgreSQL as data store implements business logic on its side. However, in pursuit of production, optimization and rationalization, part of the business logic tends to be implemented on the database side in the form of triggers, stored procedures etc. So why not transfer all the business logic of your project to the database? In fact, this is a good idea in many cases, and it served as the starting point for the implementation of this project.
It is possible to implement business logic in stored procedures, but if you ever need to handle HTTP requests or implement a restful API for your database, you will need middleware. This project serves as one of such possible implementations.
To show you the basic idea, here is an example of a simple request handler. It is a PL/pgSQL procedure:
CREATE OR REPLACE FUNCTION www_main.index(IN _pgwt.request, OUT _pgwt.response)
LANGUAGE plpgsql
AS $$
--
-- Index page
--
-- uri: /
-- methods: GET
--
BEGIN
$2.body = 'Hello from PGWT!';
$2.headers = json_build_object('Content-Type', 'text/html');
END;
$$;
PGWT handles requests via Openresty because it offers the power of Nginx
and other useful things. You can control the request processing and modify the handler if you wish. You will also be able to implement specific endpoints in your API that PGWT is not intended for (e.g. file uploading/downloading).
While implementing a RESTful API, you may face a problem with several different zones (e.g. public and administrative) using common mechanisms for request processing, authentication etc. PGWT uses the term area
and the $pgwt_area
variable defined inside the Nginx location
accepted by the request handler. You can implement any number of nginx locations using a custom configuration for different areas. For PGWT, the API zone is represented as a database schema with the name www_[area]
, for example a4irkin/pgwt docker image is configured to handle only one area (main
), so the database needs a www_main
schema present.
Each HTTP request is passed to the pgwt.request()
function, where routing takes place based on the request type and URL path, as well as area
. After successful routing, the request goes to the API area handler function. You can customize the request handler: www_[area].request_handler(_pgwt.request, _pgwt.response)
. If you don't, the default handler will be used.
Every time you add or remove an endpoint handler function or change it, you need to call pgwt.init()
so PGWT can parse endpoint functions and rebuild a route table. The endpoint handler function header has a special format:
--
-- Endpoint description
--
-- uri: [regexp string]
-- methods: [GET|POST|PATCH|PUT|DELETE|OPTIONS]
-- custom_attributeN: value
--
The title goes first, then the endpoint attributes. Required attributes: uri
, methods
. You can define custom attributes for an area and get them in the area request handler function, I will showcase this in the examples.
You can install the PGWT request handler in the lua
directory of your Openresty server and implement an Nginx location
so that the PGWT can accept requests to the database. You can also use the ready-to-use a4irkin/pgwt docker image.
docker network create pgwt
docker run -itd \
--name postgres \
--network=pgwt \
-p 5432:5432 \
-e POSTGRES_PASSWORD=test \
postgres:latest
docker run -itd \
--name pgwt-openresty \
--network=pgwt \
-p 8080:8080 \
-e PGWT_CONN_HOST=postgres \
-e PGWT_CONN_DB=postgres \
-e PGWT_CONN_USER=postgres \
-e PGWT_CONN_PASSWORD=test \
a4irkin/pgwt:latest
You also need to install PGWT in PostgreSQL:
cd src
psql -h localhost -U postgres -1f deploy.sql -v schema_owner=postgres
You can find some examples in the examples directory of this project. Let's look at the simplest example of using PGWT. To do this, we will create a www_main
schema in the database. This schema will store the main area endpoint handlers:
CREATE SCHEMA www_main;
Let's define our first API endpoint function:
CREATE OR REPLACE FUNCTION www_main.first(IN _pgwt.request, OUT _pgwt.response)
LANGUAGE plpgsql
AS $$
--
-- First endpoint
--
-- uri: /first
-- methods: GET
--
DECLARE
a_arg1 text = $1.args->>'arg1';
BEGIN
$2.body = 'Hello, arg1 = '||COALESCE(a_arg1, 'null');
$2.headers = json_build_object('Content-Type', 'text/html');
END;
$$;
Now, we need PGWT to see our new function and build routes:
SELECT pgwt.init();
Now you can check this:
curl http://localhost:8080/first?arg1=works
As mentioned earlier, you can define custom attributes for functions which describe area endpoints by defining the func_attrs
table in the area
schema, inheriting it from pgwt.func_attrs
. Let's suppose some endpoints are private, for this we define the attribute priv
:
CREATE TABLE www_main.func_attrs() INHERITS (pgwt.func_attrs);
INSERT INTO www_main.func_attrs (nm, multiple, acceptable_values) VALUES
('priv', false, '{true}')
;
You should now be able to add a new priv
attribute to your endpoint functions:
CREATE OR REPLACE FUNCTION www_main.private(IN _pgwt.request, OUT _pgwt.response)
LANGUAGE plpgsql
AS $$
--
-- Private endpoint
--
-- uri: /private
-- methods: GET
-- priv: true
--
BEGIN
$2.body = json_build_object('message', 'This is a private endpoint');
END;
$$;
To implement authentication logic, we can add a custom request handler for the main area:
CREATE OR REPLACE FUNCTION www_main._request_handler(
IN _pgwt.request, OUT _pgwt.response
)
LANGUAGE plpgsql
AS $$
--
-- Custom request handler for main area
--
BEGIN
IF $1.route.func_attrs ? 'priv' AND NOT $1.cookies ? 'AUTH' THEN
PERFORM pgwt.http_error(401, 'Unauthorized');
END IF;
EXECUTE 'SELECT (r).* FROM '
||quote_ident('www_'||$1.area)||'.'
||quote_ident($1.route.proname)||'($1) r'
INTO $2
USING $1;
END;
$$;
Now, endpoint /private
can be accessed only if AUTH cookie exists.
pgwt.init()
returnsvoid
- (re)initialize pgwt routespgwt.error(a_msg text[, a_code text])
- generate user-level errorpgwt.error(a_cond boolean, a_msg text[, a_code text])
- generate user-level error if condition truepgwt.http_error(a_status int, a_msg text[, a_raw_error boolean = false])
- generate http errorpgwt.http_error(a_status int, a_msg json[, a_raw_error boolean = false])
- generate http errorpgwt.raise(a_msg text)
- raise exception, visible in response body
Composite type "_pgwt.request"
Column | Type | Collation | Nullable | Default
--------------------+-------------+-----------+----------+---------
area | text | | |
host | text | | |
path | text | | |
args | jsonb | | |
remote_addr | inet | | |
remote_host | text | | |
user_agent | text | | |
cookies | hstore | | |
body | json | | |
body_raw | text | | |
params | hstore | | |
method | text | | |
server_host | text | | |
server_addr | text | | |
headers | json | | |
route | _pgwt.route | | |
custom | jsonb | | |
resp_headers | json | | |
status | smallint | | |
body_bytes_sent | bigint | | |
request_completion | text | | |
Composite type "_pgwt.response"
Column | Type | Collation | Nullable | Default
----------+---------+-----------+----------+---------
body | text | | |
status | integer | | |
headers | jsonb | | |
commands | jsonb | | |
Table "_pgwt.route"
Column | Type | Collation | Nullable | Default
-------------------------+---------+-----------+----------+---------
id | text | | not null |
nspname | text | | not null |
proname | text | | not null |
area | text | | not null |
nm | text | | not null |
comments | text | | not null |
params | text[] | | not null |
regexp_path | text | | not null |
func_attrs | jsonb | | not null |
custom_request_handler | boolean | | not null | false
custom_response_handler | boolean | | not null | false
To make it easier to redeploy database schema, I separate functionality from data. I recommend that you take this approach too. Store all tables in schemas, for example: _data
, _usr
, _sys
, _etc
. Store functionality in schemas: data
, usr
, sys
, etc
. This way you can always redeploy functional schemas safely in one transaction. You can use my pgver script for this, which is also used by PGWT.
I recommend that you create a custom role:
CREATE ROLE nginx WITH LOGIN ENCRYPTED PASSWORD 'password';
GRANT USAGE ON SCHEMA pgwt TO nginx;
GRANT CONNECT TO DATABASE postgres TO nginx;
This role will only have access to PGWT functionality. By gaining access to the DB from this role, an attacker will not be able to do anything other than what is provided by your API.