Skip to content

Commit

Permalink
Merge pull request #352 from Southclaws/server-side-conditional-cache
Browse files Browse the repository at this point in the history
implement conditional requests
  • Loading branch information
Southclaws authored Jan 12, 2025
2 parents a0a0f4f + 9b608bb commit d4cf781
Show file tree
Hide file tree
Showing 10 changed files with 663 additions and 384 deletions.
34 changes: 34 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1150,11 +1150,14 @@ paths:
parameters:
- $ref: "#/components/parameters/ThreadMarkParam"
- $ref: "#/components/parameters/PaginationQuery"
- $ref: "#/components/parameters/If-None-Match"
- $ref: "#/components/parameters/If-Modified-Since"
responses:
default: { $ref: "#/components/responses/InternalServerError" }
"404": { $ref: "#/components/responses/NotFound" }
"401": { $ref: "#/components/responses/Unauthorised" }
"200": { $ref: "#/components/responses/ThreadGet" }
"304": { description: cached }
patch:
operationId: ThreadUpdate
description: Publish changes to a thread.
Expand Down Expand Up @@ -1910,6 +1913,21 @@ components:
#

parameters:
"If-None-Match":
description: If-None-Match cache control header.
name: If-None-Match
in: header
required: false
schema:
type: string
"If-Modified-Since":
description: If-Modified-Since cache control header.
name: If-Modified-Since
in: header
required: false
schema:
type: string

IconSize:
description: Icon sizes.
example: "512x512"
Expand Down Expand Up @@ -2789,6 +2807,10 @@ components:

ThreadGet:
description: The information about a thread and its posts.
headers:
Cache-Control: { $ref: "#/components/headers/Cache-Control" }
Last-Modified: { $ref: "#/components/headers/Last-Modified" }
ETag: { $ref: "#/components/headers/ETag" }
content:
application/json:
schema: { $ref: "#/components/schemas/Thread" }
Expand Down Expand Up @@ -3024,6 +3046,18 @@ components:
# "Y8888P" "Y8888P" 888 888 8888888888 888 888 d88P 888 "Y8888P"
#

headers:
Cache-Control:
schema:
type: string
Last-Modified:
schema:
type: string
format: RFC1123
ETag:
schema:
type: string

schemas:
#
# .d8888b.
Expand Down
61 changes: 61 additions & 0 deletions app/resources/cachecontrol/query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package cachecontrol

import (
"time"

"github.com/Southclaws/opt"

"github.com/Southclaws/storyden/app/transports/http/openapi"
)

// Query represents a HTTP conditional request query.
type Query struct {
ETag opt.Optional[string]
ModifiedSince opt.Optional[time.Time]
}

// NewQuery must be constructed from a HTTP request's conditional headers.
func NewQuery(
IfNoneMatch *string,
IfModifiedSince *string,
) opt.Optional[Query] {
if IfNoneMatch == nil && IfModifiedSince == nil {
return opt.NewEmpty[Query]()
}

modifiedSince, err := opt.MapErr(opt.NewPtr(IfModifiedSince), parseConditionalRequestTime)
if err != nil {
return opt.NewEmpty[Query]()
}

return opt.New(Query{
ETag: opt.NewPtr((*string)(IfNoneMatch)),
ModifiedSince: modifiedSince,
})
}

// NotModified takes the current updated date of a resource and returns true if
// the cache control query includes a Is-Modified-Since header and the resource
// updated date is not after the header value. True means a 304 response header.
func (q Query) NotModified(resourceUpdated time.Time) bool {
// truncate the resourceUpdated to the nearest second because the actual
// HTTP header is already truncated but the database time is in nanoseconds.
// If we didn't do this, the resource updated will always be slightly ahead.
truncated := resourceUpdated.Truncate(time.Second)

if ms, ok := q.ModifiedSince.Get(); ok {

// If the resource update time is ahead of the HTTP Last-Modified check,
// modified = 1, meaning the resource has been modified since the last
// request and should be returned from the DB, instead of a 304 status.
modified := truncated.Compare(ms)

return modified <= 0
}

return false
}

func parseConditionalRequestTime(in openapi.IfModifiedSince) (time.Time, error) {
return time.Parse(time.RFC1123, in)
}
40 changes: 40 additions & 0 deletions app/resources/post/thread_cache/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package thread_cache

