Turnout has always had one front door: its pages. This part opens two more. A public API lets anyone's website or phone app list a club's upcoming events. A protected API lets a club's own door scanner check people in, without a person signing in to Turnout.
Both are built with ORDS, Oracle REST Data Services, the same web listener that has been serving your APEX pages since Lab 1. And both call the same TO_ packages as the pages do, so an API can't break a rule the app enforces.
| Lesson | You'll build |
|---|---|
| 14.1 | A design: resources, URLs, verbs, status codes |
| 14.2 | Your schema, REST-enabled |
| 14.3 | GET /turnout/v1/events/, by hand |
| 14.4 | The rest of the API from one script, and an OAuth2 client for the door |
| 14.5 | Calls with a token, from SQL Commands, and the errors you get without one |
| 14.6 | The API's OpenAPI description, in Swagger UI |
Before you start
You need the Turnout tables, sample data and packages: Parts 2 and 3. The app itself isn't needed for this part, though it's nice to have open to compare. Any part's Finished app from Part 4 on installs them as supporting objects.
Refresh the sample data. In SQL Workshop › SQL Scripts, run turnout_seed.sql again so the sample events are in the future.
How you'll call the API. A browser's address bar is enough for the public GETs. For the organizer routes you need to send a token, and on apex.oracle.com the simplest place to do that is SQL Commands itself: APEX_WEB_SERVICE calls your API over HTTPS exactly as an outside program would. Command-line tools like curl work against your own ORDS server, but apex.oracle.com sits behind a content network that blocks them: curl just hangs, or gets a 403 page. The downloads include the curl versions for when you run ORDS yourself.
Lesson 14.1 Design before you build
Outcome: you can read an API design and say why each URL, verb and status code is what it is.
An API is a promise. Once someone's app calls /events/, you can't rename it without breaking them, so it pays to decide the shape first.
Resources, not actions. A URL names a thing; the HTTP verb says what to do with it.
| Verb and URL | Means | Who |
|---|---|---|
GET /turnout/v1/events/ |
Upcoming public events, 25 at a time | Anyone |
GET /turnout/v1/events/{club}/{event} |
One event, by its two slugs | Anyone |
GET /turnout/v1/organizer/events/{id}/rsvps |
Who's coming to one of your events | A club's client |
POST /turnout/v1/organizer/events/{id}/check-in |
Check a ticket in | A club's client |
Three decisions are worth naming:
v1in the path. When version 2 changes a field, version 1 keeps working for the apps that use it.- Slugs in public URLs.
/events/tampa-runners/saturday-long-runis readable and stable; this is why Lesson 2.3 made slugs unique per club. - One prefix for everything private. Every protected route starts with
organizer/, so one ORDS privilege can guard them all. A new route under it is protected the day it's added.
Status codes tell the caller what happened, before it reads a word:
| Code | Meaning | When Turnout sends it |
|---|---|---|
| 200 | OK | The data, or the check-in worked |
| 400 | Bad request | A rule said no: "That ticket isn't for this event." |
| 401 | Unauthorized | No token, or an expired one (ORDS sends this for you) |
| 403 | Forbidden | A token, but not for this club |
| 404 | Not found | No such event |
The API is another layer-4 caller. Part 6 put the last check in the packages, "so a crafted request, a bug in a page, a future REST API" can't get around it. This is that future API. The check-in handler calls to_rsvp_api.check_in, the same function as the check-in page, and that function checks the caller organizes the club. The API adds nothing it could get wrong.
Check yourselfWhy is check-in a POST and not a GET?
It changes data. GET must be safe to repeat, cache and prefetch; a browser or proxy may send it again on its own.
Lesson 14.2 REST-enable your schema
Outcome: ORDS knows your schema, and you know its base URL.
On apex.oracle.com your schema is REST-enabled already. To see it, open SQL Workshop: the SQL Developer Web tile says "Configure schema REST access". Click it, and the dialog lists your schema with a Configure button. Close it again; you won't need SQL Developer Web for this part.
What you need is the schema's alias, the part of the URL that stands for your schema. In SQL Commands:
select pattern, status from user_ords_schemas
It shows one row, for example yourworkspace and ENABLED. Your base URL is https://oracleapex.com/ords/ plus that alias plus /. Every URL in this part starts there.
On your own database, where nobody has enabled it yet, the query returns no rows. Enable it once:
begin
ords.enable_schema(p_enabled => true, p_url_mapping_type => 'BASE_PATH',
p_url_mapping_pattern => 'turnout');
commit;
end;
Check: open the base URL plus metadata-catalog/ in a browser. You get a small JSON document, not an error page: ORDS is answering for your schema.
Lesson 14.3 The first endpoint, by hand
Outcome: GET /turnout/v1/events/ returns upcoming events as JSON, and you know where each part of it lives.
First, the shape. The API reads a view, never the tables: the view's columns are the promise, and the tables can change underneath without breaking anyone. Run this in SQL Commands:
create or replace view to_api_events_v as
select c.slug as club,
e.slug as event,
c.name as club_name,
e.title,
e.description,
to_char(e.starts_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as starts_at,
to_char(e.ends_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as ends_at,
c.time_zone,
to_char(e.starts_at at time zone c.time_zone, 'YYYY-MM-DD HH24:MI') as starts_local,
v.name as venue,
e.capacity,
to_rsvp_api.spots_left(e.id) as spots_left,
e.starts_at as sort_key
from to_events e
join to_clubs c on c.id = e.club_id
left join to_venues v on v.id = e.venue_id
where e.status = 'PUBLISHED'
and c.status = 'ACTIVE'
and c.visibility = 'PUBLIC'
and e.ends_at > systimestamp
Times go out in UTC with a Z, the format every programming language reads, plus starts_local in the club's own zone for people. And spots_left comes from the same function as the badges on Discover, so the API and the app can never disagree.
Now the endpoint. ORDS organizes an API in three levels, and the ORDS package has one call for each:
| Level | What it is | Turnout's |
|---|---|---|
| Module | The API, with a base path and a version | turnout.v1 at /turnout/v1/ |
| Template | A URL pattern under the base path | events/ |
| Handler | What one verb does on that URL | GET: run a query, 25 rows a page |
Run this in SQL Commands:
begin
ords.define_module(
p_module_name => 'turnout.v1',
p_base_path => '/turnout/v1/',
p_items_per_page => 25,
p_status => 'PUBLISHED',
p_comments => 'Turnout public API, version 1');
ords.define_template(
p_module_name => 'turnout.v1',
p_pattern => 'events/');
ords.define_handler(
p_module_name => 'turnout.v1',
p_pattern => 'events/',
p_method => 'GET',
p_source_type => ords.source_type_collection_feed,
p_items_per_page => 25,
p_source => q'[
select club, event, club_name, title, starts_at, ends_at, time_zone,
starts_local, venue, capacity, spots_left
from to_api_events_v
order by sort_key]');
commit;
end;
source_type_collection_feed means "run this query and return the rows as a JSON list, a page at a time". ORDS writes the JSON; you write the SQL.
Run it once. A second run stops with ORA-00060 or ORA-00001: the module is already there, and ORDS on apex.oracle.com can't redefine a module in place. The script in Lesson 14.4 checks first, so it's the one to run again.
Check: open your base URL plus turnout/v1/events/ in a browser. You get a JSON document with an items array of events, soonest first, "hasMore":true, and at the bottom a link with "rel":"next" ending in ?offset=25. Follow it and you get the next page.
Check yourselfWhy does the query sort, when the caller could?
Pagination. Page 2 only makes sense if every request orders the rows the same way.
Lesson 14.4 The rest of the API, and a client for the door
Outcome: the whole API exists, the organizer routes are locked behind OAuth2, and Tampa Runners has a client id and secret for its door scanner.
You've built one endpoint by hand. The others follow the same three levels, so a script makes them: download part14_rest_api.sql and run it in SQL Workshop › SQL Scripts. It finds the module you made in Lesson 14.3 and adds to it, so it's safe to run again. Then see what it made:
select t.uri_template, h.method, h.source_type
from user_ords_modules m
join user_ords_templates t on t.module_id = m.id
join user_ords_handlers h on h.template_id = t.id
where m.name = 'turnout.v1'
order by t.uri_template, h.method
| It made | What for |
|---|---|
Template events/:club/:event, GET |
One event. :club and :event arrive as bind variables |
TO_API_CLIENTS table |
Which club each client works for, and which organizer it acts as |
TO_API package |
begin_call, end_call and http_status, used by every organizer handler |
Templates under organizer/, GET rsvps and POST check-in |
The protected routes |
Role and privilege turnout.organizer |
Protects the pattern /turnout/v1/organizer/* |
How the protection works. A privilege protects URL patterns; a role is what a caller must have to pass it. ORDS checks the token before your code runs. No token, no handler: just a 401.
A client is a program, not a person. The door scanner signs in with client credentials: an id and a secret, swapped for a token that lasts an hour. It has no email and no APP_USER, so TO_SECURITY would see nobody. That's what TO_API_CLIENTS is for: the client tampa-runners-door is tied to Tampa Runners and acts as Priya, one of its organizers. Each organizer handler starts like this:
l_club := to_api.begin_call(:current_user); -- look up the client, act as Priya,
-- and check she still organizes the club
...
to_api.end_call; -- and stop acting, always
ORDS fills in :current_user from the token: for a client, it's the client's id, the random-looking string, not its name. TO_API.client_name looks the id up in USER_ORDS_CLIENTS to find the name, the key of TO_API_CLIENTS. If Priya stops being an organizer, the client stops working, with no change to the API.
Why end_call is in the exception handler too. ORDS keeps a pool of database sessions and hands the next request whichever is free. Package variables, like "acting as Priya", survive in that session. A handler that forgot to stop acting after an error would hand Priya's rights to the next caller. So the handler's exception block calls to_api.end_call before anything else.
Now make the client. Download part14_oauth_client.sql and run it. Its last statement shows the client id and client secret. Treat the secret as a password for a program. You won't need to copy them for this part: Lesson 14.5's script reads them from ORDS. A real door scanner would keep them in its own settings.
Check: in SQL Commands, select name from user_ords_privileges lists turnout.organizer, and select name from user_ords_roles lists the role of the same name.
Lesson 14.5 Call it with a token
Outcome: you get a token, use it, and see each answer the API can give.
The door scanner is a program, so play one. Download part14_try_it.sql, paste it into SQL Commands and Run. It does what the scanner would, over HTTPS, through ORDS's front door:
- Calls the RSVP list without a token.
- Swaps the client id and secret for a token at
/oauth/token, withapex_web_service.oauth_authenticate. - Calls the RSVP list with the token, in an
Authorization: Bearerheader. - Checks Priya in, then tries the same ticket again.
- Tries a Bay Chess Club ticket at a Tampa Runners event.
Check-in only works from an hour before the event starts until it ends, as in Lesson 7.12. Before you run the script, move Tuesday Track Intervals to now with the update from that lesson.
Check: the output reads like this.
| Step | Status | Reply |
|---|---|---|
| 1. No token | 401 | "code": "Unauthorized": ORDS stopped it; your code never ran |
| 2. Token | token: yes |
|
| 3. RSVPs | 200 | Hannah, Omar and Priya going; Grace and Lucas waiting, in order |
| 4. Check in | 200, then 400 | {"checked_in":"Priya Raman"}, then "This ticket was already checked in." |
| 5. Wrong club | 400 | "That ticket isn't for this event." |
The 400 messages are the package's own words. The rules are the check-in page's rules, because the code is the check-in page's code.
The same calls with curl. On your own ORDS server (not apex.oracle.com, see Before you start), part14_requests.txt has every call as a curl command, starting with the token:
curl -i -u 'CLIENT_ID:CLIENT_SECRET' -d 'grant_type=client_credentials' BASE/oauth/token
Check yourselfThe token is stolen. What can the thief do, and for how long?
Only what the role allows, organizer routes, and only for Tampa Runners, as Priya. For at most an hour; delete the client, or remove Priya as organizer, and it stops sooner.
Lesson 14.6 Describe it with OpenAPI
Outcome: a machine-readable description of the API, and a page that shows it.
ORDS writes an OpenAPI description of every published module for you. Open your base URL plus open-api-catalog/turnout/v1/ in a browser: a JSON document listing every path, verb and parameter, with "openapi":"3.0.0" at the top.
To read it as a page:
- In the browser, select all of that JSON and copy it.
- Go to editor.swagger.io, clear the sample on the left, and paste. Swagger converts JSON if it asks.
- The right side lists the four endpoints. Open GET /events/ to see its parameters and the replies it describes.
Try it out in Swagger won't reach your API, and that's on purpose: a page on another website may only call an API that says it allows that website. This is CORS, and ORDS allows nobody by default. To let a site's pages call the public routes, an ORDS module can name the origins it trusts (ords.set_module_origins_allowed).
The description is only as good as what you tell ORDS. The module's comments, "Turnout public API, version 1", became the description's description. Handlers can have comments too: add them, and a developer reading the description knows what spots_left means without asking you.
Check yourselfWhy publish the description at all?
Other developers, and their tools, can generate a client for your API from it instead of reading your code.
Checkpoint
Turnout has an API: two public endpoints anyone can call, and two organizer endpoints that only a club's client can, every one of them going through the same packages as the pages.
| Download | What it is |
|---|---|
| The API | part14_rest_api.sql: the view, TO_API_CLIENTS, TO_API, the module, the role and the privilege |
| The door client | part14_oauth_client.sql: the OAuth2 client for Tampa Runners |
| Try it | part14_try_it.sql: Lesson 14.5's calls, run from SQL Commands |
| Requests | part14_requests.txt: the same calls as curl commands, for your own ORDS server |
The app itself didn't change in this part, so there's no new Finished app: Part 13's is still current.
Going further.
- Add GET /turnout/v1/clubs/ for active public clubs, with events as a link to each club's events.
- Give the door client its own role, turnout.door, that can check in but not read RSVPs. Which privilege pattern do you need?
- ORDS can AutoREST-enable a view in one click. Try it on TO_API_EVENTS_V, and compare what you get with what you designed.
Downloads for Part 14
Everything this part uses. Each file is plain text you can open in any editor before you run it.
- The APIpart14_rest_api.sqlLesson 14.4: the view, TO_API_CLIENTS, TO_API, the turnout.v1 module, the role and the privilege. Safe to run again
- The door clientpart14_oauth_client.sqlLesson 14.4: the OAuth2 client for Tampa Runners' door scanner. Run once
- Try itpart14_try_it.sqlLesson 14.5: get a token and call the organizer routes, from SQL Commands
- Requestspart14_requests.txtThe same calls as curl commands, for your own ORDS server
- Start overturnout_reset.sqlRemoves every TO_ object from your schema
Part 14 quiz
5 questions. Get 4 right to pass. You can retake it as often as you like.