SlideShare a Scribd company logo
Relational DB to RESTful API
Taking the database seriously
Most web frameworks treat
the DB as a dumb store
This helps give them broad
appeal
What if we take a stand
instead?
??
? ?
?
Taking the database seriously
Is PostgreSQL powerful and
flexible enough to replace
the custom API server?
That’s my experiment
The Traditional Web API Stack
Your App
Web Server
Database
The Traditional Web API Stack
Your App
Web Server
Database
PostgREST
A no-configuration canonical
mapping from DB to HTTP
Talk Overview
The Traditional API Server (brief)
Live demo of PostgREST
What’s the SQL? How did
it do that?
The Traditional App
Handmade Nested
Routes
Controllers
Imperative code
ORM
Logic divorced from
data
What’s in an app?
HTTP request handling
Authentication
Authorization
Request Parsing
Request Validation
Database Communication
Database Response Handling
HTTP Response Building
With error handling woven
throughout...
Maintaining bespoke APIs gets old
“Most APIs look the
same, some have
icing, some have
fondant, some are
vanilla, some
chocolate. At the
core they’re all still
cakes.” -- Jett
Durham
Problem 1: Boilerplate
Want to add a new route?
Create model
Add each CRUD action
Check permissions
Support filtering, pagination
Special routes for joining data
Problem 2: No Single Source of Truth
Constraints are removed
from DB
No longer enforced
continuously + uniformly
Imperative code means
human must write docs
Authorization is per-
controller rather than
Problem 3: Hierarchy
Your info is relational, your routes
hierarchical
Say projects have parts and vice
versa.
Need routes for parts by project
and project by parts?
Other people recognize the
problem, hence GraphQL
Demo Time!
We’ll use the Pagila example database
It was ported from MySQL “Sakila”
It’s a DVD store with films, rentals, customers, payments,
categories, actors etc
A Tour of PostgREST
Security - Roles for Authorization
Anonymous Authenticator User(s)
Security - JWT for Authentication
YES
NO
Security - Roles in SQL
CREATE ROLE authenticator NOINHERIT LOGIN;
CREATE ROLE anon;
CREATE ROLE worker;
GRANT anon, worker TO authenticator;
Switching to a role
BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE;
SET LOCAL ROLE 'worker';
SET LOCAL "postgrest.claims.id" = 'jdoe';
-- ...
COMMIT;
Row-Level Security
PostgreSQL 9.5+ allows restricting access to individual rows
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
drop policy if exists authors_eigenedit on posts;
create policy authors_eigenedit on posts
using (true)
with check (
author = basic_auth.current_email()
);
Let’s see it in action
External Actions
You can’t do everything
inside SQL
How do you
● Send an email?
● Call a 3rd party
service?
LISTEN / NOTIFY
How to version the API?
So far OK but… but I don’t
want to couple the internal
schema with an API!
How to encapsulate true
schema?
How to version specific
endpoints?
Use database schemas
Internal Schema V1
table1
table2
table3
view2
proc
view2
HTTP Interface is Flexible
postgrest --schema v1
postgrest --schema v2,v1
postgrest --schema v3,v2,v1
Accept: application/json;
version=2
OR
GET /v2/...
Use the schema search-path
SET search_path TO v2, v1;
How does it work inside?
Warning: Boring / Cool
Generating the payload in 100% SQL
WITH pg_source AS
(SELECT "public"."festival".* FROM "public"."festival")
SELECT
(SELECT pg_catalog.count(1) FROM "public"."festival") AS total_result_set,
pg_catalog.count(t) AS page_total,
NULL AS header,
array_to_json(array_agg(row_to_json(t)))::character VARYING AS body
FROM
(SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
Adding a filter
WITH pg_source AS
(SELECT "public"."festival".* FROM "public"."festival"
WHERE "public"."festival"."name" LIKE '%fun%'::UNKNOWN)
SELECT
(SELECT pg_catalog.count(1) FROM "public"."festival"
WHERE "public"."festival"."name" LIKE '%fun%'::UNKNOWN) AS total_result_set,
pg_catalog.count(t) AS page_total,
NULL AS header,
array_to_json(array_agg(row_to_json(t)))::character varying AS body
FROM
(SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
Optimistic cast
Or without a global count
WITH pg_source AS
(SELECT "public"."festival".* FROM "public"."festival")
SELECT
NULL AS total_result_set,
pg_catalog.count(t) AS page_total,
NULL AS header,
array_to_json(array_agg(row_to_json(t)))::character varying AS body
FROM
(SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
Creating CSV body
-- ...
(SELECT string_agg(a.k, ',')
FROM
(SELECT json_object_keys(r)::TEXT AS k
FROM
(SELECT row_to_json(hh) AS r
FROM pg_source AS hh LIMIT 1) s
) a
) || 'n' ||
coalesce(
string_agg(
substring(t::text, 2, length(t::text) - 2), 'n'
), ''
)
-- ...
First row
Column names
Remove quotes
Embedding a relation
WITH pg_source AS
(SELECT "public"."film"."id", row_to_json("director".*) AS "director"
FROM "public"."film"
LEFT OUTER JOIN
(SELECT "public"."director".*
FROM "public"."director") AS "director"
ON "director"."name" = "film"."director")
SELECT
(SELECT pg_catalog.count(1) FROM "public"."film") AS total_result_set,
pg_catalog.count(t) AS page_total,
NULL AS header,
array_to_json(array_agg(row_to_json(t)))::character varying AS body
FROM
(SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
Embed row as field
Key(s) detected
ELSE
CASE
WHEN t.typelem <> 0::oid AND t.typlen = (-1)
THEN 'ARRAY'::text
WHEN nt.nspname = 'pg_catalog'::name THEN
format_type(a.atttypid, NULL::integer)
ELSE 'USER-DEFINED'::text
END
END::information_schema.character_data AS data_type,
information_schema._pg_char_max_length(information_schema._pg_truetypid(a.*,
t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS character_maximum_length,
information_schema._pg_char_octet_length(information_schema._pg_truetypid(a.
*, t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS character_octet_length,
information_schema._pg_numeric_precision(information_schema._pg_truetypid(a.
*, t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS numeric_precision,
information_schema._pg_numeric_precision_radix(information_schema._pg_truety
pid(a.*, t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS numeric_precision_radix,
information_schema._pg_numeric_scale(information_schema._pg_truetypid(a.*,
t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS numeric_scale,
information_schema._pg_datetime_precision(information_schema._pg_truetypid(a
.*, t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.cardinal_number AS datetime_precision,
information_schema._pg_interval_type(information_schema._pg_truetypid(a.*,
t.*), information_schema._pg_truetypmod(a.*,
t.*))::information_schema.character_data AS interval_type,
NULL::integer::information_schema.cardinal_number AS
interval_precision,
NULL::character varying::information_schema.sql_identifier
AS character_set_catalog,
NULL::character varying::information_schema.sql_identifier
AS character_set_schema,
NULL::character varying::information_schema.sql_identifier
AS character_set_name,
SELECT DISTINCT
info.table_schema AS schema,
info.table_name AS table_name,
info.column_name AS name,
info.ordinal_position AS position,
info.is_nullable::boolean AS nullable,
info.data_type AS col_type,
info.is_updatable::boolean AS updatable,
info.character_maximum_length AS max_len,
info.numeric_precision AS precision,
info.column_default AS default_value,
array_to_string(enum_info.vals, ',') AS enum
FROM (
/*
-- CTE based on information_schema.columns to remove the owner filter
*/
WITH columns AS (
SELECT current_database()::information_schema.sql_identifier AS table_catalog,
nc.nspname::information_schema.sql_identifier AS table_schema,
c.relname::information_schema.sql_identifier AS table_name,
a.attname::information_schema.sql_identifier AS column_name,
a.attnum::information_schema.cardinal_number AS ordinal_position,
pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default,
CASE
WHEN a.attnotnull OR t.typtype = 'd'::"char" AND t.typnotnull THEN 'NO'::text
ELSE 'YES'::text
END::information_schema.yes_or_no AS is_nullable,
CASE
WHEN t.typtype = 'd'::"char" THEN
CASE
WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text
WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype,
NULL::integer)
ELSE 'USER-DEFINED'::text
END
Matching up foreign keys
Deleting an item
WITH pg_source AS
(DELETE FROM "test"."items"
WHERE "test"."items"."id" = '1'::unknown
RETURNING "test"."items".*)
SELECT
'' AS total_result_set,
pg_catalog.count(t) AS page_total,
'',
''
FROM
(SELECT 1 FROM pg_source) t
Learning More
Read the Docs
http://postgrest.com
github.com / begriffs / postgrest

More Related Content

A Tour of PostgREST

  • 1. Relational DB to RESTful API
  • 2. Taking the database seriously Most web frameworks treat the DB as a dumb store This helps give them broad appeal What if we take a stand instead? ?? ? ? ?
  • 3. Taking the database seriously Is PostgreSQL powerful and flexible enough to replace the custom API server? That’s my experiment
  • 4. The Traditional Web API Stack Your App Web Server Database
  • 5. The Traditional Web API Stack Your App Web Server Database PostgREST A no-configuration canonical mapping from DB to HTTP
  • 6. Talk Overview The Traditional API Server (brief) Live demo of PostgREST What’s the SQL? How did it do that?
  • 7. The Traditional App Handmade Nested Routes Controllers Imperative code ORM Logic divorced from data
  • 8. What’s in an app? HTTP request handling Authentication Authorization Request Parsing Request Validation Database Communication Database Response Handling HTTP Response Building With error handling woven throughout...
  • 9. Maintaining bespoke APIs gets old “Most APIs look the same, some have icing, some have fondant, some are vanilla, some chocolate. At the core they’re all still cakes.” -- Jett Durham
  • 10. Problem 1: Boilerplate Want to add a new route? Create model Add each CRUD action Check permissions Support filtering, pagination Special routes for joining data
  • 11. Problem 2: No Single Source of Truth Constraints are removed from DB No longer enforced continuously + uniformly Imperative code means human must write docs Authorization is per- controller rather than
  • 12. Problem 3: Hierarchy Your info is relational, your routes hierarchical Say projects have parts and vice versa. Need routes for parts by project and project by parts? Other people recognize the problem, hence GraphQL
  • 13. Demo Time! We’ll use the Pagila example database It was ported from MySQL “Sakila” It’s a DVD store with films, rentals, customers, payments, categories, actors etc
  • 15. Security - Roles for Authorization Anonymous Authenticator User(s)
  • 16. Security - JWT for Authentication YES NO
  • 17. Security - Roles in SQL CREATE ROLE authenticator NOINHERIT LOGIN; CREATE ROLE anon; CREATE ROLE worker; GRANT anon, worker TO authenticator;
  • 18. Switching to a role BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE; SET LOCAL ROLE 'worker'; SET LOCAL "postgrest.claims.id" = 'jdoe'; -- ... COMMIT;
  • 19. Row-Level Security PostgreSQL 9.5+ allows restricting access to individual rows ALTER TABLE posts ENABLE ROW LEVEL SECURITY; drop policy if exists authors_eigenedit on posts; create policy authors_eigenedit on posts using (true) with check ( author = basic_auth.current_email() );
  • 20. Let’s see it in action
  • 21. External Actions You can’t do everything inside SQL How do you ● Send an email? ● Call a 3rd party service? LISTEN / NOTIFY
  • 22. How to version the API? So far OK but… but I don’t want to couple the internal schema with an API! How to encapsulate true schema? How to version specific endpoints?
  • 23. Use database schemas Internal Schema V1 table1 table2 table3 view2 proc view2
  • 24. HTTP Interface is Flexible postgrest --schema v1 postgrest --schema v2,v1 postgrest --schema v3,v2,v1 Accept: application/json; version=2 OR GET /v2/...
  • 25. Use the schema search-path SET search_path TO v2, v1;
  • 26. How does it work inside? Warning: Boring / Cool
  • 27. Generating the payload in 100% SQL WITH pg_source AS (SELECT "public"."festival".* FROM "public"."festival") SELECT (SELECT pg_catalog.count(1) FROM "public"."festival") AS total_result_set, pg_catalog.count(t) AS page_total, NULL AS header, array_to_json(array_agg(row_to_json(t)))::character VARYING AS body FROM (SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
  • 28. Adding a filter WITH pg_source AS (SELECT "public"."festival".* FROM "public"."festival" WHERE "public"."festival"."name" LIKE '%fun%'::UNKNOWN) SELECT (SELECT pg_catalog.count(1) FROM "public"."festival" WHERE "public"."festival"."name" LIKE '%fun%'::UNKNOWN) AS total_result_set, pg_catalog.count(t) AS page_total, NULL AS header, array_to_json(array_agg(row_to_json(t)))::character varying AS body FROM (SELECT * FROM pg_source LIMIT ALL OFFSET 0) t Optimistic cast
  • 29. Or without a global count WITH pg_source AS (SELECT "public"."festival".* FROM "public"."festival") SELECT NULL AS total_result_set, pg_catalog.count(t) AS page_total, NULL AS header, array_to_json(array_agg(row_to_json(t)))::character varying AS body FROM (SELECT * FROM pg_source LIMIT ALL OFFSET 0) t
  • 30. Creating CSV body -- ... (SELECT string_agg(a.k, ',') FROM (SELECT json_object_keys(r)::TEXT AS k FROM (SELECT row_to_json(hh) AS r FROM pg_source AS hh LIMIT 1) s ) a ) || 'n' || coalesce( string_agg( substring(t::text, 2, length(t::text) - 2), 'n' ), '' ) -- ... First row Column names Remove quotes
  • 31. Embedding a relation WITH pg_source AS (SELECT "public"."film"."id", row_to_json("director".*) AS "director" FROM "public"."film" LEFT OUTER JOIN (SELECT "public"."director".* FROM "public"."director") AS "director" ON "director"."name" = "film"."director") SELECT (SELECT pg_catalog.count(1) FROM "public"."film") AS total_result_set, pg_catalog.count(t) AS page_total, NULL AS header, array_to_json(array_agg(row_to_json(t)))::character varying AS body FROM (SELECT * FROM pg_source LIMIT ALL OFFSET 0) t Embed row as field Key(s) detected
  • 32. ELSE CASE WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer) ELSE 'USER-DEFINED'::text END END::information_schema.character_data AS data_type, information_schema._pg_char_max_length(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS character_maximum_length, information_schema._pg_char_octet_length(information_schema._pg_truetypid(a. *, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS character_octet_length, information_schema._pg_numeric_precision(information_schema._pg_truetypid(a. *, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_precision, information_schema._pg_numeric_precision_radix(information_schema._pg_truety pid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_precision_radix, information_schema._pg_numeric_scale(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_scale, information_schema._pg_datetime_precision(information_schema._pg_truetypid(a .*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS datetime_precision, information_schema._pg_interval_type(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.character_data AS interval_type, NULL::integer::information_schema.cardinal_number AS interval_precision, NULL::character varying::information_schema.sql_identifier AS character_set_catalog, NULL::character varying::information_schema.sql_identifier AS character_set_schema, NULL::character varying::information_schema.sql_identifier AS character_set_name, SELECT DISTINCT info.table_schema AS schema, info.table_name AS table_name, info.column_name AS name, info.ordinal_position AS position, info.is_nullable::boolean AS nullable, info.data_type AS col_type, info.is_updatable::boolean AS updatable, info.character_maximum_length AS max_len, info.numeric_precision AS precision, info.column_default AS default_value, array_to_string(enum_info.vals, ',') AS enum FROM ( /* -- CTE based on information_schema.columns to remove the owner filter */ WITH columns AS ( SELECT current_database()::information_schema.sql_identifier AS table_catalog, nc.nspname::information_schema.sql_identifier AS table_schema, c.relname::information_schema.sql_identifier AS table_name, a.attname::information_schema.sql_identifier AS column_name, a.attnum::information_schema.cardinal_number AS ordinal_position, pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default, CASE WHEN a.attnotnull OR t.typtype = 'd'::"char" AND t.typnotnull THEN 'NO'::text ELSE 'YES'::text END::information_schema.yes_or_no AS is_nullable, CASE WHEN t.typtype = 'd'::"char" THEN CASE WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer) ELSE 'USER-DEFINED'::text END Matching up foreign keys
  • 33. Deleting an item WITH pg_source AS (DELETE FROM "test"."items" WHERE "test"."items"."id" = '1'::unknown RETURNING "test"."items".*) SELECT '' AS total_result_set, pg_catalog.count(t) AS page_total, '', '' FROM (SELECT 1 FROM pg_source) t
  • 34. Learning More Read the Docs http://postgrest.com
  • 35. github.com / begriffs / postgrest