Skip to content
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
13 changes: 13 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(go build *)",
"Bash(go test *)",
"Bash(go vet *)",
"Bash(go generate *)"
],
"additionalDirectories": [
"/tmp"
]
}
}
26 changes: 26 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ jobs:
with:
version: v2.10

generate:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}

- name: Verify generated code is up to date
run: |
go generate ./...
if [[ -n "$(git status --porcelain)" ]]; then
echo "::error::Generated code is out of date. Run 'go generate ./...' locally and commit the result."
echo "Files changed or created by 'go generate':"
git status --short
echo
echo "Diff:"
git --no-pager diff
exit 1
fi

test:
runs-on: ubuntu-latest

Expand Down
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,11 @@ Desktop.ini
*.swp
*.swo
*~
.#*
.#*

# AI file
.claude/settings.local.json

# Go generate
sparsefieldsgen
!sparsefieldsgen/
79 changes: 79 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ func MessageCreate(ctx context.Context, engine *twapi.Engine, req MessageCreateR
}
```

### 6. Sparse-fieldsets wiring (List endpoints only)

If your resource has a List endpoint, follow the [Sparse fieldsets](#sparse-fieldsets-list-responses) recipe below to expose typed field selection. The pattern is mandatory for new v3 list endpoints — `go generate` plus a CI check in `.github/workflows/test.yaml` enforce that the wiring stays in sync.

---

## Request / Response Patterns
Expand Down Expand Up @@ -213,6 +217,81 @@ func (r *MessageListResponse) Iterate() *MessageListRequest {
}
```

### Sparse fieldsets (list responses)

Every v3 list endpoint supports sparse fieldsets — clients restrict the attributes returned per entity via `fields[entity]=attr1,attr2,...`. The SDK exposes this as compile-time-checked typed slices, generated by `internal/sparsefieldsgen` from markers on the source. Contributors write four small bits of wiring; the generator emits everything else (`<Entity>Field` constants, the `<Resource>ListFields` container, its `apply` method, and tests).

#### The recipe — four steps

1. **Mark the entity struct** with `// sparsefields:gen` as the last paragraph of its doc comment:

```go
// Message is a project-scoped announcement…
//
// sparsefields:gen
type Message struct { ... }
```

2. **Mark the `*ListResponse` struct** with `// sparsefields:list`:

```go
// MessageListResponse contains messages matching the filters…
//
// sparsefields:list
type MessageListResponse struct { ... }
```

3. **Add `Fields <Resource>ListFields`** as the last field of `*ListRequestFilters`. Document it consistently with other filters:

```go
type MessageListRequestFilters struct {
// ...existing fields...

Page int64
PageSize int64

// Fields restricts the attributes returned for the message and each of
// its sideloads. Each slot of MessageListFields is a separate
// `fields[entity]=…` selection; populated slots restrict the response,
// empty slots return the API default. Use the generated MessageField
// constants to ensure values match real attributes.
Fields MessageListFields
}
```

4. **Call `t.Fields.apply(query)`** immediately before `req.URL.RawQuery = query.Encode()` in the filter's `apply()` method:

```go
func (m MessageListRequestFilters) apply(req *http.Request) {
query := req.URL.Query()
// ...existing filter wiring...
m.Fields.apply(query)
req.URL.RawQuery = query.Encode()
}
```

Then run `go generate ./...` from the package directory (or the repo root). The output lands in `projects/sparse_fields_gen.go` plus `projects/sparse_fields_gen_test.go`.

#### What the generator emits

- `type <Entity>Field string` plus one constant per JSON-tagged attribute of the entity (same-package embedded structs are flattened; an outer struct's tag shadows the embed's).
- `type <Resource>ListFields struct { ... }` with one typed slice per slot — the main list slice **and** each map field inside the response's `Included` struct. Slot names mirror the response's Go field names; entity keys come from the response's JSON tags.
- `func (f <Resource>ListFields) apply(query url.Values)` writing every populated slot via `twapi.ApplySparseFields`.
- A pair of generated tests per wired list: `Test<Resource>ListFieldsApply` and `Test<Resource>ListFieldsZeroValue`.

#### When *not* to mark a response

- **Responses that wrap anonymous structs** (e.g. some `rates.go` list responses use `[]struct{...}` for their main slice, or `any` for sideloads). The generator can't introspect anonymous types; leave the response unmarked.
- **List endpoints with no filters struct** (e.g. `IndustryListRequest struct{}`). Without a filter to host the `Fields` slot there's no place for the wiring; leave the response unmarked.

In both cases the SDK still works — the endpoint simply can't expose typed sparse fields.

#### Safety nets

- The generator fails fast if a marked response references an entity type that *isn't* marked with `sparsefields:gen` — every slot must resolve to a `<Entity>Field` enum.
- The generator also fails if a filter declares `Fields <Container>` but no method on that filter type ever calls `<receiver>.Fields.apply(...)` — that catches the easy mistake of adding the slot but forgetting to wire it into the request.
- CI runs `go generate ./...` on a clean checkout and fails if anything changes, so stale generated code can't merge.

---

## Common Types
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
33 changes: 23 additions & 10 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,9 @@ func Execute[R HTTPRequester, T HTTPResponser](ctx context.Context, engine *Engi
responser = reflect.New(rt.Elem()).Interface().(T)
}

req, err := requester.HTTPRequest(ctx, engine.session.Server())
resp, err := ExecuteRaw(ctx, engine, requester)
if err != nil {
return responser, fmt.Errorf("failed to create request: %w", err)
}
if err := engine.session.Authenticate(ctx, req); err != nil {
return responser, fmt.Errorf("failed to authenticate request: %w", err)
}

resp, err := engine.client.Do(req)
if err != nil {
return responser, fmt.Errorf("failed to execute request: %w", err)
return responser, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
Expand All @@ -142,3 +134,24 @@ func Execute[R HTTPRequester, T HTTPResponser](ctx context.Context, engine *Engi

return responser, nil
}

// ExecuteRaw sends an HTTP request using the provided requester and returns the
// raw HTTP response. The caller will be responsible for closing the response
// body. This is useful when using sparse fields, where only a subset of the
// fields are returned in the response, and the caller needs to handle the
// response manually.
func ExecuteRaw[R HTTPRequester](ctx context.Context, engine *Engine, requester R) (*http.Response, error) {
req, err := requester.HTTPRequest(ctx, engine.session.Server())
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if err := engine.session.Authenticate(ctx, req); err != nil {
return nil, fmt.Errorf("failed to authenticate request: %w", err)
}

resp, err := engine.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
return resp, nil
}
Loading