Skip to content

Commit 7f559f5

Browse files
committed
implement conditional requests
- currently only applies to single thread get - still exploring internal API design for this - will maybe look at proper caching layer too
1 parent a5c8215 commit 7f559f5

File tree

7 files changed

+632
-381
lines changed

7 files changed

+632
-381
lines changed

api/openapi.yaml

+34
Original file line numberDiff line numberDiff line change
@@ -1150,11 +1150,14 @@ paths:
11501150
parameters:
11511151
- $ref: "#/components/parameters/ThreadMarkParam"
11521152
- $ref: "#/components/parameters/PaginationQuery"
1153+
- $ref: "#/components/parameters/If-None-Match"
1154+
- $ref: "#/components/parameters/If-Modified-Since"
11531155
responses:
11541156
default: { $ref: "#/components/responses/InternalServerError" }
11551157
"404": { $ref: "#/components/responses/NotFound" }
11561158
"401": { $ref: "#/components/responses/Unauthorised" }
11571159
"200": { $ref: "#/components/responses/ThreadGet" }
1160+
"304": { description: cached }
11581161
patch:
11591162
operationId: ThreadUpdate
11601163
description: Publish changes to a thread.
@@ -1910,6 +1913,21 @@ components:
19101913
#
19111914

19121915
parameters:
1916+
"If-None-Match":
1917+
description: If-None-Match cache control header.
1918+
name: If-None-Match
1919+
in: header
1920+
required: false
1921+
schema:
1922+
type: string
1923+
"If-Modified-Since":
1924+
description: If-Modified-Since cache control header.
1925+
name: If-Modified-Since
1926+
in: header
1927+
required: false
1928+
schema:
1929+
type: string
1930+
19131931
IconSize:
19141932
description: Icon sizes.
19151933
example: "512x512"
@@ -2789,6 +2807,10 @@ components:
27892807

27902808
ThreadGet:
27912809
description: The information about a thread and its posts.
2810+
headers:
2811+
Cache-Control: { $ref: "#/components/headers/Cache-Control" }
2812+
Last-Modified: { $ref: "#/components/headers/Last-Modified" }
2813+
ETag: { $ref: "#/components/headers/ETag" }
27922814
content:
27932815
application/json:
27942816
schema: { $ref: "#/components/schemas/Thread" }
@@ -3024,6 +3046,18 @@ components:
30243046
# "Y8888P" "Y8888P" 888 888 8888888888 888 888 d88P 888 "Y8888P"
30253047
#
30263048

3049+
headers:
3050+
Cache-Control:
3051+
schema:
3052+
type: string
3053+
Last-Modified:
3054+
schema:
3055+
type: string
3056+
format: RFC1123
3057+
ETag:
3058+
schema:
3059+
type: string
3060+
30273061
schemas:
30283062
#
30293063
# .d8888b.

app/resources/cachecontrol/query.go

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package cachecontrol
2+
3+
import (
4+
"time"
5+
6+
"github.com/Southclaws/opt"
7+
8+
"github.com/Southclaws/storyden/app/transports/http/openapi"
9+
)
10+
11+
// Query represents a HTTP conditional request query.
12+
type Query struct {
13+
ETag opt.Optional[string]
14+
ModifiedSince opt.Optional[time.Time]
15+
}
16+
17+
// NewQuery must be constructed from a HTTP request's conditional headers.
18+
func NewQuery(
19+
IfNoneMatch *string,
20+
IfModifiedSince *string,
21+
) opt.Optional[Query] {
22+
if IfNoneMatch == nil && IfModifiedSince == nil {
23+
return opt.NewEmpty[Query]()
24+
}
25+
26+
modifiedSince, err := opt.MapErr(opt.NewPtr(IfModifiedSince), parseConditionalRequestTime)
27+
if err != nil {
28+
return opt.NewEmpty[Query]()
29+
}
30+
31+
return opt.New(Query{
32+
ETag: opt.NewPtr((*string)(IfNoneMatch)),
33+
ModifiedSince: modifiedSince,
34+
})
35+
}
36+
37+
// NotModified takes the current updated date of a resource and returns true if
38+
// the cache control query includes a Is-Modified-Since header and the resource
39+
// updated date is not after the header value. True means a 304 response header.
40+
func (q Query) NotModified(resourceUpdated time.Time) bool {
41+
// truncate the resourceUpdated to the nearest second because the actual
42+
// HTTP header is already truncated but the database time is in nanoseconds.
43+
// If we didn't do this, the resource updated will always be slightly ahead.
44+
truncated := resourceUpdated.Truncate(time.Second)
45+
46+
if ms, ok := q.ModifiedSince.Get(); ok {
47+
48+
// If the resource update time is ahead of the HTTP Last-Modified check,
49+
// modified = 1, meaning the resource has been modified since the last
50+
// request and should be returned from the DB, instead of a 304 status.
51+
modified := truncated.Compare(ms)
52+
53+
return modified <= 0
54+
}
55+
56+
return false
57+
}
58+
59+
func parseConditionalRequestTime(in openapi.IfModifiedSince) (time.Time, error) {
60+
return time.Parse(time.RFC1123, in)
61+
}
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package thread_cache
2+
3+
import (
4+
"context"
5+
6+
"github.com/Southclaws/fault"
7+
"github.com/Southclaws/fault/fctx"
8+
"github.com/Southclaws/opt"
9+
"github.com/rs/xid"
10+
11+
"github.com/Southclaws/storyden/app/resources/cachecontrol"
12+
"github.com/Southclaws/storyden/internal/ent"
13+
"github.com/Southclaws/storyden/internal/ent/post"
14+
)
15+
16+
type Cache struct {
17+
db *ent.Client
18+
}
19+
20+
func New(db *ent.Client) *Cache {
21+
return &Cache{
22+
db: db,
23+
}
24+
}
25+
26+
func (c *Cache) IsNotModified(ctx context.Context, cq opt.Optional[cachecontrol.Query], id xid.ID) (bool, error) {
27+
query, ok := cq.Get()
28+
if !ok {
29+
return false, nil
30+
}
31+
32+
r, err := c.db.Post.Query().Select(post.FieldUpdatedAt).Where(post.ID(id)).Only(ctx)
33+
if err != nil {
34+
return false, fault.Wrap(err, fctx.With(ctx))
35+
}
36+
37+
notModified := query.NotModified(r.UpdatedAt)
38+
39+
return notModified, nil
40+
}

