From a3370a9a49f6428a31d70b3243233a44008d1ae1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 3 Jun 2026 20:23:33 +0200 Subject: [PATCH] fix(api/v2): drop ETag/conditional read on project get The project read response is enriched with user-scoped, derived state (subscription, favorite, views, computed archived state) that can change without bumping project.Updated. An ETag built only from Updated would therefore hand out stale 304s and hide those changes from the client. Serve project reads fresh on every call by returning the no-ETag singleBody envelope and dropping the conditional.Params input. Labels keep their ETag because their response has no such volatile derived fields. Update the ReadOne/Normal webtest to assert no ETag is sent. --- pkg/routes/api/v2/projects.go | 20 ++++++++------------ pkg/webtests/huma_project_test.go | 5 ++++- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/pkg/routes/api/v2/projects.go b/pkg/routes/api/v2/projects.go index 37ff4bd1e..219fb5422 100644 --- a/pkg/routes/api/v2/projects.go +++ b/pkg/routes/api/v2/projects.go @@ -25,7 +25,6 @@ import ( "code.vikunja.io/api/pkg/web/handler" "github.com/danielgtaylor/huma/v2" - "github.com/danielgtaylor/huma/v2/conditional" ) // projectListBody is the list-response envelope. models.Project.ReadAll @@ -50,7 +49,7 @@ func RegisterProjectRoutes(api huma.API) { Register(api, huma.Operation{ OperationID: "projects-read", Summary: "Get a project", - Description: "Returns a single project the caller can read, including its views and the caller's favorite/subscription state. Resolves the Favorites pseudo-project and saved-filter-backed projects. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.", + Description: "Returns a single project the caller can read, including its views and the caller's favorite/subscription state. Resolves the Favorites pseudo-project and saved-filter-backed projects. Pass expand=permissions to include the caller's max_permission; otherwise max_permission is null. Served fresh on every call (no conditional/ETag) because the response carries user-scoped state that changes without bumping the project's updated timestamp.", Method: http.MethodGet, Path: "/projects/{id}", Tags: tags, @@ -113,8 +112,7 @@ func projectsList(ctx context.Context, in *struct { func projectsRead(ctx context.Context, in *struct { ID int64 `path:"id"` Expand string `query:"expand" enum:"permissions" doc:"If set to \"permissions\", the project includes the max permission the requesting user has on it (max_permission)."` - conditional.Params -}) (*singleReadBody[models.Project], error) { +}) (*singleBody[models.Project], error) { a, err := authFromCtx(ctx) if err != nil { return nil, err @@ -135,14 +133,12 @@ func projectsRead(ctx context.Context, in *struct { } else { project.MaxPermission = models.PermissionUnknown } - // PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form. - etag := fmt.Sprintf("%d-%d", project.ID, project.Updated.UnixNano()) - if in.HasConditionalParams() { - if err := in.PreconditionFailed(etag, project.Updated); err != nil { - return nil, err - } - } - return &singleReadBody[models.Project]{ETag: `"` + etag + `"`, Body: project}, nil + // No ETag/conditional read here: a project response carries user-scoped, + // derived state (subscription, favorite, views, computed archived state) + // that changes without bumping project.Updated. An ETag built from Updated + // would hand out stale 304s and hide those changes, so the read is always + // served fresh. + return &singleBody[models.Project]{Body: project}, nil } func projectsCreate(ctx context.Context, in *struct { diff --git a/pkg/webtests/huma_project_test.go b/pkg/webtests/huma_project_test.go index df24b107b..8cdee37c9 100644 --- a/pkg/webtests/huma_project_test.go +++ b/pkg/webtests/huma_project_test.go @@ -72,7 +72,10 @@ func TestHumaProject(t *testing.T) { // Without expand=permissions, max_permission must be null rather than // defaulting to 0 (which would falsely read as PermissionRead). assert.Contains(t, rec.Body.String(), `"max_permission":null`) - assert.NotEmpty(t, rec.Result().Header.Get("ETag")) + // The project read is served fresh on every call; no ETag is sent + // because the response carries derived state that changes without + // bumping project.Updated. + assert.Empty(t, rec.Result().Header.Get("ETag")) }) t.Run("Expand permissions", func(t *testing.T) { // User 1 owns Test1 → admin (2); expand surfaces it as max_permission.