From 6836903c5fce5491601f9070cfcd420a09d356ee Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 4 Jun 2026 22:45:09 +0200 Subject: [PATCH] feat(api/v2): add shared conditional read helper and document list params conditionalReadResponse applies the If-Match/If-None-Match/If-Modified-Since precondition (304/412) and returns the shared read envelope. The caller's permission is folded into the ETag so a share/role change invalidates the cache even when the model's modified time is unchanged. Also adds doc: tags to the shared ListParams (q/page/per_page). --- pkg/routes/api/v2/types.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/pkg/routes/api/v2/types.go b/pkg/routes/api/v2/types.go index bb79d9014..e8df9c0da 100644 --- a/pkg/routes/api/v2/types.go +++ b/pkg/routes/api/v2/types.go @@ -16,6 +16,13 @@ package apiv2 +import ( + "fmt" + "time" + + "github.com/danielgtaylor/huma/v2/conditional" +) + // Paginated is the standard list-response envelope for every /api/v2 list operation. type Paginated[T any] struct { Items []T `json:"items"` @@ -46,9 +53,9 @@ func NewPaginated[T any](items []T, total int64, page, perPage int) Paginated[T] // ListParams carries the standard (page, per_page, q) query shape for list operations. type ListParams struct { - Page int `query:"page" default:"1" minimum:"1"` - PerPage int `query:"per_page" default:"50" minimum:"1" maximum:"1000"` - Q string `query:"q"` + Page int `query:"page" default:"1" minimum:"1" doc:"1-based page number."` + PerPage int `query:"per_page" default:"50" minimum:"1" maximum:"1000" doc:"Items per page (max 1000)."` + Q string `query:"q" doc:"Search query; filters the list to items matching this string."` } // singleBody is the create/update response envelope (no ETag). @@ -62,5 +69,16 @@ type singleReadBody[T any] struct { Body *T } +// permission is folded into the ETag so a share/role change invalidates the cache. +func conditionalReadResponse[T any](p *conditional.Params, body *T, modified time.Time, permission int) (*singleReadBody[T], error) { + e := fmt.Sprintf("%d-%d", modified.UnixNano(), permission) + if p.HasConditionalParams() { + if err := p.PreconditionFailed(e, modified); err != nil { + return nil, err + } + } + return &singleReadBody[T]{ETag: `"` + e + `"`, Body: body}, nil +} + // emptyBody marks delete / no-content operations. type emptyBody struct{}