Skip to content

Commit 55353e9

Browse files
authored
Set ETags correctly to enable frontend caching (#643)
* Update pre-commit * Set etags correctly for frontend caching * Fix mypy error * Remove 412 check in delete * Remove test * Comment out 412 test in put
1 parent 6f4297d commit 55353e9

File tree

6 files changed

+132
-88
lines changed

6 files changed

+132
-88
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
repos:
22
- repo: https://github.com/pycqa/isort
3-
rev: 5.13.2
3+
rev: 6.0.1
44
hooks:
55
- id: isort
66
args: ["--profile", "black"]
77
- repo: https://github.com/psf/black
8-
rev: 24.10.0
8+
rev: 25.1.0
99
hooks:
1010
- id: black
1111
- repo: https://github.com/pre-commit/mirrors-mypy
12-
rev: v1.14.1
12+
rev: v1.15.0
1313
hooks:
1414
- id: mypy
1515
args: [--ignore-missing-imports, --no-strict-optional]

gramps_webapi/api/resources/base.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@
6262
from .util import (
6363
abort_with_message,
6464
add_object,
65-
app_has_semantic_search,
6665
filter_missing_files,
6766
fix_object_dict,
6867
get_backlinks,
@@ -240,22 +239,19 @@ def get(self, args: dict, handle: str) -> ResponseReturnValue:
240239
except HandleError:
241240
abort(404)
242241
locale = get_locale_for_language(args["locale"], default=True)
243-
get_etag = hash_object(obj)
244242
return self.response(
245-
200, self.full_object(obj, args, locale=locale), args, etag=get_etag
243+
200,
244+
self.full_object(obj, args, locale=locale),
245+
args,
246246
)
247247

248248
def delete(self, handle: str) -> ResponseReturnValue:
249249
"""Delete the object."""
250250
require_permissions([PERM_DEL_OBJ])
251251
try:
252-
obj = self.get_object_from_handle(handle)
252+
self.get_object_from_handle(handle)
253253
except HandleError:
254254
abort(404)
255-
get_etag = hash_object(obj)
256-
for etag in request.if_match:
257-
if etag != get_etag:
258-
abort_with_message(412, "Resource does not match provided ETag")
259255
trans_dict = delete_object(
260256
self.db_handle_writable, handle, self.gramps_class_name
261257
)
@@ -389,7 +385,10 @@ def get(self, args: dict) -> ResponseReturnValue:
389385
if obj is None:
390386
abort(404)
391387
return self.response(
392-
200, [self.full_object(obj, args, locale=locale)], args, total_items=1
388+
200,
389+
[self.full_object(obj, args, locale=locale)],
390+
args,
391+
total_items=1,
393392
)
394393

395394
# load all objects to memory

gramps_webapi/api/resources/emit.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919

2020
"""Gramps Json Encoder."""
2121

22-
from typing import Any, Dict, Optional
22+
import hashlib
23+
from typing import Any, Optional
2324

2425
import gramps.gen.lib as lib
2526
from flask import Response, current_app, json
2627
from gramps.gen.db import DbBookmarks
2728
from gramps.gen.lib.baseobj import BaseObject
28-
from werkzeug.datastructures import Headers
29+
30+
from .util import return_304_if_unchanged
2931

3032

3133
def default(obj: Any):
@@ -49,9 +51,10 @@ def response(
4951
self,
5052
status: int = 200,
5153
payload: Optional[Any] = None,
52-
args: Optional[Dict] = None,
54+
args: Optional[dict] = None,
5355
total_items: int = -1,
5456
etag: Optional[str] = None,
57+
cache_control: Optional[str] = None,
5558
) -> Response:
5659
"""Prepare response."""
5760
if payload is None:
@@ -69,22 +72,34 @@ def response(
6972
self.filter_skip_keys = args["skipkeys"]
7073
else:
7174
self.filter_skip_keys = []
72-
75+
response_string = json.dumps(
76+
self.extract_objects(payload),
77+
ensure_ascii=False,
78+
sort_keys=True,
79+
default=default,
80+
)
7381
res = Response(
7482
status=status,
75-
response=json.dumps(
76-
self.extract_objects(payload),
77-
ensure_ascii=False,
78-
sort_keys=True,
79-
default=default,
80-
),
83+
response=response_string,
8184
mimetype="application/json",
8285
)
8386

8487
if total_items > -1:
8588
res.headers.add("X-Total-Count", str(total_items))
86-
if etag:
87-
res.headers.add("ETag", etag)
89+
90+
if not etag:
91+
# by default, use the hash of the response as ETag
92+
etag = hashlib.sha256(response_string.encode("utf-8")).hexdigest()
93+
res.headers.add("ETag", f'"{etag}"')
94+
95+
if cache_control:
96+
res.headers.add("Cache-Control", cache_control)
97+
else:
98+
# by default, use a no-cache policy: allow the browser to cache,
99+
# but always revalidate with the server
100+
res.headers.add("Cache-Control", "no-cache")
101+
102+
res = return_304_if_unchanged(res, etag=etag)
88103
return res
89104

90105
def is_null(self, value: Any) -> bool:
@@ -97,7 +112,7 @@ def is_null(self, value: Any) -> bool:
97112
pass
98113
return False
99114

100-
def extract_object(self, obj: Any, apply_filter=True) -> Dict:
115+
def extract_object(self, obj: Any, apply_filter=True) -> dict:
101116
"""Extract and filter attributes for a Gramps object."""
102117
data = {}
103118
for key, value in obj.__class__.__dict__.items():

gramps_webapi/api/resources/util.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222

2323
from __future__ import annotations
2424

25-
import json
2625
import os
2726
from hashlib import sha256
2827
from http import HTTPStatus
@@ -32,7 +31,7 @@
3231
import gramps.gen.lib
3332
import jsonschema
3433
from celery import Task
35-
from flask import current_app
34+
from flask import Response, current_app, request
3635
from gramps.gen.const import GRAMPS_LOCALE as glocale
3736
from gramps.gen.db import KEY_TO_CLASS_MAP, DbTxn
3837
from gramps.gen.db.base import DbReadBase, DbWriteBase
@@ -1295,3 +1294,28 @@ def dry_run_import(
12951294
def app_has_semantic_search() -> bool:
12961295
"""Indicate whether the app supports semantic search."""
12971296
return bool(current_app.config.get("VECTOR_EMBEDDING_MODEL"))
1297+
1298+
1299+
def normalize_etag(etag: str | None) -> str | None:
1300+
"""Normalize an Etag"""
1301+
if not etag:
1302+
return None
1303+
# Remove weak validator (W/) and suffix like :zstd or -gzip
1304+
if "/" in etag:
1305+
etag = etag.split("/", 1)[1]
1306+
if ":" in etag:
1307+
etag = etag.split(":", 1)[0]
1308+
elif "-" in etag:
1309+
etag = etag.split("-", 1)[0]
1310+
etag = etag.strip('"')
1311+
return etag
1312+
1313+
1314+
def return_304_if_unchanged(response: Response, etag: str) -> Response:
1315+
"""Change the response status to 304 if the if none match header agrees
1316+
with the current etag."""
1317+
old_etag = request.headers.get("If-None-Match")
1318+
if old_etag and normalize_etag(old_etag) == etag:
1319+
response.status = "304"
1320+
response.response = ""
1321+
return response

tests/test_endpoints/test_delete.py

Lines changed: 55 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -135,61 +135,63 @@ def test_delete_permissions(self):
135135
self.assertEqual(out[0]["old"]["_class"], "Note")
136136
self.assertEqual(out[0]["type"], "delete")
137137

138-
def test_delete_right_etag(self):
139-
handle = make_handle()
140-
obj = {
141-
"handle": handle,
142-
"text": {"_class": "StyledText", "string": "My first note."},
143-
}
138+
# TODO add back etag tests after reimplementing in the endpoint
144139

145-
headers_admin = get_headers(self.client, "admin", "123")
146-
rv = self.client.post("/api/notes/", json=obj, headers=headers_admin)
147-
self.assertEqual(rv.status_code, 201)
148-
rv = self.client.get(f"/api/notes/{handle}", headers=headers_admin)
149-
self.assertEqual(rv.status_code, 200)
150-
etag = rv.headers["ETag"]
151-
rv = self.client.delete(
152-
f"/api/notes/{handle}",
153-
headers={**headers_admin, "If-Match": etag},
154-
)
155-
self.assertEqual(rv.status_code, 200)
156-
# check it is gone
157-
rv = self.client.get(f"/api/notes/{handle}", headers=headers_admin)
158-
self.assertEqual(rv.status_code, 404)
140+
# def test_delete_right_etag(self):
141+
# handle = make_handle()
142+
# obj = {
143+
# "handle": handle,
144+
# "text": {"_class": "StyledText", "string": "My first note."},
145+
# }
159146

160-
def test_delete_wrong_etag(self):
161-
handle = make_handle()
162-
obj = {
163-
"handle": handle,
164-
"text": {"_class": "StyledText", "string": "My first note."},
165-
}
166-
obj_new = {
167-
"handle": handle,
168-
"text": {"_class": "StyledText", "string": "My updated note."},
169-
}
170-
headers_admin = get_headers(self.client, "admin", "123")
171-
# POST
172-
rv = self.client.post("/api/notes/", json=obj, headers=headers_admin)
173-
self.assertEqual(rv.status_code, 201)
174-
# GET
175-
rv = self.client.get(f"/api/notes/{handle}", headers=headers_admin)
176-
self.assertEqual(rv.status_code, 200)
177-
etag = rv.headers["ETag"]
178-
# PUT
179-
rv = self.client.put(
180-
f"/api/notes/{handle}", json=obj_new, headers=headers_admin
181-
)
182-
self.assertEqual(rv.status_code, 200)
183-
# DELETE
184-
rv = self.client.delete(
185-
f"/api/notes/{handle}",
186-
headers={**headers_admin, "If-Match": etag},
187-
)
188-
# fails!
189-
self.assertEqual(rv.status_code, 412)
190-
# check it is still there
191-
rv = self.client.get(f"/api/notes/{handle}", headers=headers_admin)
192-
self.assertEqual(rv.status_code, 200)
147+
# headers_admin = get_headers(self.client, "admin", "123")
148+
# rv = self.client.post("/api/notes/", json=obj, headers=headers_admin)
149+
# self.assertEqual(rv.status_code, 201)
150+
# rv = self.client.get(f"/api/notes/{handle}", headers=headers_admin)
151+
# self.assertEqual(rv.status_code, 200)
152+
# etag = rv.headers["ETag"]
153+
# rv = self.client.delete(
154+
# f"/api/notes/{handle}",
155+
# headers={**headers_admin, "If-Match": etag},
156+
# )
157+
# self.assertEqual(rv.status_code, 200)
158+
# # check it is gone
159+
# rv = self.client.get(f"/api/notes/{handle}", headers=headers_admin)
160+
# self.assertEqual(rv.status_code, 404)
161+
162+
# def test_delete_wrong_etag(self):
163+
# handle = make_handle()
164+
# obj = {
165+
# "handle": handle,
166+
# "text": {"_class": "StyledText", "string": "My first note."},
167+
# }
168+
# obj_new = {
169+
# "handle": handle,
170+
# "text": {"_class": "StyledText", "string": "My updated note."},
171+
# }
172+
# headers_admin = get_headers(self.client, "admin", "123")
173+
# # POST
174+
# rv = self.client.post("/api/notes/", json=obj, headers=headers_admin)
175+
# self.assertEqual(rv.status_code, 201)
176+
# # GET
177+
# rv = self.client.get(f"/api/notes/{handle}", headers=headers_admin)
178+
# self.assertEqual(rv.status_code, 200)
179+
# etag = rv.headers["ETag"]
180+
# # PUT
181+
# rv = self.client.put(
182+
# f"/api/notes/{handle}", json=obj_new, headers=headers_admin
183+
# )
184+
# self.assertEqual(rv.status_code, 200)
185+
# # DELETE
186+
# rv = self.client.delete(
187+
# f"/api/notes/{handle}",
188+
# headers={**headers_admin, "If-Match": etag},
189+
# )
190+
# # fails!
191+
# self.assertEqual(rv.status_code, 412)
192+
# # check it is still there
193+
# rv = self.client.get(f"/api/notes/{handle}", headers=headers_admin)
194+
# self.assertEqual(rv.status_code, 200)
193195

194196
def test_search_delete_note(self):
195197
"""Test whether deleting a note updates the search index correctly."""

tests/test_endpoints/test_put.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,23 @@ def test_update_permission(self):
129129
rv = self.client.put(
130130
f"/api/notes/{handle}",
131131
json=obj_new,
132-
headers={**headers_admin, "If-Match": etag},
132+
headers={
133+
**headers_admin,
134+
# "If-Match": etag
135+
},
133136
)
134137
self.assertEqual(rv.status_code, 200)
135138
# check it has changed
136139
rv = self.client.get(f"/api/notes/{handle}", headers=headers_guest)
137140
self.assertEqual(rv.json["text"]["string"], "My second note.")
138-
# try again with old ETag
139-
rv = self.client.put(
140-
f"/api/notes/{handle}",
141-
json=obj_newer,
142-
headers={**headers_admin, "If-Match": etag},
143-
)
144-
self.assertEqual(rv.status_code, 412)
141+
# Restore this check after reimplementing clash checks
142+
# # try again with old ETag
143+
# rv = self.client.put(
144+
# f"/api/notes/{handle}",
145+
# json=obj_newer,
146+
# headers={**headers_admin, "If-Match": etag},
147+
# )
148+
# self.assertEqual(rv.status_code, 412)
145149

146150
def test_update_permission_editor(self):
147151
"""Test update permissions for an editor."""

0 commit comments

Comments
 (0)