import (
"context"

"github.com/Southclaws/fault"
"github.com/Southclaws/fault/fctx"
"github.com/Southclaws/opt"
"github.com/rs/xid"

"github.com/Southclaws/storyden/app/resources/cachecontrol"
"github.com/Southclaws/storyden/internal/ent"
"github.com/Southclaws/storyden/internal/ent/post"
)

type Cache struct {
db *ent.Client
}

func New(db *ent.Client) *Cache {
return &Cache{
db: db,
}
}

func (c *Cache) IsNotModified(ctx context.Context, cq opt.Optional[cachecontrol.Query], id xid.ID) (bool, error) {
query, ok := cq.Get()
if !ok {
return false, nil
}

r, err := c.db.Post.Query().Select(post.FieldUpdatedAt).Where(post.ID(id)).Only(ctx)
if err != nil {
return false, fault.Wrap(err, fctx.With(ctx))
}

notModified := query.NotModified(r.UpdatedAt)

return notModified, nil
}
2 changes: 2 additions & 0 deletions app/resources/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (
"github.com/Southclaws/storyden/app/resources/post/reaction"
"github.com/Southclaws/storyden/app/resources/post/reply"
"github.com/Southclaws/storyden/app/resources/post/thread"
"github.com/Southclaws/storyden/app/resources/post/thread_cache"
"github.com/Southclaws/storyden/app/resources/profile/follow_querier"
"github.com/Southclaws/storyden/app/resources/profile/follow_writer"
"github.com/Southclaws/storyden/app/resources/profile/profile_search"
Expand Down Expand Up @@ -72,6 +73,7 @@ func Build() fx.Option {
tag_querier.New,
tag_writer.New,
thread.New,
thread_cache.New,
reaction.New,
like_querier.New,
like_writer.New,
Expand Down
24 changes: 22 additions & 2 deletions app/transports/http/bindings/threads.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"net/url"
"strconv"
"time"

"github.com/Southclaws/dt"
"github.com/Southclaws/fault"
Expand All @@ -13,10 +14,12 @@ import (
"github.com/rs/xid"

"github.com/Southclaws/storyden/app/resources/account/account_querier"
"github.com/Southclaws/storyden/app/resources/cachecontrol"
"github.com/Southclaws/storyden/app/resources/datagraph"
"github.com/Southclaws/storyden/app/resources/tag/tag_ref"

"github.com/Southclaws/storyden/app/resources/post/category"
"github.com/Southclaws/storyden/app/resources/post/thread_cache"
"github.com/Southclaws/storyden/app/resources/visibility"
"github.com/Southclaws/storyden/app/services/authentication/session"
thread_service "github.com/Southclaws/storyden/app/services/thread"
Expand All @@ -25,17 +28,19 @@ import (
)

type Threads struct {
thread_cache *thread_cache.Cache
thread_svc thread_service.Service
thread_mark_svc thread_mark.Service
accountQuery *account_querier.Querier
}

func NewThreads(
thread_cache *thread_cache.Cache,
thread_svc thread_service.Service,
thread_mark_svc thread_mark.Service,
accountQuery *account_querier.Querier,
) Threads {
return Threads{thread_svc, thread_mark_svc, accountQuery}
return Threads{thread_cache, thread_svc, thread_mark_svc, accountQuery}
}

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

notModified, err := i.thread_cache.IsNotModified(ctx, cachecontrol.NewQuery(request.Params.IfNoneMatch, request.Params.IfModifiedSince), xid.ID(postID))
if err != nil {
return nil, fault.Wrap(err, fctx.With(ctx))
}

if notModified {
return openapi.ThreadGet304Response{}, nil
}

pp := deserialisePageParams(request.Params.Page, 50)

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

return openapi.ThreadGet200JSONResponse{
ThreadGetJSONResponse: openapi.ThreadGetJSONResponse(serialiseThread(thread)),
ThreadGetJSONResponse: openapi.ThreadGetJSONResponse{
Body: serialiseThread(thread),
Headers: openapi.ThreadGetResponseHeaders{
CacheControl: "max-age=1",
LastModified: thread.UpdatedAt.Format(time.RFC1123),
},
},
}, nil
}

Expand Down
Loading

0 comments on commit d4cf781

Please sign in to comment.