app/resources/resources.go

+2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import (
4040
"github.com/Southclaws/storyden/app/resources/post/reaction"
4141
"github.com/Southclaws/storyden/app/resources/post/reply"
4242
"github.com/Southclaws/storyden/app/resources/post/thread"
43+
"github.com/Southclaws/storyden/app/resources/post/thread_cache"
4344
"github.com/Southclaws/storyden/app/resources/profile/follow_querier"
4445
"github.com/Southclaws/storyden/app/resources/profile/follow_writer"
4546
"github.com/Southclaws/storyden/app/resources/profile/profile_search"
@@ -72,6 +73,7 @@ func Build() fx.Option {
7273
tag_querier.New,
7374
tag_writer.New,
7475
thread.New,
76+
thread_cache.New,
7577
reaction.New,
7678
like_querier.New,
7779
like_writer.New,

app/transports/http/bindings/threads.go

+22-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"net/url"
66
"strconv"
7+
"time"
78

89
"github.com/Southclaws/dt"
910
"github.com/Southclaws/fault"
@@ -13,10 +14,12 @@ import (
1314
"github.com/rs/xid"
1415

1516
"github.com/Southclaws/storyden/app/resources/account/account_querier"
17+
"github.com/Southclaws/storyden/app/resources/cachecontrol"
1618
"github.com/Southclaws/storyden/app/resources/datagraph"
1719
"github.com/Southclaws/storyden/app/resources/tag/tag_ref"
1820

1921
"github.com/Southclaws/storyden/app/resources/post/category"
22+
"github.com/Southclaws/storyden/app/resources/post/thread_cache"
2023
"github.com/Southclaws/storyden/app/resources/visibility"
2124
"github.com/Southclaws/storyden/app/services/authentication/session"
2225
thread_service "github.com/Southclaws/storyden/app/services/thread"
@@ -25,17 +28,19 @@ import (
2528
)
2629

2730
type Threads struct {
31+
thread_cache *thread_cache.Cache
2832
thread_svc thread_service.Service
2933
thread_mark_svc thread_mark.Service
3034
accountQuery *account_querier.Querier
3135
}
3236

3337
func NewThreads(
38+
thread_cache *thread_cache.Cache,
3439
thread_svc thread_service.Service,
3540
thread_mark_svc thread_mark.Service,
3641
accountQuery *account_querier.Querier,
3742
) Threads {
38-
return Threads{thread_svc, thread_mark_svc, accountQuery}
43+
return Threads{thread_cache, thread_svc, thread_mark_svc, accountQuery}
3944
}
4045

4146
func (i *Threads) ThreadCreate(ctx context.Context, request openapi.ThreadCreateRequestObject) (openapi.ThreadCreateResponseObject, error) {
@@ -211,6 +216,15 @@ func (i *Threads) ThreadGet(ctx context.Context, request openapi.ThreadGetReques
211216
return nil, fault.Wrap(err, fctx.With(ctx))
212217
}
213218

219+
notModified, err := i.thread_cache.IsNotModified(ctx, cachecontrol.NewQuery(request.Params.IfNoneMatch, request.Params.IfModifiedSince), xid.ID(postID))
220+
if err != nil {
221+
return nil, fault.Wrap(err, fctx.With(ctx))
222+
}
223+
224+
if notModified {
225+
return openapi.ThreadGet304Response{}, nil
226+
}
227+
214228
pp := deserialisePageParams(request.Params.Page, 50)
215229

216230
thread, err := i.thread_svc.Get(ctx, postID, pp)
@@ -219,7 +233,13 @@ func (i *Threads) ThreadGet(ctx context.Context, request openapi.ThreadGetReques
219233
}
220234

221235
return openapi.ThreadGet200JSONResponse{
222-
ThreadGetJSONResponse: openapi.ThreadGetJSONResponse(serialiseThread(thread)),
236+
ThreadGetJSONResponse: openapi.ThreadGetJSONResponse{
237+
Body: serialiseThread(thread),
238+
Headers: openapi.ThreadGetResponseHeaders{
239+
CacheControl: "max-age=1",
240+
LastModified: thread.UpdatedAt.Format(time.RFC1123),
241+
},
242+
},
223243
}, nil
224244
}
225245

0 commit comments

Comments
 (0)