Skip to content

Commit 578d643

Browse files
authored
Merge pull request #1419 from makeabilitylab/1268-rest-api
Add public read-only REST API at /api/v1/ (#1268)
2 parents b8e3a64 + 74055ce commit 578d643

10 files changed

Lines changed: 1012 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ Custom admin organization lives in `website/admin/admin_site.py` (`MakeabilityLa
100100
- `/media/publications/<filename>` is served by the custom `serve_pdf` view (not Django's static serve), which does **fuzzy filename matching** so stale external links to renamed PDFs still resolve. Don't replace it with a plain static route.
101101
- In `DEBUG=True`, `/media/...` is also served by Django's `serve()`. In production, the web server handles `/media/` directly.
102102

103+
### Public REST API (`website/api/`, #1268)
104+
105+
A public, **read-only** DRF API at `/api/v1/` over already-public content
106+
(publications, projects, grants, people, project leadership). Built on the
107+
already-bundled `djangorestframework` (previously an unused dependency). Code
108+
lives in the `website/api/` package (`serializers.py`, `views.py`, `urls.py`,
109+
`middleware.py`), mounted by the **root** URLconf (`makeabilitylab/urls.py`),
110+
configured by the `REST_FRAMEWORK` block in `settings.py`. GET-only, no auth, no
111+
throttle (data is already public); paginated (`?page_size=`, max 100); every
112+
payload uses absolute URLs. Cross-origin requests are allowed on `/api/` only
113+
via the in-repo `ApiCorsMiddleware` (no `django-cors-headers` dependency).
114+
`Person.email` is intentionally not serialized. Projects are gated to
115+
`is_visible=True`; the people list is scoped to actual members (those with a
116+
Position). When adding a resource, follow the existing viewset/serializer
117+
pattern and keep `v1` fields additive-only (breaking changes → `v2`). Full
118+
reference: `docs/API.md`. Tests: `website/tests/test_api.py`.
119+
103120
### Settings, config, and environment
104121

105122
- **Compose files per environment:** the servers run `docker-compose.yml` (test *and* prod — `makeabilitylabwebsite/rebuildanddeploy.sh` runs `docker compose up` with no `-f`, so it always picks the default `docker-compose.yml`; it only varies per-host env vars). Local dev runs `docker-compose-local-dev.yml` (passed explicitly with `-f`). `docker-compose-local-dev.yml` is **never** used on the servers.

docs/API.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Makeability Lab public REST API
2+
3+
A **public, read-only** JSON API over the lab's already-public content
4+
(publications, projects, grants, people, and project leadership). It lets
5+
external sites treat this website as the source of truth instead of duplicating
6+
content. Introduced in #1268.
7+
8+
- **Base URL:** `https://makeabilitylab.cs.washington.edu/api/v1/`
9+
(test server: `https://makeabilitylab-test.cs.washington.edu/api/v1/`)
10+
- **Format:** JSON. Read-only — only `GET`/`HEAD`/`OPTIONS`.
11+
- **Auth:** none. All data is already public on the site.
12+
- **Cross-origin:** enabled (`Access-Control-Allow-Origin: *`) on `/api/` only,
13+
so browser-side JavaScript can fetch it directly.
14+
- **Versioned:** everything lives under `/api/v1/`. See *Stability contract*.
15+
16+
Built on Django REST Framework. In local dev (`DEBUG=True`) the endpoints also
17+
render a **browsable HTML API** — just open them in a browser.
18+
19+
## Pagination
20+
21+
List endpoints are paginated (page-number style):
22+
23+
```json
24+
{ "count": 157, "next": "...?page=2", "previous": null, "results": [ ... ] }
25+
```
26+
27+
- `?page=<n>` — page number.
28+
- `?page_size=<n>` — items per page (default **25**, max **100**).
29+
30+
A "top 5 most recent" list is just `?page_size=5` on an endpoint whose default
31+
order is newest-first.
32+
33+
## Endpoints
34+
35+
### Publications — `GET /api/v1/publications/`
36+
37+
Default order: **newest first** (`-date`). Optional, combinable filters:
38+
39+
| Param | Example | Meaning |
40+
|-------------|------------------------|---------------------------------------|
41+
| `project` | `?project=sidewalk` | Publications attached to a project (by `short_name`). |
42+
| `author` | `?author=jonfroehlich` | Publications by a person (by `url_name`). |
43+
| `year` | `?year=2024` | Publications in a calendar year. |
44+
| `type` | `?type=Conference` | By venue type (`Conference`, `Journal`, `Poster`, …). |
45+
| `ordering` | `?ordering=title` | One of `date`, `-date`, `title`, `-title`. |
46+
47+
`GET /api/v1/publications/<id>/` adds a formatted `citation_html` and raw
48+
`bibtex`, plus `book_title`, `publisher`, `isbn`, `num_pages`, `peer_reviewed`.
49+
50+
**Example — a "Recent Publications" widget** (client-side, e.g. on an academic
51+
page):
52+
53+
```js
54+
const r = await fetch(
55+
"https://makeabilitylab.cs.washington.edu/api/v1/publications/" +
56+
"?author=jonfroehlich&page_size=5"
57+
);
58+
const { results } = await r.json();
59+
results.forEach(p => {
60+
// p.title, p.year, p.forum_name, p.authors[].name, p.pdf_url, p.official_url
61+
});
62+
```
63+
64+
### Projects — `GET /api/v1/projects/`
65+
66+
Only **publicly visible** projects (`is_visible=True`). Detail and
67+
sub-resources are keyed by `short_name`:
68+
69+
- `GET /api/v1/projects/<short_name>/` — summary, about, website, dates,
70+
keywords, umbrellas, thumbnail.
71+
- `GET /api/v1/projects/<short_name>/publications/` — the project's pubs.
72+
- `GET /api/v1/projects/<short_name>/grants/` — grants funding the project.
73+
- `GET /api/v1/projects/<short_name>/people/` — everyone with a role on the
74+
project, each as a `{ person, role, lead_project_role, start_date, end_date,
75+
is_active }` record (a person may appear more than once for multiple roles).
76+
- `GET /api/v1/projects/<short_name>/leadership/`**all** leadership across
77+
all time (current *and* past), grouped:
78+
`{ pis, co_pis, student_leads, postdoc_leads, research_scientist_leads }`,
79+
each a list of role records ordered newest-start first. A person appears once
80+
per lead role they've held (so a past student lead who later became PI shows
81+
up in both). Each record's `is_active` flag lets you separate current from
82+
past leadership.
83+
84+
### Grants — `GET /api/v1/grants/`
85+
86+
Filters: `?project=<short_name>`, `?sponsor=<sponsor short_name>`. Each grant
87+
includes its `sponsor`, `funding_amount`, `grant_id`, `grant_url`, and the
88+
`projects` it funds.
89+
90+
### People — `GET /api/v1/people/`
91+
92+
Actual lab members (people with at least one Position); external co-authors are
93+
not listed here even though they appear as publication `authors`. Detail by
94+
`url_name`: `GET /api/v1/people/<url_name>/` — name, current title, bio,
95+
thumbnail, and public social/web links (ORCID, Google Scholar, GitHub, etc.).
96+
97+
> **Note:** `email` is intentionally **not** exposed by the API to avoid making
98+
> it an email-harvesting surface, even where it appears on a member page.
99+
100+
## Stability contract
101+
102+
- **`v1` fields are additive-only.** New fields may be added; existing field
103+
names and meanings will not change or be removed within `v1`. Breaking changes
104+
ship as `/api/v2/`.
105+
- Don't hardcode pagination page sizes as a proxy for "all" — page through
106+
`next`, or set `page_size` explicitly (≤100).
107+
- URLs in responses (PDFs, thumbnails, page links) are absolute and safe to use
108+
directly.
109+
110+
## Implementation notes (for maintainers)
111+
112+
Code lives in `website/api/` (`serializers.py`, `views.py`, `urls.py`,
113+
`middleware.py`), mounted at `/api/` by the root URLconf
114+
(`makeabilitylab/urls.py`). Config is the `REST_FRAMEWORK` block in
115+
`settings.py`. CORS is a tiny in-repo middleware
116+
(`website.api.middleware.ApiCorsMiddleware`), scoped to `/api/`, rather than a
117+
third-party package. Tests: `website/tests/test_api.py`.
118+
119+
**Deliberately deferred** (add on the same pattern when needed): write
120+
endpoints, auth / API keys, request throttling, and Talks/Posters/Videos
121+
resources.

makeabilitylab/settings.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@
8686
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
8787

8888
# Makeability Lab Global Variables, including Makeability Lab version
89-
ML_WEBSITE_VERSION = "2.26.0" # Keep this updated with each release and also change the short description below
90-
ML_WEBSITE_VERSION_DESCRIPTION = "Improves image handling on the Awards admin badge plus a Data Health polish. The badge field now has an instant client-side preview and square (1:1) cropping via Cropper.js (#1408), and a new 'Pad badge to a square (don't crop)' option (#1410) that pads a non-square upload to a centered square -- white margins for JPEG, transparent for PNG/WebP -- instead of cropping off content, so editors no longer need to pad logos in an external tool before uploading. Padding is done server-side with Pillow: it re-encodes JPEG at quality 92, saves WebP lossless so a lossless source isn't degraded, and leaves already-square uploads untouched; a full-image crop box is stored so the public render isn't cropped. Also standardizes the per-row action links across the Data Health checks (#1405)."
89+
ML_WEBSITE_VERSION = "2.27.0" # Keep this updated with each release and also change the short description below
90+
ML_WEBSITE_VERSION_DESCRIPTION = "Adds a public, read-only REST API (#1268) at /api/v1/ so external sites can treat the Makeability Lab website as the source of truth for already-public content instead of duplicating it. Endpoints cover publications (filterable by project, author, year, and venue type -- e.g. ?author=jonfroehlich&page_size=5 for a 'recent publications' widget), publicly-visible projects, grants, and people, plus project sub-resources for a project's publications, grants, people, and leadership (PIs/Co-PIs/leads) -- the exact data Project Sidewalk needs to render its funding, team, and papers from one place. Built on the already-bundled Django REST Framework: read-only (GET only), no auth and no throttle since the data is already public, paginated with a tunable page_size (max 100), absolute media/page URLs in every payload, and cross-origin requests enabled on /api/ only (via a tiny in-repo CORS middleware) so a browser-side widget can fetch it directly. Personal email is deliberately not exposed. Full reference: docs/API.md."
9191
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
9292
MAX_BANNERS = 7 # Maximum number of banners on a page
9393

@@ -259,8 +259,28 @@
259259
'django.contrib.auth.middleware.AuthenticationMiddleware',
260260
'django.contrib.messages.middleware.MessageMiddleware',
261261
'django.middleware.clickjacking.XFrameOptionsMiddleware',
262+
263+
# Adds permissive CORS headers to /api/ responses only (#1268). Read-only,
264+
# already-public data -- see website/api/middleware.py.
265+
'website.api.middleware.ApiCorsMiddleware',
262266
]
263267

