From 65791fcdce6e540e2e7db49de3b27bbc4e3557ec Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Thu, 6 Aug 2026 15:58:05 -0300 Subject: [PATCH 1/2] Enhancement: Sparse fields - Support search sideloads Mark `SearchResponse` and `CommentSideload`, and wire `SearchRequestFilters.Fields` so each sideloaded entity accepts a `fields[...]` selection. The comment enum derives from `CommentSideload`, whose content is serialized as "title". Add a `sparsefields:skip` marker for fields that hold relationships rather than entities (`SearchResponse.Items`). --- internal/sparsefieldsgen/main.go | 25 ++++++++++--- projects/comment.go | 2 ++ projects/search.go | 9 +++++ projects/sparse_fields_gen.go | 58 ++++++++++++++++++++++++++++++ projects/sparse_fields_gen_test.go | 53 +++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 4 deletions(-) diff --git a/internal/sparsefieldsgen/main.go b/internal/sparsefieldsgen/main.go index fdc0dbd..a566e92 100644 --- a/internal/sparsefieldsgen/main.go +++ b/internal/sparsefieldsgen/main.go @@ -27,7 +27,12 @@ // that key from the entity's own marked *ListResponse, so a get and its // list can never disagree. // -// A fourth marker applies to individual fields of a marked response: +// Two more markers apply to individual fields of a marked response: +// +// - sparsefields:skip +// Excludes the field from slot generation. Used when a response's main +// slice holds relationships rather than a sparse-fields-capable entity +// (e.g. SearchResponse.Items). // // - sparsefields:key=entityName // Overrides the fields[...] entity key for that slot. For list slots the @@ -71,6 +76,7 @@ const ( markerList = "sparsefields:list" markerGet = "sparsefields:get" markerKey = "sparsefields:key" + markerSkip = "sparsefields:skip" rootImportPath = "github.com/teamwork/twapi-go-sdk" rootImportName = "twapi" @@ -721,7 +727,7 @@ func extractFields( func extractSlots(st *ast.StructType, ownerName string, fieldTypeOf map[string]string) ([]slot, error) { var slots []slot for _, field := range st.Fields.List { - if len(field.Names) == 0 || field.Tag == nil { + if len(field.Names) == 0 || field.Tag == nil || hasSkipMarker(field) { continue } tagText, err := strconv.Unquote(field.Tag.Value) @@ -789,7 +795,7 @@ func extractSlots(st *ast.StructType, ownerName string, fieldTypeOf map[string]s func extractGetSlots(st *ast.StructType, ownerName string, fieldTypeOf, entityKeyOf map[string]string) ([]slot, error) { var slots []slot for _, field := range st.Fields.List { - if len(field.Names) == 0 || field.Tag == nil { + if len(field.Names) == 0 || field.Tag == nil || hasSkipMarker(field) { continue } tagText, err := strconv.Unquote(field.Tag.Value) @@ -847,7 +853,7 @@ func extractGetSlots(st *ast.StructType, ownerName string, fieldTypeOf, entityKe func extractIncludedSlots(st *ast.StructType, ownerName string, fieldTypeOf map[string]string) ([]slot, error) { var slots []slot for _, field := range st.Fields.List { - if len(field.Names) == 0 || field.Tag == nil { + if len(field.Names) == 0 || field.Tag == nil || hasSkipMarker(field) { continue } mt, ok := field.Type.(*ast.MapType) @@ -886,6 +892,17 @@ func extractIncludedSlots(st *ast.StructType, ownerName string, fieldTypeOf map[ return slots, nil } +// hasSkipMarker reports whether a field opts out of slot generation via +// `sparsefields:skip` on its doc or trailing comment. +func hasSkipMarker(field *ast.Field) bool { + for _, doc := range []*ast.CommentGroup{field.Doc, field.Comment} { + if _, ok := markerOverride(doc, markerSkip, ""); ok { + return true + } + } + return false +} + // elementIdent returns an unqualified ident for a same-package type reference, // stripping pointer wrappers. Returns false for qualified types (e.g. // twapi.Relationship) and structural types (slice-of-slice, etc.). diff --git a/projects/comment.go b/projects/comment.go index b99c202..f05458f 100644 --- a/projects/comment.go +++ b/projects/comment.go @@ -87,6 +87,8 @@ type Comment struct { // CommentSideload contains minimal information about a comment, used for // sideloading in other API responses. +// +// sparsefields:gen type CommentSideload struct { // ID is the unique identifier of the comment. ID int64 `json:"id"` diff --git a/projects/search.go b/projects/search.go index 4993ef0..02c853d 100644 --- a/projects/search.go +++ b/projects/search.go @@ -123,6 +123,10 @@ type SearchRequestFilters struct { // Include contains additional related information to include in the response // as a sideload. Include []SearchRequestSideload + + // Fields selects sparse fieldsets for the sideloaded entities. Leave a slot + // empty to receive the API default for that entity. + Fields SearchFields } func (s SearchRequestFilters) apply(req *http.Request) { @@ -156,6 +160,7 @@ func (s SearchRequestFilters) apply(req *http.Request) { } query.Set("include", strings.Join(include, ",")) } + s.Fields.apply(query) // By default the API returns results ordered by updated date. To ensure we // get the most relevant results, we set the orderBy parameter to relevance. @@ -201,6 +206,8 @@ func (s SearchRequest) HTTPRequest(ctx context.Context, server string) (*http.Re // SearchResponse contains search results matching the request filters. // // https://apidocs.teamwork.com/docs/teamwork/v3/search/get-projects-api-v3-search-json +// +// sparsefields:list type SearchResponse struct { request SearchRequest @@ -213,6 +220,8 @@ type SearchResponse struct { // Items is the list of search results matching the request filters. Each item // contains a relationship to the actual item, which can be found as a // sideload. + // + // sparsefields:skip Items []SearchItem `json:"search"` // Included contains related objects included in the response. diff --git a/projects/sparse_fields_gen.go b/projects/sparse_fields_gen.go index 5ab477d..97e7a3b 100644 --- a/projects/sparse_fields_gen.go +++ b/projects/sparse_fields_gen.go @@ -96,6 +96,19 @@ const ( CommentFieldDeletedAt CommentField = "dateDeleted" ) +// CommentSideloadField identifies a JSON-tagged attribute of CommentSideload usable for v3 sparse fieldsets. +type CommentSideloadField string + +// List of possible CommentSideload fields. +const ( + CommentSideloadFieldID CommentSideloadField = "id" + CommentSideloadFieldBody CommentSideloadField = "title" + CommentSideloadFieldObject CommentSideloadField = "object" + CommentSideloadFieldProject CommentSideloadField = "project" + CommentSideloadFieldPostedBy CommentSideloadField = "postedBy" + CommentSideloadFieldPostedAt CommentSideloadField = "postedDateTime" +) + // CompanyField identifies a JSON-tagged attribute of Company usable for v3 sparse fieldsets. type CompanyField string @@ -927,6 +940,51 @@ func (f ProjectListFields) apply(query url.Values) { twapi.ApplySparseFields(query, "customfieldProjects", f.CustomFieldValues) } +// SearchFields selects sparse-fields slots for SearchResponse. Leave a slot empty to receive the +// API default for that entity; populate it to restrict the attributes returned. +type SearchFields struct { + // Comments controls fields[comments]=… on the response. + Comments []CommentSideloadField + // Companies controls fields[companies]=… on the response. + Companies []CompanyField + // Links controls fields[links]=… on the response. + Links []LinkField + // Messages controls fields[messages]=… on the response. + Messages []MessageField + // Milestones controls fields[milestones]=… on the response. + Milestones []MilestoneField + // Notebooks controls fields[notebooks]=… on the response. + Notebooks []NotebookField + // Projects controls fields[projects]=… on the response. + Projects []ProjectField + // Tasklists controls fields[tasklists]=… on the response. + Tasklists []TasklistField + // Tasks controls fields[tasks]=… on the response. + Tasks []TaskField + // Teams controls fields[teams]=… on the response. + Teams []TeamField + // Timelogs controls fields[timelogs]=… on the response. + Timelogs []TimelogField + // Users controls fields[users]=… on the response. + Users []UserField +} + +// apply writes every populated slot to query as a fields[entity]=… parameter. +func (f SearchFields) apply(query url.Values) { + twapi.ApplySparseFields(query, "comments", f.Comments) + twapi.ApplySparseFields(query, "companies", f.Companies) + twapi.ApplySparseFields(query, "links", f.Links) + twapi.ApplySparseFields(query, "messages", f.Messages) + twapi.ApplySparseFields(query, "milestones", f.Milestones) + twapi.ApplySparseFields(query, "notebooks", f.Notebooks) + twapi.ApplySparseFields(query, "projects", f.Projects) + twapi.ApplySparseFields(query, "tasklists", f.Tasklists) + twapi.ApplySparseFields(query, "tasks", f.Tasks) + twapi.ApplySparseFields(query, "teams", f.Teams) + twapi.ApplySparseFields(query, "timelogs", f.Timelogs) + twapi.ApplySparseFields(query, "users", f.Users) +} + // SkillListFields selects sparse-fields slots for SkillListResponse. Leave a slot empty to receive the // API default for that entity; populate it to restrict the attributes returned. type SkillListFields struct { diff --git a/projects/sparse_fields_gen_test.go b/projects/sparse_fields_gen_test.go index fcfafae..22f1ebf 100644 --- a/projects/sparse_fields_gen_test.go +++ b/projects/sparse_fields_gen_test.go @@ -815,6 +815,59 @@ func TestProjectListFieldsZeroValue(t *testing.T) { } } +// TestSearchFieldsApply verifies that populated SearchFields slots emit the +// expected fields[entity]=… query parameters. +func TestSearchFieldsApply(t *testing.T) { + fields := SearchFields{ + Comments: []CommentSideloadField{CommentSideloadFieldID}, + Companies: []CompanyField{CompanyFieldID}, + Links: []LinkField{LinkFieldID}, + Messages: []MessageField{MessageFieldID}, + Milestones: []MilestoneField{MilestoneFieldID}, + Notebooks: []NotebookField{NotebookFieldID}, + Projects: []ProjectField{ProjectFieldID}, + Tasklists: []TasklistField{TasklistFieldID}, + Tasks: []TaskField{TaskFieldID}, + Teams: []TeamField{TeamFieldID}, + Timelogs: []TimelogField{TimelogFieldID}, + Users: []UserField{UserFieldID}, + } + query := url.Values{} + fields.apply(query) + checks := map[string]string{ + "fields[comments]": "id", + "fields[companies]": "id", + "fields[links]": "id", + "fields[messages]": "id", + "fields[milestones]": "id", + "fields[notebooks]": "id", + "fields[projects]": "id", + "fields[tasklists]": "id", + "fields[tasks]": "id", + "fields[teams]": "id", + "fields[timelogs]": "id", + "fields[users]": "id", + } + for key, want := range checks { + if got := query.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } +} + +// TestSearchFieldsZeroValue verifies that an unset SearchFields emits no +// fields[*]=… query parameters. +func TestSearchFieldsZeroValue(t *testing.T) { + var fields SearchFields + query := url.Values{} + fields.apply(query) + for key := range query { + if strings.HasPrefix(key, "fields[") { + t.Errorf("unexpected sparse-fields parameter %q on zero-value container", key) + } + } +} + // TestSkillListFieldsApply verifies that populated SkillListFields slots emit the // expected fields[entity]=… query parameters. func TestSkillListFieldsApply(t *testing.T) { From 9b07035c2afb390046ef4569645896bd2a27bf12 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Fri, 7 Aug 2026 06:34:40 -0300 Subject: [PATCH 2/2] Dummy commit to trigger CI