Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement conditional requests #352

Merged
merged 2 commits into from
Jan 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
Comment on lines +40 to +57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider implementing ETag comparison

The NotModified method currently only checks If-Modified-Since. Consider implementing ETag comparison for better caching support.

 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)
 
+    // Check ETag first if available
+    if etag, ok := q.ETag.Get(); ok {
+        // TODO: Implement ETag comparison logic
+        // This would require the resource to provide its current ETag
+        // return resourceEtag == etag
+    }
+
     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
 }

Committable suggestion skipped: line range outside the PR's diff.


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
Loading