268+
# Django REST Framework config for the public read-only API (#1268).
269+
# Public data, so no auth and no throttle (per the #1268 scoping decision); the
270+
# browsable HTML API is enabled only in DEBUG (JSON-only in prod).
271+
REST_FRAMEWORK = {
272+
'DEFAULT_AUTHENTICATION_CLASSES': [],
273+
'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.AllowAny'],
274+
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
275+
'PAGE_SIZE': 25,
276+
'DEFAULT_RENDERER_CLASSES': (
277+
['rest_framework.renderers.JSONRenderer',
278+
'rest_framework.renderers.BrowsableAPIRenderer']
279+
if DEBUG else
280+
['rest_framework.renderers.JSONRenderer']
281+
),
282+
}
283+
264284
# A string representing the full Python import path to your root URLconf.
265285
# See: https://docs.djangoproject.com/en/4.2/ref/settings/#root-urlconf
266286
ROOT_URLCONF = 'makeabilitylab.urls'

makeabilitylab/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
# the top-level ./robots.txt to change crawler rules or the Sitemap line.
5151
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
5252

53+
# Public read-only REST API (#1268). Declared before the website.urls
54+
# include so the app's catch-all patterns can't shadow /api/.
55+
path('api/', include('website.api.urls')),
56+
5357
#Info on how to route root to website was found here http://stackoverflow.com/questions/7580220/django-urls-howto-map-root-to-app
5458
re_path(r'', include('website.urls')),
5559
# re_path(r'^admin/', admin.site.urls),

website/api/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""
2+
Public, read-only REST API for the Makeability Lab website (#1268).
3+
4+
Exposes already-public data (publications, projects, grants, people, and
5+
project leadership) in a machine-readable, versioned form so external consumers
6+
can treat this site as the source of truth instead of duplicating content. Two
7+
concrete consumers drove the design: Project Sidewalk (grants / people /
8+
leadership / publications for a project) and Jon's academic page (a "recent
9+
publications" list).
10+
11+
Design summary (see docs/API.md for the full contract):
12+
* Django REST Framework, mounted at ``/api/v1/``.
13+
* Read-only (GET/HEAD/OPTIONS), no auth, no throttle -- the data is already
14+
public on the site, so nothing new is disclosed.
15+
* Cross-origin browser requests are allowed via ``ApiCorsMiddleware`` (scoped
16+
to ``/api/``) so a client-side widget can fetch it directly.
17+
"""

website/api/middleware.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
Minimal CORS support for the public API (#1268), scoped to ``/api/`` only.
3+
4+
The API is read-only and serves data that's already public, so a permissive
5+
``Access-Control-Allow-Origin: *`` is safe and lets a client-side widget (e.g. a
6+
"recent publications" list on an external academic page) fetch it directly from
7+
the browser. We deliberately don't pull in ``django-cors-headers`` for this --
8+
the surface is one GET-only path prefix.
9+
10+
Only the ``/api/`` prefix gets these headers; the rest of the site is untouched
11+
(no cross-origin exposure of admin, forms, etc.).
12+
"""
13+
14+
API_PREFIX = "/api/"
15+
16+
17+
class ApiCorsMiddleware:
18+
"""Add permissive CORS headers to ``/api/`` responses and answer preflight.
19+
20+
A browser preflights a cross-origin request with ``OPTIONS``; we short-
21+
circuit that with a 200 + the CORS headers so the real GET is allowed.
22+
"""
23+
24+
def __init__(self, get_response):
25+
self.get_response = get_response
26+
27+
def __call__(self, request):
28+
is_api = request.path.startswith(API_PREFIX)
29+
30+
if is_api and request.method == "OPTIONS":
31+
from django.http import HttpResponse
32+
33+
response = HttpResponse(status=200)
34+
else:
35+
response = self.get_response(request)
36+
37+
if is_api:
38+
response["Access-Control-Allow-Origin"] = "*"
39+
response["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
40+
response["Access-Control-Allow-Headers"] = "Accept, Content-Type"
41+
response["Access-Control-Max-Age"] = "86400"
42+
43+
return response

0 commit comments

Comments
 (0)