diff --git a/Makefile b/Makefile
index 68b3800..75afe7d 100644
--- a/Makefile
+++ b/Makefile
@@ -74,7 +74,25 @@ url-routes-check:
# Drift detection
#------------------------------------------------------------------------------
-.PHONY: drift-check drift-check-mvp drift-check-full drift-regen
+.PHONY: drift-check drift-check-mvp drift-check-full drift-regen route-coverage route-coverage-check
+
+route-coverage:
+ @echo "==> Generating route coverage..."
+ ./scripts/generate-route-coverage
+
+route-coverage-check:
+ @echo "==> Checking route coverage freshness..."
+ @tmpfile=$$(mktemp) || exit 1; \
+ if ! ./scripts/generate-route-coverage openapi.json "$$tmpfile" spec/route-coverage-scope.json > /dev/null; then \
+ rm -f "$$tmpfile"; \
+ exit 1; \
+ fi; \
+ if ! diff -q spec/route-coverage.json "$$tmpfile" > /dev/null 2>&1; then \
+ rm -f "$$tmpfile"; \
+ echo "ERROR: spec/route-coverage.json is out of date. Run 'make route-coverage'"; \
+ exit 1; \
+ fi; \
+ rm -f "$$tmpfile"
# Forward-only: every modeled operation has a matching route
drift-check-forward:
@@ -304,12 +322,12 @@ audit-check:
#------------------------------------------------------------------------------
# Phase 0-1: Smithy model validation
-check-mvp: smithy-check behavior-model-check drift-check-mvp \
+check-mvp: smithy-check behavior-model-check route-coverage-check drift-check-mvp \
url-routes-check go-check go-check-drift conformance-mvp
@echo "==> MVP gate passed"
# Phase 3: Full surface, all languages
-check-full: smithy-check behavior-model-check drift-check-full \
+check-full: smithy-check behavior-model-check route-coverage-check drift-check-full \
sync-api-version-check provenance-check \
go-check-drift kt-check-drift \
go-check ts-check rb-check swift-check kt-check \
diff --git a/behavior-model.json b/behavior-model.json
index e661edc..2288d7c 100644
--- a/behavior-model.json
+++ b/behavior-model.json
@@ -67,6 +67,16 @@
]
}
},
+ "CreateReplyDraft": {
+ "idempotent": false,
+ "readonly": false,
+ "retry": {
+ "backoff": "constant",
+ "base_delay_ms": 1000,
+ "max": 1,
+ "retry_on": []
+ }
+ },
"CreateTopicMessage": {
"idempotent": false,
"readonly": false,
@@ -93,6 +103,16 @@
]
}
},
+ "DeleteDraft": {
+ "idempotent": false,
+ "readonly": false,
+ "retry": {
+ "backoff": "constant",
+ "base_delay_ms": 1000,
+ "max": 1,
+ "retry_on": []
+ }
+ },
"GetAsidebox": {
"idempotent": false,
"readonly": true,
diff --git a/conformance/runner/go/main.go b/conformance/runner/go/main.go
index f0d2441..b2767e4 100644
--- a/conformance/runner/go/main.go
+++ b/conformance/runner/go/main.go
@@ -141,16 +141,27 @@ func runTest(tc TestCase) TestResult {
var requestTimes []time.Time
var requestPaths []string
var requestHeaders []http.Header
+ var requestBodies [][]byte
+ var requestBodyReadErr error
var responseStatuses []int
// Create mock server that serves responses in sequence
responseIndex := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var requestBody []byte
+ var readErr error
+ if r.Body != nil {
+ requestBody, readErr = readRequestBody(r.Body)
+ }
mu.Lock()
+ if readErr != nil && requestBodyReadErr == nil {
+ requestBodyReadErr = readErr
+ }
requestCount++
requestTimes = append(requestTimes, time.Now())
requestPaths = append(requestPaths, r.URL.Path)
requestHeaders = append(requestHeaders, r.Header.Clone())
+ requestBodies = append(requestBodies, requestBody)
idx := responseIndex
responseIndex++
mu.Unlock()
@@ -220,6 +231,15 @@ func runTest(tc TestCase) TestResult {
// Execute the operation
ctx := context.Background()
sdkResp, sdkErr := executeOperation(client, ctx, tc)
+ mu.Lock()
+ capturedRequestBodyReadErr := requestBodyReadErr
+ mu.Unlock()
+ if capturedRequestBodyReadErr != nil {
+ if sdkResp != nil && sdkResp.Body != nil {
+ _ = sdkResp.Body.Close()
+ }
+ return fail(tc.Name, "Failed to read captured request body: %v", capturedRequestBodyReadErr)
+ }
// Capture response body for responseBody assertions
var responseBodyBytes []byte
@@ -265,6 +285,7 @@ func runTest(tc TestCase) TestResult {
requestTimes: requestTimes,
requestPaths: requestPaths,
requestHeaders: requestHeaders,
+ requestBodies: requestBodies,
lastStatus: lastStatus,
sdkErr: sdkErr,
sdkError: sdkError,
@@ -283,6 +304,15 @@ func runTest(tc TestCase) TestResult {
}
}
+func readRequestBody(body io.ReadCloser) ([]byte, error) {
+ requestBody, readErr := io.ReadAll(body)
+ closeErr := body.Close()
+ if readErr != nil {
+ return requestBody, readErr
+ }
+ return requestBody, closeErr
+}
+
// runConfigOverrideTest handles tests that override client configuration
// (e.g. HTTPS enforcement with a non-localhost HTTP URL).
func runConfigOverrideTest(tc TestCase, baseURL string) TestResult {
@@ -351,6 +381,7 @@ type checkState struct {
requestTimes []time.Time
requestPaths []string
requestHeaders []http.Header
+ requestBodies [][]byte
lastStatus int
sdkErr error
sdkError *hey.Error
@@ -481,6 +512,58 @@ func checkAssertion(testName string, a Assertion, s checkState) TestResult {
return fail(testName, "Expected header %q to be present, but it was not", headerName)
}
+ case "requestHeader":
+ expected, ok := a.Expected.(string)
+ if !ok {
+ return fail(testName, "requestHeader: expected a string value, got %T", a.Expected)
+ }
+ if len(s.requestHeaders) == 0 {
+ return fail(testName, "Expected request with header %q, but no requests were recorded", a.Path)
+ }
+ if actual := s.requestHeaders[0].Get(a.Path); actual != expected {
+ return fail(testName, "Expected request header %s=%q, got %q", a.Path, expected, actual)
+ }
+
+ case "requestForm":
+ if len(s.requestBodies) == 0 {
+ return fail(testName, "Expected a form request, but no request bodies were recorded")
+ }
+ values, err := url.ParseQuery(string(s.requestBodies[0]))
+ if err != nil {
+ return fail(testName, "Failed to decode form request: %v", err)
+ }
+ actual := values[a.Path]
+ switch expected := a.Expected.(type) {
+ case string:
+ if len(actual) != 1 || actual[0] != expected {
+ return fail(testName, "Expected form field %s=%q, got %v", a.Path, expected, actual)
+ }
+ case []interface{}:
+ if len(actual) != len(expected) {
+ return fail(testName, "Expected %d values for form field %s, got %v", len(expected), a.Path, actual)
+ }
+ for i := range expected {
+ want, ok := expected[i].(string)
+ if !ok || actual[i] != want {
+ return fail(testName, "Expected form field %s[%d]=%q, got %q", a.Path, i, want, actual[i])
+ }
+ }
+ default:
+ return fail(testName, "requestForm: unsupported expected type %T", a.Expected)
+ }
+
+ case "responseHeader":
+ expected, ok := a.Expected.(string)
+ if !ok {
+ return fail(testName, "responseHeader: expected a string value, got %T", a.Expected)
+ }
+ if s.sdkResp == nil {
+ return fail(testName, "No HTTP response to check header %s", a.Path)
+ }
+ if actual := s.sdkResp.Header.Get(a.Path); actual != expected {
+ return fail(testName, "Expected response header %s=%q, got %q", a.Path, expected, actual)
+ }
+
case "responseMeta":
switch a.Path {
case "totalCount":
@@ -805,6 +888,39 @@ func executeOperation(client *generated.Client, ctx context.Context, tc TestCase
},
}
return client.CreateReply(ctx, entryId, body)
+ case "CreateReplyDraft":
+ entryId := getInt64Param(tc.PathParams, "entryId")
+ body := generated.CreateReplyDraftFormdataRequestBody{
+ ActingSenderId: getInt64Param(tc.RequestBody, "acting_sender_id"),
+ AuthenticityToken: getStringParam(tc.RequestBody, "authenticity_token"),
+ EntryAddressedBlindcopied: getStringSliceParam(tc.RequestBody, "bcc"),
+ EntryAddressedCopied: getStringSliceParam(tc.RequestBody, "cc"),
+ EntryAddressedDirectly: getStringSliceParam(tc.RequestBody, "to"),
+ EntryStatus: "drafted",
+ MessageAutoQuoting: getBoolPtrParam(tc.RequestBody, "auto_quoting"),
+ MessageContent: getStringParam(tc.RequestBody, "content"),
+ MessageSubject: getStringParam(tc.RequestBody, "subject"),
+ }
+ csrf := body.AuthenticityToken
+ return client.CreateReplyDraftWithFormdataBody(ctx, entryId, body, func(_ context.Context, req *http.Request) error {
+ req.Header.Set("Accept", "*/*")
+ if csrf != "" {
+ req.Header.Set("X-CSRF-Token", csrf)
+ }
+ return nil
+ })
+ case "DeleteDraft":
+ messageId := getInt64Param(tc.PathParams, "messageId")
+ csrf := getStringParam(tc.RequestBody, "authenticity_token")
+ params := &generated.DeleteDraftParams{XCSRFToken: csrf}
+ body := generated.DeleteDraftFormdataRequestBody{
+ UnderscoreMethod: "delete",
+ Status: "drafted",
+ }
+ return client.DeleteDraftWithFormdataBody(ctx, messageId, params, body, func(_ context.Context, req *http.Request) error {
+ req.Header.Set("Accept", "*/*")
+ return nil
+ })
// Contacts
case "ListContacts":
@@ -965,6 +1081,33 @@ func getStringParam(params map[string]interface{}, key string) string {
return ""
}
+func getStringSliceParam(params map[string]interface{}, key string) []string {
+ val, ok := params[key]
+ if !ok {
+ return nil
+ }
+ values, ok := val.([]interface{})
+ if !ok {
+ return nil
+ }
+ result := make([]string, 0, len(values))
+ for _, value := range values {
+ if stringValue, ok := value.(string); ok {
+ result = append(result, stringValue)
+ }
+ }
+ return result
+}
+
+func getBoolPtrParam(params map[string]interface{}, key string) *bool {
+ if val, ok := params[key]; ok {
+ if boolValue, ok := val.(bool); ok {
+ return &boolValue
+ }
+ }
+ return nil
+}
+
// getStringPtrParam extracts a *string parameter from a map.
func getStringPtrParam(params map[string]interface{}, key string) *string {
if val, ok := params[key]; ok {
diff --git a/conformance/runner/go/main_test.go b/conformance/runner/go/main_test.go
new file mode 100644
index 0000000..5f231c6
--- /dev/null
+++ b/conformance/runner/go/main_test.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "errors"
+ "io"
+ "testing"
+)
+
+func TestReadRequestBodyPropagatesPartialReadError(t *testing.T) {
+ sentinel := errors.New("request body read failed")
+ reader := &partialErrorReadCloser{
+ contents: []byte("status=drafted"),
+ err: sentinel,
+ }
+
+ body, err := readRequestBody(reader)
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("readRequestBody() error = %v, want %v", err, sentinel)
+ }
+ if got, want := string(body), "status=drafted"; got != want {
+ t.Fatalf("readRequestBody() body = %q, want %q", got, want)
+ }
+ if !reader.closed {
+ t.Fatal("readRequestBody() did not close the request body")
+ }
+}
+
+func TestReadRequestBodyPropagatesCloseError(t *testing.T) {
+ sentinel := errors.New("request body close failed")
+ reader := &partialErrorReadCloser{
+ contents: []byte("status=drafted"),
+ closeErr: sentinel,
+ }
+
+ body, err := readRequestBody(reader)
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("readRequestBody() error = %v, want %v", err, sentinel)
+ }
+ if got, want := string(body), "status=drafted"; got != want {
+ t.Fatalf("readRequestBody() body = %q, want %q", got, want)
+ }
+}
+
+type partialErrorReadCloser struct {
+ contents []byte
+ err error
+ closeErr error
+ read bool
+ closed bool
+}
+
+func (r *partialErrorReadCloser) Read(p []byte) (int, error) {
+ if r.read {
+ return 0, io.EOF
+ }
+ r.read = true
+ return copy(p, r.contents), r.err
+}
+
+func (r *partialErrorReadCloser) Close() error {
+ r.closed = true
+ return r.closeErr
+}
diff --git a/conformance/tests/draft-deletion.json b/conformance/tests/draft-deletion.json
new file mode 100644
index 0000000..63c6efe
--- /dev/null
+++ b/conformance/tests/draft-deletion.json
@@ -0,0 +1,31 @@
+[
+ {
+ "name": "DeleteDraft uses the browser discard form without following redirects",
+ "description": "Verifies the Rails method override, drafted guard, CSRF header, redirect preservation, and single-attempt browser form transport",
+ "operation": "DeleteDraft",
+ "method": "POST",
+ "path": "/messages/{messageId}",
+ "pathParams": {"messageId": 987},
+ "requestBody": {
+ "authenticity_token": "conformance-delete-csrf"
+ },
+ "mockResponses": [
+ {
+ "status": 303,
+ "headers": {"Location": "/topics/123"}
+ }
+ ],
+ "assertions": [
+ {"type": "requestCount", "expected": 1},
+ {"type": "requestPath", "expected": "/messages/987"},
+ {"type": "requestHeader", "path": "Content-Type", "expected": "application/x-www-form-urlencoded"},
+ {"type": "requestHeader", "path": "X-CSRF-Token", "expected": "conformance-delete-csrf"},
+ {"type": "requestForm", "path": "_method", "expected": "delete"},
+ {"type": "requestForm", "path": "status", "expected": "drafted"},
+ {"type": "responseHeader", "path": "Location", "expected": "/topics/123"},
+ {"type": "statusCode", "expected": 303},
+ {"type": "noError"}
+ ],
+ "tags": ["entries", "drafts", "delete", "form", "csrf"]
+ }
+]
diff --git a/conformance/tests/reply-drafts.json b/conformance/tests/reply-drafts.json
new file mode 100644
index 0000000..52e837d
--- /dev/null
+++ b/conformance/tests/reply-drafts.json
@@ -0,0 +1,43 @@
+[
+ {
+ "name": "CreateReplyDraft preserves rich HTML and complete recipients",
+ "description": "Verifies the browser-compatible reply draft path, Rails form encoding, CSRF propagation, semantic rich HTML, and Location response",
+ "operation": "CreateReplyDraft",
+ "method": "POST",
+ "path": "/entries/{entryId}/replies",
+ "pathParams": {"entryId": 456},
+ "requestBody": {
+ "acting_sender_id": 42,
+ "authenticity_token": "conformance-csrf-token",
+ "subject": "Re: Project update",
+ "content": "
New reply
- First item
- Second item
Quoted thread
Signature
",
+ "auto_quoting": true,
+ "to": ["one@example.com", "two@example.org"],
+ "cc": ["copy@example.com"],
+ "bcc": ["blind@example.org"]
+ },
+ "mockResponses": [
+ {
+ "status": 201,
+ "headers": {"Location": "/entries/drafts/789"}
+ }
+ ],
+ "assertions": [
+ {"type": "requestPath", "expected": "/entries/456/replies"},
+ {"type": "requestHeader", "path": "Content-Type", "expected": "application/x-www-form-urlencoded"},
+ {"type": "requestHeader", "path": "X-CSRF-Token", "expected": "conformance-csrf-token"},
+ {"type": "requestForm", "path": "acting_sender_id", "expected": "42"},
+ {"type": "requestForm", "path": "entry[status]", "expected": "drafted"},
+ {"type": "requestForm", "path": "message[subject]", "expected": "Re: Project update"},
+ {"type": "requestForm", "path": "message[content]", "expected": "New reply
- First item
- Second item
Quoted thread
Signature
"},
+ {"type": "requestForm", "path": "message[auto_quoting]", "expected": "true"},
+ {"type": "requestForm", "path": "entry[addressed][directly][]", "expected": ["one@example.com", "two@example.org"]},
+ {"type": "requestForm", "path": "entry[addressed][copied][]", "expected": ["copy@example.com"]},
+ {"type": "requestForm", "path": "entry[addressed][blindcopied][]", "expected": ["blind@example.org"]},
+ {"type": "responseHeader", "path": "Location", "expected": "/entries/drafts/789"},
+ {"type": "statusCode", "expected": 201},
+ {"type": "noError"}
+ ],
+ "tags": ["entries", "drafts", "form", "rich-html", "csrf"]
+ }
+]
diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go
index 837aafb..2e43e5b 100644
--- a/go/pkg/generated/client.gen.go
+++ b/go/pkg/generated/client.gen.go
@@ -12,8 +12,10 @@ import (
"io"
"log/slog"
"math/rand"
+ "mime"
"net/http"
"net/url"
+ "sort"
"strconv"
"strings"
"sync"
@@ -65,6 +67,11 @@ type Attendance struct {
Status string `json:"status,omitempty"`
}
+// BadRequestErrorResponseContent defines model for BadRequestErrorResponseContent.
+type BadRequestErrorResponseContent struct {
+ Message string `json:"message"`
+}
+
// Box Box — a HEY mailbox
type Box struct {
AppUrl string `json:"app_url,omitempty"`
@@ -225,10 +232,11 @@ type CreateMessageRequestContent struct {
Message MessagePayload `json:"message"`
}
-// CreateReplyRequestContent Wire format: {acting_sender_id, message: {content}}
+// CreateReplyRequestContent Wire format: {acting_sender_id, message: {content}, entry: {addressed: {...}}}
type CreateReplyRequestContent struct {
- ActingSenderId int64 `json:"acting_sender_id"`
- Message ReplyMessagePayload `json:"message"`
+ ActingSenderId int64 `json:"acting_sender_id"`
+ Entry *MessageEntryPayload `json:"entry,omitempty"`
+ Message ReplyMessagePayload `json:"message"`
}
// CreateTopicMessageRequestContent Wire format: {acting_sender_id, message: {content}}
@@ -311,6 +319,11 @@ type Folder struct {
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
+// ForbiddenErrorResponseContent defines model for ForbiddenErrorResponseContent.
+type ForbiddenErrorResponseContent struct {
+ Message string `json:"message"`
+}
+
// GetAsideboxResponseContent BoxShowResponse — box detail with postings.
// The API can return fields at root level or nested under a `box` key.
// SDK response decoders normalize the nested variant to flat before decoding.
@@ -900,6 +913,19 @@ type ListDraftsParams struct {
Page string `form:"page,omitempty" json:"page,omitempty"`
}
+// CreateReplyDraftFormdataBody defines parameters for CreateReplyDraft.
+type CreateReplyDraftFormdataBody struct {
+ ActingSenderId int64 `form:"acting_sender_id" json:"acting_sender_id"`
+ AuthenticityToken string `form:"authenticity_token,omitempty" json:"authenticity_token,omitempty"`
+ EntryAddressedBlindcopied []string `form:"entry[addressed][blindcopied][],omitempty" json:"entry[addressed][blindcopied][],omitempty"`
+ EntryAddressedCopied []string `form:"entry[addressed][copied][],omitempty" json:"entry[addressed][copied][],omitempty"`
+ EntryAddressedDirectly []string `form:"entry[addressed][directly][],omitempty" json:"entry[addressed][directly][],omitempty"`
+ EntryStatus string `form:"entry[status]" json:"entry[status]"`
+ MessageAutoQuoting *bool `form:"message[auto_quoting],omitempty" json:"message[auto_quoting],omitempty"`
+ MessageContent string `form:"message[content]" json:"message[content]"`
+ MessageSubject string `form:"message[subject],omitempty" json:"message[subject],omitempty"`
+}
+
// GetFeedboxParams defines parameters for GetFeedbox.
type GetFeedboxParams struct {
Page string `form:"page,omitempty" json:"page,omitempty"`
@@ -910,6 +936,17 @@ type GetImboxParams struct {
Page string `form:"page,omitempty" json:"page,omitempty"`
}
+// DeleteDraftFormdataBody defines parameters for DeleteDraft.
+type DeleteDraftFormdataBody struct {
+ UnderscoreMethod string `form:"_method" json:"_method"`
+ Status string `form:"status" json:"status"`
+}
+
+// DeleteDraftParams defines parameters for DeleteDraft.
+type DeleteDraftParams struct {
+ XCSRFToken string `json:"X-CSRF-Token"`
+}
+
// GetTrailboxParams defines parameters for GetTrailbox.
type GetTrailboxParams struct {
Page string `form:"page,omitempty" json:"page,omitempty"`
@@ -968,12 +1005,18 @@ type UpdateTimeTrackJSONRequestBody = UpdateTimeTrackRequestContent
// CreateCalendarTodoJSONRequestBody defines body for CreateCalendarTodo for application/json ContentType.
type CreateCalendarTodoJSONRequestBody = CreateCalendarTodoRequestContent
+// CreateReplyDraftFormdataRequestBody defines body for CreateReplyDraft for application/x-www-form-urlencoded ContentType.
+type CreateReplyDraftFormdataRequestBody CreateReplyDraftFormdataBody
+
// CreateReplyJSONRequestBody defines body for CreateReply for application/json ContentType.
type CreateReplyJSONRequestBody = CreateReplyRequestContent
// CreateMessageJSONRequestBody defines body for CreateMessage for application/json ContentType.
type CreateMessageJSONRequestBody = CreateMessageRequestContent
+// DeleteDraftFormdataRequestBody defines body for DeleteDraft for application/x-www-form-urlencoded ContentType.
+type DeleteDraftFormdataRequestBody DeleteDraftFormdataBody
+
// MarkPostingsSeenJSONRequestBody defines body for MarkPostingsSeen for application/json ContentType.
type MarkPostingsSeenJSONRequestBody = MarkPostingsRequestContent
@@ -1065,6 +1108,33 @@ func NewClient(server string, opts ...ClientOption) (*Client, error) {
return &client, nil
}
+func isFormURLEncoded(contentType string) bool {
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ return err == nil && mediaType == "application/x-www-form-urlencoded"
+}
+
+// doRequest prevents a concrete *http.Client from following redirects for
+// browser-compatible form operations, allowing callers to inspect the
+// resource-identifying Location header. It leaves the exported Client doer
+// unchanged and preserves configured behavior for custom doers and non-form
+// requests.
+func (c *Client) doRequest(req *http.Request) (*http.Response, error) {
+ if !isFormURLEncoded(req.Header.Get("Content-Type")) {
+ return c.Client.Do(req)
+ }
+
+ httpClient, ok := c.Client.(*http.Client)
+ if !ok {
+ return c.Client.Do(req)
+ }
+
+ noRedirect := *httpClient
+ noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }
+ return noRedirect.Do(req)
+}
+
// WithHTTPClient allows overriding the default Doer, which is
// automatically created using http.Client. This is useful for tests.
func WithHTTPClient(doer HttpRequestDoer) ClientOption {
@@ -1099,6 +1169,48 @@ func WithLogger(logger *slog.Logger) ClientOption {
}
}
+// normalizeRailsArrayFormValues converts the indexed keys produced by
+// runtime.MarshalForm for explicitly bracketed Rails array fields back into
+// repeated [] keys. For example, recipients[][0] and recipients[][1] become
+// two recipients[] values, matching browser form submission semantics.
+func normalizeRailsArrayFormValues(values url.Values) url.Values {
+ type formValue struct {
+ key string
+ normalized string
+ index int
+ indexed bool
+ items []string
+ }
+
+ entries := make([]formValue, 0, len(values))
+ for key, items := range values {
+ entry := formValue{key: key, normalized: key, items: items}
+ if marker := strings.LastIndex(key, "[]["); marker >= 0 && strings.HasSuffix(key, "]") {
+ if index, err := strconv.Atoi(key[marker+3 : len(key)-1]); err == nil {
+ entry.normalized = key[:marker+2]
+ entry.index = index
+ entry.indexed = true
+ }
+ }
+ entries = append(entries, entry)
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ if entries[i].normalized != entries[j].normalized {
+ return entries[i].normalized < entries[j].normalized
+ }
+ if entries[i].indexed && entries[j].indexed && entries[i].index != entries[j].index {
+ return entries[i].index < entries[j].index
+ }
+ return entries[i].key < entries[j].key
+ })
+
+ normalized := make(url.Values, len(values))
+ for _, entry := range entries {
+ normalized[entry.normalized] = append(normalized[entry.normalized], entry.items...)
+ }
+ return normalized
+}
+
// isRetryableStatus returns true if the HTTP status code indicates a retryable error.
func isRetryableStatus(statusCode int) bool {
switch statusCode {
@@ -1134,7 +1246,7 @@ func (c *Client) doWithRetry(ctx context.Context, buildRequest func() (*http.Req
return nil, err
}
- resp, err := c.Client.Do(req)
+ resp, err := c.doRequest(req)
if err != nil {
lastErr = err
if c.Logger != nil {
@@ -1276,6 +1388,11 @@ type ClientInterface interface {
// ListDrafts request
ListDrafts(ctx context.Context, params *ListDraftsParams, reqEditors ...RequestEditorFn) (*http.Response, error)
+ // CreateReplyDraftWithBody request with any body
+ CreateReplyDraftWithBody(ctx context.Context, entryId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ CreateReplyDraftWithFormdataBody(ctx context.Context, entryId int64, body CreateReplyDraftFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
+
// CreateReplyWithBody request with any body
CreateReplyWithBody(ctx context.Context, entryId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1298,6 +1415,11 @@ type ClientInterface interface {
// GetMessage request
GetMessage(ctx context.Context, messageId int64, reqEditors ...RequestEditorFn) (*http.Response, error)
+ // DeleteDraftWithBody request with any body
+ DeleteDraftWithBody(ctx context.Context, messageId int64, params *DeleteDraftParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ DeleteDraftWithFormdataBody(ctx context.Context, messageId int64, params *DeleteDraftParams, body DeleteDraftFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
+
// GetNavigation request
GetNavigation(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1437,7 +1559,7 @@ func (c *Client) UpdateJournalEntryWithBody(ctx context.Context, day string, con
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1451,7 +1573,7 @@ func (c *Client) UpdateJournalEntry(ctx context.Context, day string, body Update
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1477,7 +1599,7 @@ func (c *Client) StartTimeTrackWithBody(ctx context.Context, contentType string,
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1491,7 +1613,7 @@ func (c *Client) StartTimeTrack(ctx context.Context, body StartTimeTrackJSONRequ
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1525,7 +1647,7 @@ func (c *Client) CreateCalendarTodoWithBody(ctx context.Context, contentType str
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1539,7 +1661,7 @@ func (c *Client) CreateCalendarTodo(ctx context.Context, body CreateCalendarTodo
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1623,6 +1745,36 @@ func (c *Client) ListDrafts(ctx context.Context, params *ListDraftsParams, reqEd
}
+// CreateReplyDraftWithBody executes the CreateReplyDraft operation.
+
+func (c *Client) CreateReplyDraftWithBody(ctx context.Context, entryId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
+
+ req, err := NewCreateReplyDraftRequestWithBody(c.Server, entryId, contentType, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.doRequest(req)
+
+}
+
+func (c *Client) CreateReplyDraftWithFormdataBody(ctx context.Context, entryId int64, body CreateReplyDraftFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
+
+ req, err := NewCreateReplyDraftRequestWithFormdataBody(c.Server, entryId, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.doRequest(req)
+
+}
+
// CreateReplyWithBody executes the CreateReply operation.
func (c *Client) CreateReplyWithBody(ctx context.Context, entryId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
@@ -1635,7 +1787,7 @@ func (c *Client) CreateReplyWithBody(ctx context.Context, entryId int64, content
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1649,7 +1801,7 @@ func (c *Client) CreateReply(ctx context.Context, entryId int64, body CreateRepl
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1695,7 +1847,7 @@ func (c *Client) CreateMessageWithBody(ctx context.Context, contentType string,
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1709,7 +1861,7 @@ func (c *Client) CreateMessage(ctx context.Context, body CreateMessageJSONReques
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1723,6 +1875,36 @@ func (c *Client) GetMessage(ctx context.Context, messageId int64, reqEditors ...
}
+// DeleteDraftWithBody executes the DeleteDraft operation.
+
+func (c *Client) DeleteDraftWithBody(ctx context.Context, messageId int64, params *DeleteDraftParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
+
+ req, err := NewDeleteDraftRequestWithBody(c.Server, messageId, params, contentType, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.doRequest(req)
+
+}
+
+func (c *Client) DeleteDraftWithFormdataBody(ctx context.Context, messageId int64, params *DeleteDraftParams, body DeleteDraftFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
+
+ req, err := NewDeleteDraftRequestWithFormdataBody(c.Server, messageId, params, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.doRequest(req)
+
+}
+
// GetNavigation is marked as idempotent and will be retried on transient failures.
func (c *Client) GetNavigation(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
@@ -1755,7 +1937,7 @@ func (c *Client) MarkPostingsSeenWithBody(ctx context.Context, contentType strin
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1769,7 +1951,7 @@ func (c *Client) MarkPostingsSeen(ctx context.Context, body MarkPostingsSeenJSON
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1785,7 +1967,7 @@ func (c *Client) MarkPostingsUnseenWithBody(ctx context.Context, contentType str
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1799,7 +1981,7 @@ func (c *Client) MarkPostingsUnseen(ctx context.Context, body MarkPostingsUnseen
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1815,7 +1997,7 @@ func (c *Client) IgnorePosting(ctx context.Context, postingId int64, reqEditors
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1831,7 +2013,7 @@ func (c *Client) MovePostingToSetAside(ctx context.Context, postingId int64, req
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1847,7 +2029,7 @@ func (c *Client) MovePostingToFeed(ctx context.Context, postingId int64, reqEdit
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1863,7 +2045,7 @@ func (c *Client) MovePostingToReplyLater(ctx context.Context, postingId int64, r
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1879,7 +2061,7 @@ func (c *Client) MovePostingToPaperTrail(ctx context.Context, postingId int64, r
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -1895,7 +2077,7 @@ func (c *Client) MovePostingToTrash(ctx context.Context, postingId int64, reqEdi
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -2001,7 +2183,7 @@ func (c *Client) CreateTopicMessageWithBody(ctx context.Context, topicId int64,
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -2015,7 +2197,7 @@ func (c *Client) CreateTopicMessage(ctx context.Context, topicId int64, body Cre
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
}
@@ -2789,6 +2971,54 @@ func NewListDraftsRequest(server string, params *ListDraftsParams) (*http.Reques
return req, nil
}
+// NewCreateReplyDraftRequestWithFormdataBody calls the generic CreateReplyDraft builder with application/x-www-form-urlencoded body
+func NewCreateReplyDraftRequestWithFormdataBody(server string, entryId int64, body CreateReplyDraftFormdataRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ bodyStr, err := runtime.MarshalForm(body, nil)
+ if err != nil {
+ return nil, err
+ }
+ bodyStr = normalizeRailsArrayFormValues(bodyStr)
+ bodyReader = strings.NewReader(bodyStr.Encode())
+ return NewCreateReplyDraftRequestWithBody(server, entryId, "application/x-www-form-urlencoded", bodyReader)
+}
+
+// NewCreateReplyDraftRequestWithBody generates requests for CreateReplyDraft with any type of body
+func NewCreateReplyDraftRequestWithBody(server string, entryId int64, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithLocation("simple", false, "entryId", runtime.ParamLocationPath, entryId)
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/entries/%s/replies", pathParam0)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", queryURL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+
+ return req, nil
+}
+
// NewCreateReplyRequest calls the generic CreateReply builder with application/json body
func NewCreateReplyRequest(server string, entryId int64, body CreateReplyJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
@@ -3027,6 +3257,67 @@ func NewGetMessageRequest(server string, messageId int64) (*http.Request, error)
return req, nil
}
+// NewDeleteDraftRequestWithFormdataBody calls the generic DeleteDraft builder with application/x-www-form-urlencoded body
+func NewDeleteDraftRequestWithFormdataBody(server string, messageId int64, params *DeleteDraftParams, body DeleteDraftFormdataRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ bodyStr, err := runtime.MarshalForm(body, nil)
+ if err != nil {
+ return nil, err
+ }
+ bodyStr = normalizeRailsArrayFormValues(bodyStr)
+ bodyReader = strings.NewReader(bodyStr.Encode())
+ return NewDeleteDraftRequestWithBody(server, messageId, params, "application/x-www-form-urlencoded", bodyReader)
+}
+
+// NewDeleteDraftRequestWithBody generates requests for DeleteDraft with any type of body
+func NewDeleteDraftRequestWithBody(server string, messageId int64, params *DeleteDraftParams, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithLocation("simple", false, "messageId", runtime.ParamLocationPath, messageId)
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/messages/%s", pathParam0)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", queryURL.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+
+ if params != nil {
+
+ var headerParam0 string
+
+ headerParam0, err = runtime.StyleParamWithLocation("simple", false, "X-CSRF-Token", runtime.ParamLocationHeader, params.XCSRFToken)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("X-CSRF-Token", headerParam0)
+
+ }
+
+ return req, nil
+}
+
// NewGetNavigationRequest generates requests for GetNavigation
func NewGetNavigationRequest(server string) (*http.Request, error) {
var err error
@@ -3888,12 +4179,14 @@ var operationMetadata = map[string]OperationMetadata{
"ListContacts": {Idempotent: true, HasSensitiveParams: false},
"GetContact": {Idempotent: true, HasSensitiveParams: false},
"ListDrafts": {Idempotent: true, HasSensitiveParams: false},
+ "CreateReplyDraft": {Idempotent: false, HasSensitiveParams: true},
"CreateReply": {Idempotent: false, HasSensitiveParams: false},
"GetFeedbox": {Idempotent: true, HasSensitiveParams: false},
"GetIdentity": {Idempotent: true, HasSensitiveParams: false},
"GetImbox": {Idempotent: true, HasSensitiveParams: false},
"CreateMessage": {Idempotent: false, HasSensitiveParams: false},
"GetMessage": {Idempotent: true, HasSensitiveParams: false},
+ "DeleteDraft": {Idempotent: false, HasSensitiveParams: true},
"GetNavigation": {Idempotent: true, HasSensitiveParams: false},
"GetTrailbox": {Idempotent: true, HasSensitiveParams: false},
"MarkPostingsSeen": {Idempotent: false, HasSensitiveParams: false},
@@ -4583,6 +4876,11 @@ type ClientWithResponsesInterface interface {
// ListDraftsWithResponse request
ListDraftsWithResponse(ctx context.Context, params *ListDraftsParams, reqEditors ...RequestEditorFn) (*ListDraftsResponse, error)
+ // CreateReplyDraftWithBodyWithResponse request with any body
+ CreateReplyDraftWithBodyWithResponse(ctx context.Context, entryId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateReplyDraftResponse, error)
+
+ CreateReplyDraftWithFormdataBodyWithResponse(ctx context.Context, entryId int64, body CreateReplyDraftFormdataRequestBody, reqEditors ...RequestEditorFn) (*CreateReplyDraftResponse, error)
+
// CreateReplyWithBodyWithResponse request with any body
CreateReplyWithBodyWithResponse(ctx context.Context, entryId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateReplyResponse, error)
@@ -4605,6 +4903,11 @@ type ClientWithResponsesInterface interface {
// GetMessageWithResponse request
GetMessageWithResponse(ctx context.Context, messageId int64, reqEditors ...RequestEditorFn) (*GetMessageResponse, error)
+ // DeleteDraftWithBodyWithResponse request with any body
+ DeleteDraftWithBodyWithResponse(ctx context.Context, messageId int64, params *DeleteDraftParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteDraftResponse, error)
+
+ DeleteDraftWithFormdataBodyWithResponse(ctx context.Context, messageId int64, params *DeleteDraftParams, body DeleteDraftFormdataRequestBody, reqEditors ...RequestEditorFn) (*DeleteDraftResponse, error)
+
// GetNavigationWithResponse request
GetNavigationWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetNavigationResponse, error)
@@ -5160,6 +5463,34 @@ func (r ListDraftsResponse) StatusCode() int {
return 0
}
+type CreateReplyDraftResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON400 *BadRequestErrorResponseContent
+ JSON401 *UnauthorizedErrorResponseContent
+ JSON403 *ForbiddenErrorResponseContent
+ JSON404 *NotFoundErrorResponseContent
+ JSON422 *UnprocessableEntityErrorResponseContent
+ JSON500 *InternalServerErrorResponseContent
+ JSON503 *ServiceUnavailableErrorResponseContent
+}
+
+// Status returns HTTPResponse.Status
+func (r CreateReplyDraftResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r CreateReplyDraftResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
type CreateReplyResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -5312,6 +5643,34 @@ func (r GetMessageResponse) StatusCode() int {
return 0
}
+type DeleteDraftResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON400 *BadRequestErrorResponseContent
+ JSON401 *UnauthorizedErrorResponseContent
+ JSON403 *ForbiddenErrorResponseContent
+ JSON404 *NotFoundErrorResponseContent
+ JSON422 *UnprocessableEntityErrorResponseContent
+ JSON500 *InternalServerErrorResponseContent
+ JSON503 *ServiceUnavailableErrorResponseContent
+}
+
+// Status returns HTTPResponse.Status
+func (r DeleteDraftResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r DeleteDraftResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
type GetNavigationResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -6016,6 +6375,23 @@ func (c *ClientWithResponses) ListDraftsWithResponse(ctx context.Context, params
return ParseListDraftsResponse(rsp)
}
+// CreateReplyDraftWithBodyWithResponse request with arbitrary body returning *CreateReplyDraftResponse
+func (c *ClientWithResponses) CreateReplyDraftWithBodyWithResponse(ctx context.Context, entryId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateReplyDraftResponse, error) {
+ rsp, err := c.CreateReplyDraftWithBody(ctx, entryId, contentType, body, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseCreateReplyDraftResponse(rsp)
+}
+
+func (c *ClientWithResponses) CreateReplyDraftWithFormdataBodyWithResponse(ctx context.Context, entryId int64, body CreateReplyDraftFormdataRequestBody, reqEditors ...RequestEditorFn) (*CreateReplyDraftResponse, error) {
+ rsp, err := c.CreateReplyDraftWithFormdataBody(ctx, entryId, body, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseCreateReplyDraftResponse(rsp)
+}
+
// CreateReplyWithBodyWithResponse request with arbitrary body returning *CreateReplyResponse
func (c *ClientWithResponses) CreateReplyWithBodyWithResponse(ctx context.Context, entryId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateReplyResponse, error) {
rsp, err := c.CreateReplyWithBody(ctx, entryId, contentType, body, reqEditors...)
@@ -6086,6 +6462,23 @@ func (c *ClientWithResponses) GetMessageWithResponse(ctx context.Context, messag
return ParseGetMessageResponse(rsp)
}
+// DeleteDraftWithBodyWithResponse request with arbitrary body returning *DeleteDraftResponse
+func (c *ClientWithResponses) DeleteDraftWithBodyWithResponse(ctx context.Context, messageId int64, params *DeleteDraftParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteDraftResponse, error) {
+ rsp, err := c.DeleteDraftWithBody(ctx, messageId, params, contentType, body, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseDeleteDraftResponse(rsp)
+}
+
+func (c *ClientWithResponses) DeleteDraftWithFormdataBodyWithResponse(ctx context.Context, messageId int64, params *DeleteDraftParams, body DeleteDraftFormdataRequestBody, reqEditors ...RequestEditorFn) (*DeleteDraftResponse, error) {
+ rsp, err := c.DeleteDraftWithFormdataBody(ctx, messageId, params, body, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseDeleteDraftResponse(rsp)
+}
+
// GetNavigationWithResponse request returning *GetNavigationResponse
func (c *ClientWithResponses) GetNavigationWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetNavigationResponse, error) {
rsp, err := c.GetNavigation(ctx, reqEditors...)
@@ -7274,6 +7667,74 @@ func ParseListDraftsResponse(rsp *http.Response) (*ListDraftsResponse, error) {
return response, nil
}
+// ParseCreateReplyDraftResponse parses an HTTP response from a CreateReplyDraftWithResponse call
+func ParseCreateReplyDraftResponse(rsp *http.Response) (*CreateReplyDraftResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &CreateReplyDraftResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
+ var dest BadRequestErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON400 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
+ var dest UnauthorizedErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON401 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
+ var dest ForbiddenErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON403 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
+ var dest NotFoundErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON404 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422:
+ var dest UnprocessableEntityErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON422 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
+ var dest InternalServerErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON500 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503:
+ var dest ServiceUnavailableErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON503 = &dest
+
+ }
+
+ return response, nil
+}
+
// ParseCreateReplyResponse parses an HTTP response from a CreateReplyWithResponse call
func ParseCreateReplyResponse(rsp *http.Response) (*CreateReplyResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
@@ -7570,6 +8031,74 @@ func ParseGetMessageResponse(rsp *http.Response) (*GetMessageResponse, error) {
return response, nil
}
+// ParseDeleteDraftResponse parses an HTTP response from a DeleteDraftWithResponse call
+func ParseDeleteDraftResponse(rsp *http.Response) (*DeleteDraftResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &DeleteDraftResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
+ var dest BadRequestErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON400 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
+ var dest UnauthorizedErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON401 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
+ var dest ForbiddenErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON403 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
+ var dest NotFoundErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON404 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422:
+ var dest UnprocessableEntityErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON422 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
+ var dest InternalServerErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON500 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503:
+ var dest ServiceUnavailableErrorResponseContent
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON503 = &dest
+
+ }
+
+ return response, nil
+}
+
// ParseGetNavigationResponse parses an HTTP response from a GetNavigationWithResponse call
func ParseGetNavigationResponse(rsp *http.Response) (*GetNavigationResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
diff --git a/go/pkg/generated/client_test.go b/go/pkg/generated/client_test.go
new file mode 100644
index 0000000..73a2a3f
--- /dev/null
+++ b/go/pkg/generated/client_test.go
@@ -0,0 +1,207 @@
+package generated
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestNewClientPreservesHTTPClientIdentity(t *testing.T) {
+ defaultClient, err := NewClient("https://example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := defaultClient.Client.(*http.Client); !ok {
+ t.Fatalf("default Client doer has type %T, want *http.Client", defaultClient.Client)
+ }
+
+ supplied := &http.Client{Timeout: 17 * time.Second}
+ client, err := NewClient("https://example.com", WithHTTPClient(supplied))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if client.Client != supplied {
+ t.Fatalf("Client doer = %p, want supplied client %p", client.Client, supplied)
+ }
+}
+
+func TestIsFormURLEncoded(t *testing.T) {
+ tests := map[string]bool{
+ "application/x-www-form-urlencoded": true,
+ "application/x-www-form-urlencoded; charset=utf-8": true,
+ "application/x-www-form-urlencoded-extra": false,
+ "application/json": false,
+ "not a media type": false,
+ }
+ for contentType, expected := range tests {
+ t.Run(contentType, func(t *testing.T) {
+ if got := isFormURLEncoded(contentType); got != expected {
+ t.Fatalf("isFormURLEncoded(%q) = %v, want %v", contentType, got, expected)
+ }
+ })
+ }
+}
+
+func TestDoRequestCapturesFormRedirectWithoutMutatingHTTPClient(t *testing.T) {
+ var redirectRequests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/redirected" {
+ redirectRequests.Add(1)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ w.Header().Set("Location", "/redirected")
+ w.WriteHeader(http.StatusFound)
+ }))
+ defer server.Close()
+
+ var redirectChecks atomic.Int32
+ supplied := &http.Client{
+ Timeout: 17 * time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ redirectChecks.Add(1)
+ return nil
+ },
+ }
+ client, err := NewClient(server.URL, WithHTTPClient(supplied))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/start", strings.NewReader("status=drafted"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
+ response, err := client.doRequest(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusFound {
+ t.Fatalf("form response status = %d, want %d", response.StatusCode, http.StatusFound)
+ }
+ if got := response.Header.Get("Location"); got != "/redirected" {
+ t.Fatalf("form response Location = %q, want %q", got, "/redirected")
+ }
+ if got := redirectRequests.Load(); got != 0 {
+ t.Fatalf("form request followed redirect %d times, want 0", got)
+ }
+ if got := redirectChecks.Load(); got != 0 {
+ t.Fatalf("form request called supplied CheckRedirect %d times, want 0", got)
+ }
+ if client.Client != supplied || supplied.Timeout != 17*time.Second {
+ t.Fatal("form request mutated or replaced the supplied HTTP client")
+ }
+
+ directRequest, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL+"/start", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ directResponse, err := supplied.Do(directRequest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer directResponse.Body.Close()
+ if directResponse.StatusCode != http.StatusOK {
+ t.Fatalf("direct response status = %d, want %d", directResponse.StatusCode, http.StatusOK)
+ }
+ if got := redirectRequests.Load(); got != 1 {
+ t.Fatalf("direct request followed redirect %d times, want 1", got)
+ }
+ if got := redirectChecks.Load(); got != 1 {
+ t.Fatalf("direct request called supplied CheckRedirect %d times, want 1", got)
+ }
+}
+
+func TestDoRequestPreservesCustomDoerIdentity(t *testing.T) {
+ doer := &recordingDoer{}
+ client, err := NewClient("https://example.com", WithHTTPClient(doer))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if client.Client != doer {
+ t.Fatal("NewClient replaced the supplied custom doer")
+ }
+
+ request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://example.com/drafts", strings.NewReader("status=drafted"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response, err := client.doRequest(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ if got := doer.calls.Load(); got != 1 {
+ t.Fatalf("custom doer calls = %d, want 1", got)
+ }
+}
+
+func TestPostConstructionTransportOptionsPreserveExistingTransport(t *testing.T) {
+ base := &recordingTransport{}
+ client, err := NewClient("https://example.com", WithHTTPClient(&http.Client{Transport: base}))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := WithAuthTransport(&StaticTokenProvider{Token: "test-token"}, "test-agent")(client); err != nil {
+ t.Fatal(err)
+ }
+ httpClient, ok := client.Client.(*http.Client)
+ if !ok {
+ t.Fatalf("authenticated Client doer has type %T, want *http.Client", client.Client)
+ }
+ authTransport, ok := httpClient.Transport.(*AuthTransport)
+ if !ok {
+ t.Fatalf("authenticated transport has type %T, want *AuthTransport", httpClient.Transport)
+ }
+ if authTransport.Base != base {
+ t.Fatal("WithAuthTransport did not preserve the existing transport")
+ }
+
+ if err := WithCachingTransport(NewInMemoryCache())(client); err != nil {
+ t.Fatal(err)
+ }
+ httpClient, ok = client.Client.(*http.Client)
+ if !ok {
+ t.Fatalf("cached Client doer has type %T, want *http.Client", client.Client)
+ }
+ cachingTransport, ok := httpClient.Transport.(*CachingTransport)
+ if !ok {
+ t.Fatalf("cached transport has type %T, want *CachingTransport", httpClient.Transport)
+ }
+ if cachingTransport.Base != authTransport {
+ t.Fatal("WithCachingTransport did not preserve the authenticated transport")
+ }
+}
+
+type recordingDoer struct {
+ calls atomic.Int32
+}
+
+func (d *recordingDoer) Do(*http.Request) (*http.Response, error) {
+ d.calls.Add(1)
+ return &http.Response{
+ StatusCode: http.StatusFound,
+ Header: http.Header{"Location": []string{"/drafts/1"}},
+ Body: io.NopCloser(strings.NewReader("")),
+ }, nil
+}
+
+type recordingTransport struct{}
+
+func (*recordingTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader("")),
+ }, nil
+}
+
+var _ http.RoundTripper = (*recordingTransport)(nil)
+var _ HttpRequestDoer = (*recordingDoer)(nil)
diff --git a/go/pkg/hey/entries.go b/go/pkg/hey/entries.go
index 82f6556..405e2a3 100644
--- a/go/pkg/hey/entries.go
+++ b/go/pkg/hey/entries.go
@@ -3,6 +3,10 @@ package hey
import (
"context"
"fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
"time"
"github.com/basecamp/hey-sdk/go/pkg/generated"
@@ -13,6 +17,41 @@ type EntriesService struct {
client *Client
}
+// CreateReplyDraftParams contains the browser-composer fields used to save an
+// editable reply draft. Content must already be rich ActionText HTML; the SDK
+// transmits it without modification.
+type CreateReplyDraftParams struct {
+ ActingSenderID int64
+ Content string
+ Subject string
+ AutoQuoting *bool
+ To []string
+ CC []string
+ BCC []string
+ AuthenticityToken string
+}
+
+// ReplyDraft identifies a newly saved reply draft.
+type ReplyDraft struct {
+ ID int64 `json:"id"`
+ Location string `json:"location"`
+ EditURL string `json:"edit_url"`
+}
+
+// DeleteDraftParams contains the browser form security fields used to discard
+// an editable draft.
+type DeleteDraftParams struct {
+ AuthenticityToken string
+}
+
+// DraftDeletion describes the response returned after a draft is discarded.
+// HEY may return a redirect to the surrounding topic, so Location is preserved
+// without following it.
+type DraftDeletion struct {
+ Location string `json:"location"`
+ StatusCode int `json:"status_code"`
+}
+
// NewEntriesService creates a new EntriesService.
func NewEntriesService(client *Client) *EntriesService {
return &EntriesService{client: client}
@@ -65,28 +104,193 @@ func (s *EntriesService) CreateReply(ctx context.Context, entryID int64, content
return err
}
- body := map[string]any{
- "acting_sender_id": senderID,
- "message": map[string]any{
- "content": content,
+ body := generated.CreateReplyJSONRequestBody{
+ ActingSenderId: senderID,
+ Message: generated.ReplyMessagePayload{
+ Content: content,
},
}
- addressed := map[string]any{}
+ addressed := generated.MessageAddressed{}
if len(to) > 0 {
- addressed["directly"] = to
+ addressed.Directly = strings.Join(to, ",")
}
if len(cc) > 0 {
- addressed["copied"] = cc
+ addressed.Copied = strings.Join(cc, ",")
}
if len(bcc) > 0 {
- addressed["blindcopied"] = bcc
+ addressed.Blindcopied = strings.Join(bcc, ",")
+ }
+ if len(to) > 0 || len(cc) > 0 || len(bcc) > 0 {
+ body.Entry = &generated.MessageEntryPayload{
+ Addressed: addressed,
+ }
+ }
+
+ s.client.initGeneratedClient()
+ resp, err := s.client.gen.CreateReplyWithResponse(ctx, entryID, body)
+ if err != nil {
+ return err
+ }
+ return CheckResponse(resp.HTTPResponse)
+}
+
+// CreateReplyDraft saves an editable reply draft without sending it.
+// ActingSenderID should be copied from the reply composer when available. When
+// it is zero, the acting sender ID is resolved from the current identity.
+// Recipient slices are encoded as repeated Rails form fields and rich HTML
+// content is preserved exactly.
+func (s *EntriesService) CreateReplyDraft(ctx context.Context, entryID int64, params CreateReplyDraftParams) (result *ReplyDraft, err error) {
+ op := OperationInfo{
+ Service: "Entries", Operation: "CreateReplyDraft",
+ ResourceType: "draft", IsMutation: true, ResourceID: entryID,
+ }
+ if gater, ok := s.client.hooks.(GatingHooks); ok {
+ if ctx, err = gater.OnOperationGate(ctx, op); err != nil {
+ return nil, err
+ }
+ }
+ start := time.Now()
+ ctx = s.client.hooks.OnOperationStart(ctx, op)
+ defer func() { s.client.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()
+
+ senderID := params.ActingSenderID
+ if senderID == 0 {
+ senderID, err = s.client.DefaultSenderID(ctx)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ body := generated.CreateReplyDraftFormdataRequestBody{
+ ActingSenderId: senderID,
+ AuthenticityToken: params.AuthenticityToken,
+ EntryAddressedBlindcopied: append([]string(nil), params.BCC...),
+ EntryAddressedCopied: append([]string(nil), params.CC...),
+ EntryAddressedDirectly: append([]string(nil), params.To...),
+ EntryStatus: "drafted",
+ MessageAutoQuoting: params.AutoQuoting,
+ MessageContent: params.Content,
+ MessageSubject: params.Subject,
+ }
+
+ s.client.initGeneratedClient()
+ resp, err := s.client.gen.CreateReplyDraftWithFormdataBody(ctx, entryID, body,
+ func(_ context.Context, req *http.Request) error {
+ req.Header.Set("Accept", "*/*")
+ if params.AuthenticityToken != "" {
+ req.Header.Set("X-CSRF-Token", params.AuthenticityToken)
+ }
+ return nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if !replyDraftSuccess(resp.StatusCode) {
+ return nil, CheckResponse(resp)
}
- if len(addressed) > 0 {
- body["entry"] = map[string]any{
- "addressed": addressed,
+ return replyDraftFromLocation(resp.Header.Get("Location"))
+}
+
+// DeleteDraft discards an editable draft without sending it. The generated
+// operation reproduces HEY's browser form contract: POST to /messages/{id}
+// with a Rails DELETE method override, drafted status, and CSRF header.
+func (s *EntriesService) DeleteDraft(ctx context.Context, messageID int64, params DeleteDraftParams) (result *DraftDeletion, err error) {
+ if messageID <= 0 {
+ return nil, fmt.Errorf("draft message ID must be positive")
+ }
+ if strings.TrimSpace(params.AuthenticityToken) == "" {
+ return nil, fmt.Errorf("draft deletion requires an authenticity token")
+ }
+
+ op := OperationInfo{
+ Service: "Entries", Operation: "DeleteDraft",
+ ResourceType: "draft", IsMutation: true, ResourceID: messageID,
+ }
+ if gater, ok := s.client.hooks.(GatingHooks); ok {
+ if ctx, err = gater.OnOperationGate(ctx, op); err != nil {
+ return nil, err
}
}
+ start := time.Now()
+ ctx = s.client.hooks.OnOperationStart(ctx, op)
+ defer func() { s.client.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()
+
+ generatedParams := &generated.DeleteDraftParams{
+ XCSRFToken: params.AuthenticityToken,
+ }
+ body := generated.DeleteDraftFormdataRequestBody{
+ UnderscoreMethod: "delete",
+ Status: "drafted",
+ }
- _, err = s.client.PostMutation(ctx, fmt.Sprintf("/entries/%d/replies.json", entryID), body)
- return err
+ s.client.initGeneratedClient()
+ resp, err := s.client.gen.DeleteDraftWithFormdataBody(ctx, messageID, generatedParams, body,
+ func(_ context.Context, req *http.Request) error {
+ req.Header.Set("Accept", "*/*")
+ return nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if !formMutationSuccess(resp.StatusCode) {
+ return nil, CheckResponse(resp)
+ }
+ return &DraftDeletion{
+ Location: resp.Header.Get("Location"),
+ StatusCode: resp.StatusCode,
+ }, nil
+}
+
+func replyDraftSuccess(status int) bool {
+ return formMutationSuccess(status)
+}
+
+func formMutationSuccess(status int) bool {
+ return status >= 200 && status < 300 || status == http.StatusFound || status == http.StatusSeeOther
+}
+
+func replyDraftFromLocation(location string) (*ReplyDraft, error) {
+ if strings.TrimSpace(location) == "" {
+ return nil, fmt.Errorf("reply draft response is missing Location header")
+ }
+
+ parsed, err := url.Parse(location)
+ if err != nil {
+ return nil, fmt.Errorf("invalid reply draft Location %q: %w", location, err)
+ }
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
+ if parsed.Path == "" {
+ return nil, fmt.Errorf("reply draft Location %q has no draft ID", location)
+ }
+ segments := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
+ hasEditSuffix := len(segments) == 4 && segments[3] == "edit"
+ if (len(segments) != 3 && !hasEditSuffix) || segments[0] != "entries" || segments[1] != "drafts" {
+ return nil, fmt.Errorf("reply draft Location %q does not match /entries/drafts/{id}[/edit]", location)
+ }
+ id, err := strconv.ParseInt(segments[2], 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("reply draft Location %q has no numeric draft ID", location)
+ }
+ if id <= 0 {
+ return nil, fmt.Errorf("reply draft Location %q has an invalid draft ID", location)
+ }
+
+ parsed.RawQuery = ""
+ parsed.ForceQuery = false
+ parsed.Fragment = ""
+ parsed.RawPath = ""
+ if !hasEditSuffix {
+ parsed.Path += "/edit"
+ }
+ return &ReplyDraft{
+ ID: id,
+ Location: location,
+ EditURL: parsed.String(),
+ }, nil
}
diff --git a/go/pkg/hey/messages.go b/go/pkg/hey/messages.go
index df3543d..a3483ef 100644
--- a/go/pkg/hey/messages.go
+++ b/go/pkg/hey/messages.go
@@ -3,6 +3,7 @@ package hey
import (
"context"
"fmt"
+ "strings"
"time"
"github.com/basecamp/hey-sdk/go/pkg/generated"
@@ -69,13 +70,13 @@ func (s *MessagesService) Create(ctx context.Context, subject, content string, t
addressed := map[string]any{}
if len(to) > 0 {
- addressed["directly"] = to
+ addressed["directly"] = strings.Join(to, ",")
}
if len(cc) > 0 {
- addressed["copied"] = cc
+ addressed["copied"] = strings.Join(cc, ",")
}
if len(bcc) > 0 {
- addressed["blindcopied"] = bcc
+ addressed["blindcopied"] = strings.Join(bcc, ",")
}
body := map[string]any{
diff --git a/go/pkg/hey/services_test.go b/go/pkg/hey/services_test.go
index 0915621..88df501 100644
--- a/go/pkg/hey/services_test.go
+++ b/go/pkg/hey/services_test.go
@@ -437,13 +437,13 @@ func TestMessagesService_Create(t *testing.T) {
if !ok {
t.Fatal("missing addressed in entry")
}
- directly, ok := addressed["directly"].([]any)
- if !ok || len(directly) != 1 || directly[0] != "test@example.com" {
- t.Errorf("expected directly ['test@example.com'], got %v", addressed["directly"])
+ directly, ok := addressed["directly"].(string)
+ if !ok || directly != "test@example.com" {
+ t.Errorf("expected directly 'test@example.com', got %v", addressed["directly"])
}
- copied, ok := addressed["copied"].([]any)
- if !ok || len(copied) != 1 || copied[0] != "cc@example.com" {
- t.Errorf("expected copied ['cc@example.com'], got %v", addressed["copied"])
+ copied, ok := addressed["copied"].(string)
+ if !ok || copied != "cc@example.com" {
+ t.Errorf("expected copied 'cc@example.com', got %v", addressed["copied"])
}
if _, ok := addressed["blindcopied"]; ok {
t.Error("expected no blindcopied key for empty bcc")
@@ -512,16 +512,317 @@ func TestEntriesService_CreateReply(t *testing.T) {
if msg["content"] != "My reply" {
t.Errorf("expected content 'My reply', got %v", msg["content"])
}
+ entry, ok := body["entry"].(map[string]any)
+ if !ok {
+ t.Fatal("missing entry wrapper")
+ }
+ addressed, ok := entry["addressed"].(map[string]any)
+ if !ok {
+ t.Fatal("missing addressed in entry")
+ }
+ if addressed["directly"] != "one@example.com,two@example.org" {
+ t.Errorf("expected comma-separated directly recipients, got %v", addressed["directly"])
+ }
+ if addressed["copied"] != "copy@example.com" {
+ t.Errorf("expected copied recipient, got %v", addressed["copied"])
+ }
+ if _, ok := addressed["blindcopied"]; ok {
+ t.Error("expected no blindcopied key for nil bcc")
+ }
},
`{"notice":"sent"}`,
)
- err := client.Entries().CreateReply(context.Background(), 10, "My reply", []string{"test@example.com"}, nil, nil)
+ err := client.Entries().CreateReply(context.Background(), 10, "My reply",
+ []string{"one@example.com", "two@example.org"}, []string{"copy@example.com"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
+func TestEntriesService_CreateReplyDraft(t *testing.T) {
+ const richHTML = `New reply
- First item
- Second item
Quoted thread
Signature
`
+ const location = "/entries/drafts/987?source=save#composer"
+ autoQuoting := true
+ metadata, ok := generated.GetOperationMetadata("CreateReplyDraft")
+ if !ok || !metadata.HasSensitiveParams {
+ t.Fatal("CreateReplyDraft metadata must mark its content and CSRF fields as sensitive")
+ }
+
+ for _, tc := range []struct {
+ name string
+ status int
+ token string
+ actingSenderID int64
+ wantActingSenderID string
+ wantIdentityRequests int
+ }{
+ {
+ name: "created response uses composer-selected sender",
+ status: http.StatusCreated,
+ token: "csrf-test-token",
+ actingSenderID: 84,
+ wantActingSenderID: "84",
+ },
+ {
+ name: "redirect response with bearer auth and composer-selected sender",
+ status: http.StatusFound,
+ actingSenderID: 84,
+ wantActingSenderID: "84",
+ },
+ {
+ name: "see other response falls back to default sender",
+ status: http.StatusSeeOther,
+ token: "csrf-test-token",
+ wantActingSenderID: "42",
+ wantIdentityRequests: 1,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ draftRequests := 0
+ identityRequests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/identity.json" {
+ identityRequests++
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(identityJSON))
+ return
+ }
+
+ draftRequests++
+ if r.Method != http.MethodPost {
+ t.Errorf("expected POST, got %s", r.Method)
+ }
+ if r.URL.Path != "/entries/10/replies" {
+ t.Errorf("expected draft reply path, got %s", r.URL.Path)
+ }
+ if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
+ t.Errorf("expected form content type, got %q", contentType)
+ }
+ if accept := r.Header.Get("Accept"); accept != "*/*" {
+ t.Errorf("expected Accept */*, got %q", accept)
+ }
+ if csrf := r.Header.Get("X-CSRF-Token"); csrf != tc.token {
+ t.Errorf("expected CSRF header %q, got %q", tc.token, csrf)
+ }
+ if err := r.ParseForm(); err != nil {
+ t.Fatalf("failed to parse form: %v", err)
+ }
+ if got := r.PostForm.Get("acting_sender_id"); got != tc.wantActingSenderID {
+ t.Errorf("expected acting sender %s, got %q", tc.wantActingSenderID, got)
+ }
+ if got := r.PostForm.Get("entry[status]"); got != "drafted" {
+ t.Errorf("expected drafted status, got %q", got)
+ }
+ if got := r.PostForm.Get("message[subject]"); got != "Re: Project update" {
+ t.Errorf("expected preserved subject, got %q", got)
+ }
+ if got := r.PostForm.Get("message[content]"); got != richHTML {
+ t.Errorf("rich HTML changed:\nwant: %s\n got: %s", richHTML, got)
+ }
+ if got := r.PostForm.Get("message[auto_quoting]"); got != "true" {
+ t.Errorf("expected auto_quoting=true, got %q", got)
+ }
+ if got := strings.Join(r.PostForm["entry[addressed][directly][]"], ","); got != "one@example.com,two@example.org" {
+ t.Errorf("unexpected To recipients: %q", got)
+ }
+ if got := strings.Join(r.PostForm["entry[addressed][copied][]"], ","); got != "copy@example.com" {
+ t.Errorf("unexpected CC recipients: %q", got)
+ }
+ if got := strings.Join(r.PostForm["entry[addressed][blindcopied][]"], ","); got != "blind@example.org" {
+ t.Errorf("unexpected BCC recipients: %q", got)
+ }
+ if tc.token == "" {
+ if _, ok := r.PostForm["authenticity_token"]; ok {
+ t.Error("expected omitted authenticity_token for bearer-only request")
+ }
+ } else if got := r.PostForm.Get("authenticity_token"); got != tc.token {
+ t.Errorf("expected authenticity_token %q, got %q", tc.token, got)
+ }
+
+ w.Header().Set("Location", location)
+ w.WriteHeader(tc.status)
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(&Config{BaseURL: server.URL}, &StaticTokenProvider{Token: "test-token"}, WithMaxRetries(0))
+ result, err := client.Entries().CreateReplyDraft(context.Background(), 10, CreateReplyDraftParams{
+ ActingSenderID: tc.actingSenderID,
+ Content: richHTML,
+ Subject: "Re: Project update",
+ AutoQuoting: &autoQuoting,
+ To: []string{"one@example.com", "two@example.org"},
+ CC: []string{"copy@example.com"},
+ BCC: []string{"blind@example.org"},
+ AuthenticityToken: tc.token,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.ID != 987 {
+ t.Errorf("expected draft ID 987, got %d", result.ID)
+ }
+ if result.Location != location {
+ t.Errorf("expected raw Location %q, got %q", location, result.Location)
+ }
+ if result.EditURL != "/entries/drafts/987/edit" {
+ t.Errorf("expected edit URL without query/fragment, got %q", result.EditURL)
+ }
+ if draftRequests != 1 {
+ t.Errorf("expected one draft request and no followed redirect, got %d", draftRequests)
+ }
+ if identityRequests != tc.wantIdentityRequests {
+ t.Errorf("expected %d identity requests, got %d", tc.wantIdentityRequests, identityRequests)
+ }
+ })
+ }
+}
+
+func TestEntriesService_DeleteDraft(t *testing.T) {
+ metadata, ok := generated.GetOperationMetadata("DeleteDraft")
+ if !ok {
+ t.Fatal("DeleteDraft metadata missing")
+ }
+ if metadata.Idempotent {
+ t.Fatal("DeleteDraft browser form must remain single-attempt")
+ }
+ if !metadata.HasSensitiveParams {
+ t.Fatal("DeleteDraft metadata must mark its CSRF token as sensitive")
+ }
+
+ for _, tc := range []struct {
+ name string
+ status int
+ location string
+ wantErr bool
+ }{
+ {name: "ok", status: http.StatusOK},
+ {name: "found redirect", status: http.StatusFound, location: "/topics/123"},
+ {name: "see other redirect", status: http.StatusSeeOther, location: "/topics/123"},
+ {name: "server failure is not retried", status: http.StatusServiceUnavailable, wantErr: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if r.Method != http.MethodPost {
+ t.Errorf("expected browser-compatible POST, got %s", r.Method)
+ }
+ if r.URL.Path != "/messages/987" {
+ t.Errorf("expected draft message path, got %s", r.URL.Path)
+ }
+ if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
+ t.Errorf("expected form content type, got %q", contentType)
+ }
+ if accept := r.Header.Get("Accept"); accept != "*/*" {
+ t.Errorf("expected Accept */*, got %q", accept)
+ }
+ if csrf := r.Header.Get("X-CSRF-Token"); csrf != "csrf-delete-token" {
+ t.Errorf("expected CSRF header, got %q", csrf)
+ }
+ if err := r.ParseForm(); err != nil {
+ t.Fatalf("failed to parse form: %v", err)
+ }
+ if got := r.PostForm.Get("_method"); got != "delete" {
+ t.Errorf("expected Rails delete override, got %q", got)
+ }
+ if got := r.PostForm.Get("status"); got != "drafted" {
+ t.Errorf("expected drafted status, got %q", got)
+ }
+ if _, present := r.PostForm["authenticity_token"]; present {
+ t.Error("CSRF token must be sent only in the header")
+ }
+
+ if tc.location != "" {
+ w.Header().Set("Location", tc.location)
+ }
+ w.WriteHeader(tc.status)
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(&Config{BaseURL: server.URL}, &StaticTokenProvider{Token: "test-token"}, WithMaxRetries(2))
+ result, err := client.Entries().DeleteDraft(context.Background(), 987, DeleteDraftParams{
+ AuthenticityToken: "csrf-delete-token",
+ })
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.StatusCode != tc.status || result.Location != tc.location {
+ t.Errorf("unexpected result: %+v", result)
+ }
+ }
+ if requests != 1 {
+ t.Errorf("expected exactly one request with no redirect follow or retry, got %d", requests)
+ }
+ })
+ }
+}
+
+func TestEntriesService_DeleteDraftValidatesInput(t *testing.T) {
+ client := NewClient(&Config{BaseURL: "https://app.hey.com"}, &StaticTokenProvider{Token: "test-token"})
+
+ if _, err := client.Entries().DeleteDraft(context.Background(), 0, DeleteDraftParams{AuthenticityToken: "token"}); err == nil {
+ t.Fatal("expected non-positive message ID error")
+ }
+ if _, err := client.Entries().DeleteDraft(context.Background(), 1, DeleteDraftParams{}); err == nil {
+ t.Fatal("expected missing authenticity token error")
+ }
+ if _, err := client.Entries().DeleteDraft(context.Background(), 1, DeleteDraftParams{AuthenticityToken: " "}); err == nil {
+ t.Fatal("expected blank authenticity token error")
+ }
+}
+
+func TestReplyDraftFromLocation(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ location string
+ wantID int64
+ wantEdit string
+ wantErr bool
+ }{
+ {name: "relative", location: "/entries/drafts/42", wantID: 42, wantEdit: "/entries/drafts/42/edit"},
+ {name: "relative edit", location: "/entries/drafts/43/edit", wantID: 43, wantEdit: "/entries/drafts/43/edit"},
+ {name: "relative empty query", location: "/entries/drafts/44?", wantID: 44, wantEdit: "/entries/drafts/44/edit"},
+ {name: "relative edit empty query", location: "/entries/drafts/45/edit?", wantID: 45, wantEdit: "/entries/drafts/45/edit"},
+ {name: "encoded edit separator", location: "/entries/drafts/46%2Fedit?source=save", wantID: 46, wantEdit: "/entries/drafts/46/edit"},
+ {name: "absolute", location: "https://app.hey.com/entries/drafts/99/?source=save#composer", wantID: 99, wantEdit: "https://app.hey.com/entries/drafts/99/edit"},
+ {name: "absolute edit", location: "https://app.hey.com/entries/drafts/100/edit/?source=save#composer", wantID: 100, wantEdit: "https://app.hey.com/entries/drafts/100/edit"},
+ {name: "missing", wantErr: true},
+ {name: "malformed", location: "/entries/drafts/%zz", wantErr: true},
+ {name: "nonnumeric", location: "/entries/drafts/not-a-number", wantErr: true},
+ {name: "nonnumeric edit", location: "/entries/drafts/not-a-number/edit", wantErr: true},
+ {name: "edit without ID", location: "/edit", wantErr: true},
+ {name: "unrelated numeric route", location: "/topics/42", wantErr: true},
+ {name: "unrelated numeric edit route", location: "/messages/42/edit", wantErr: true},
+ {name: "unexpected suffix", location: "/entries/drafts/42/preview", wantErr: true},
+ {name: "repeated edit suffix", location: "/entries/drafts/42/edit/edit", wantErr: true},
+ {name: "zero", location: "/entries/drafts/0", wantErr: true},
+ {name: "negative", location: "/entries/drafts/-1", wantErr: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := replyDraftFromLocation(tc.location)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.ID != tc.wantID || result.EditURL != tc.wantEdit || result.Location != tc.location {
+ t.Errorf("unexpected result: %+v", result)
+ }
+ })
+ }
+}
+
// --- Contacts ---
func TestContactsService_List(t *testing.T) {
diff --git a/go/pkg/hey/url-routes.json b/go/pkg/hey/url-routes.json
index 4764c54..424bf28 100644
--- a/go/pkg/hey/url-routes.json
+++ b/go/pkg/hey/url-routes.json
@@ -4,7 +4,7 @@
"generated": true,
"routes": [
{
- "pattern": "/boxes",
+ "pattern": "/boxes.json",
"resource": "Boxes",
"operations": {
"GET": "ListBoxes"
@@ -25,7 +25,7 @@
}
},
{
- "pattern": "/bubble_up",
+ "pattern": "/bubble_up.json",
"resource": "Boxes",
"operations": {
"GET": "GetBubblebox"
@@ -65,7 +65,7 @@
}
},
{
- "pattern": "/calendar/ongoing_time_track",
+ "pattern": "/calendar/ongoing_time_track.json",
"resource": "Calendar Time Tracks",
"operations": {
"GET": "GetOngoingTimeTrack",
@@ -87,7 +87,7 @@
}
},
{
- "pattern": "/calendar/todos",
+ "pattern": "/calendar/todos.json",
"resource": "Calendar Todos",
"operations": {
"POST": "CreateCalendarTodo"
@@ -122,7 +122,7 @@
}
},
{
- "pattern": "/calendars",
+ "pattern": "/calendars.json",
"resource": "Calendars",
"operations": {
"GET": "ListCalendars"
@@ -143,7 +143,7 @@
}
},
{
- "pattern": "/contacts",
+ "pattern": "/contacts.json",
"resource": "Contacts",
"operations": {
"GET": "ListContacts"
@@ -164,7 +164,7 @@
}
},
{
- "pattern": "/entries/drafts",
+ "pattern": "/entries/drafts.json",
"resource": "Entries",
"operations": {
"GET": "ListDrafts"
@@ -174,6 +174,19 @@
{
"pattern": "/entries/{entryId}/replies",
"resource": "Entries",
+ "operations": {
+ "POST": "CreateReplyDraft"
+ },
+ "params": {
+ "entryId": {
+ "role": "parent",
+ "type": "int64"
+ }
+ }
+ },
+ {
+ "pattern": "/entries/{entryId}/replies.json",
+ "resource": "Entries",
"operations": {
"POST": "CreateReply"
},
@@ -185,7 +198,7 @@
}
},
{
- "pattern": "/feedbox",
+ "pattern": "/feedbox.json",
"resource": "Boxes",
"operations": {
"GET": "GetFeedbox"
@@ -193,7 +206,7 @@
"params": {}
},
{
- "pattern": "/identity",
+ "pattern": "/identity.json",
"resource": "Identity",
"operations": {
"GET": "GetIdentity"
@@ -201,7 +214,7 @@
"params": {}
},
{
- "pattern": "/imbox",
+ "pattern": "/imbox.json",
"resource": "Boxes",
"operations": {
"GET": "GetImbox"
@@ -209,7 +222,7 @@
"params": {}
},
{
- "pattern": "/messages",
+ "pattern": "/messages.json",
"resource": "Messages",
"operations": {
"POST": "CreateMessage"
@@ -220,7 +233,8 @@
"pattern": "/messages/{messageId}",
"resource": "Messages",
"operations": {
- "GET": "GetMessage"
+ "GET": "GetMessage",
+ "POST": "DeleteDraft"
},
"params": {
"messageId": {
@@ -230,7 +244,7 @@
}
},
{
- "pattern": "/my/navigation",
+ "pattern": "/my/navigation.json",
"resource": "Identity",
"operations": {
"GET": "GetNavigation"
@@ -238,7 +252,7 @@
"params": {}
},
{
- "pattern": "/paper_trail",
+ "pattern": "/paper_trail.json",
"resource": "Boxes",
"operations": {
"GET": "GetTrailbox"
@@ -246,7 +260,7 @@
"params": {}
},
{
- "pattern": "/postings/seen",
+ "pattern": "/postings/seen.json",
"resource": "Postings",
"operations": {
"POST": "MarkPostingsSeen"
@@ -254,7 +268,7 @@
"params": {}
},
{
- "pattern": "/postings/unseen",
+ "pattern": "/postings/unseen.json",
"resource": "Postings",
"operations": {
"POST": "MarkPostingsUnseen"
@@ -262,7 +276,7 @@
"params": {}
},
{
- "pattern": "/postings/{postingId}/ignore",
+ "pattern": "/postings/{postingId}/ignore.json",
"resource": "Postings",
"operations": {
"POST": "IgnorePosting"
@@ -275,7 +289,7 @@
}
},
{
- "pattern": "/postings/{postingId}/move/asidebox",
+ "pattern": "/postings/{postingId}/move/asidebox.json",
"resource": "Postings",
"operations": {
"POST": "MovePostingToSetAside"
@@ -288,7 +302,7 @@
}
},
{
- "pattern": "/postings/{postingId}/move/feedbox",
+ "pattern": "/postings/{postingId}/move/feedbox.json",
"resource": "Postings",
"operations": {
"POST": "MovePostingToFeed"
@@ -301,7 +315,7 @@
}
},
{
- "pattern": "/postings/{postingId}/move/laterbox",
+ "pattern": "/postings/{postingId}/move/laterbox.json",
"resource": "Postings",
"operations": {
"POST": "MovePostingToReplyLater"
@@ -314,7 +328,7 @@
}
},
{
- "pattern": "/postings/{postingId}/move/trailbox",
+ "pattern": "/postings/{postingId}/move/trailbox.json",
"resource": "Postings",
"operations": {
"POST": "MovePostingToPaperTrail"
@@ -327,7 +341,7 @@
}
},
{
- "pattern": "/postings/{postingId}/trash",
+ "pattern": "/postings/{postingId}/trash.json",
"resource": "Postings",
"operations": {
"POST": "MovePostingToTrash"
@@ -340,7 +354,7 @@
}
},
{
- "pattern": "/reply_later",
+ "pattern": "/reply_later.json",
"resource": "Boxes",
"operations": {
"GET": "GetLaterbox"
@@ -348,7 +362,7 @@
"params": {}
},
{
- "pattern": "/search",
+ "pattern": "/search.json",
"resource": "Search",
"operations": {
"GET": "Search"
@@ -356,7 +370,7 @@
"params": {}
},
{
- "pattern": "/set_aside",
+ "pattern": "/set_aside.json",
"resource": "Boxes",
"operations": {
"GET": "GetAsidebox"
@@ -364,7 +378,7 @@
"params": {}
},
{
- "pattern": "/topics/everything",
+ "pattern": "/topics/everything.json",
"resource": "Topics",
"operations": {
"GET": "GetEverythingTopics"
@@ -372,7 +386,7 @@
"params": {}
},
{
- "pattern": "/topics/sent",
+ "pattern": "/topics/sent.json",
"resource": "Topics",
"operations": {
"GET": "GetSentTopics"
@@ -380,7 +394,7 @@
"params": {}
},
{
- "pattern": "/topics/spam",
+ "pattern": "/topics/spam.json",
"resource": "Topics",
"operations": {
"GET": "GetSpamTopics"
@@ -388,7 +402,7 @@
"params": {}
},
{
- "pattern": "/topics/trash",
+ "pattern": "/topics/trash.json",
"resource": "Topics",
"operations": {
"GET": "GetTrashTopics"
@@ -422,7 +436,7 @@
}
},
{
- "pattern": "/topics/{topicId}/entries",
+ "pattern": "/topics/{topicId}/entries.json",
"resource": "Messages",
"operations": {
"POST": "CreateTopicMessage"
diff --git a/go/pkg/hey/url.go b/go/pkg/hey/url.go
index c4daed4..7df7f71 100644
--- a/go/pkg/hey/url.go
+++ b/go/pkg/hey/url.go
@@ -47,6 +47,13 @@ type routeEntry struct {
params []string
}
+type routeMatch struct {
+ route *routeEntry
+ captures []string
+ alias bool
+ routeRank int
+}
+
// Router matches HEY API URLs against the OpenAPI-derived route table.
type Router struct {
routes []routeEntry
@@ -148,40 +155,84 @@ func sortRoutes(routes []routeEntry) {
// The path should be the API path portion (e.g., "/boxes/123" or "/topics/456/entries").
func (r *Router) MatchPath(path string) *Match {
path = strings.TrimRight(path, "/")
- path = strings.TrimSuffix(path, ".json")
-
- for i := range r.routes {
- rt := &r.routes[i]
- matches := rt.regex.FindStringSubmatch(path)
- if matches == nil {
- continue
+ type routeCandidate struct {
+ path string
+ jsonSuffixRoute bool
+ alias bool
+ }
+ var candidates []routeCandidate
+ if strings.HasSuffix(path, ".json") {
+ candidates = []routeCandidate{
+ {path: path, jsonSuffixRoute: true},
+ {path: strings.TrimSuffix(path, ".json"), alias: true},
}
-
- m := &Match{
- Operations: rt.operations,
- Resource: rt.resource,
- Params: make(map[string]string, len(rt.params)),
+ } else if path != "" && path != "/" {
+ candidates = []routeCandidate{
+ {path: path},
+ {path: path + ".json", jsonSuffixRoute: true, alias: true},
}
+ } else {
+ candidates = []routeCandidate{{path: path}}
+ }
- // Pick the default operation: prefer GET, then first alphabetically.
- if op, ok := rt.operations["GET"]; ok {
- m.Operation = op
- } else {
- for _, op := range rt.operations {
- if m.Operation == "" || op < m.Operation {
- m.Operation = op
- }
+ var best *routeMatch
+ for _, candidate := range candidates {
+ for i := range r.routes {
+ rt := &r.routes[i]
+ if strings.HasSuffix(rt.pattern, ".json") != candidate.jsonSuffixRoute {
+ continue
+ }
+ matches := rt.regex.FindStringSubmatch(candidate.path)
+ if matches == nil {
+ continue
+ }
+ match := &routeMatch{route: rt, captures: matches, alias: candidate.alias, routeRank: i}
+ if best == nil || betterRouteMatch(match, best) {
+ best = match
}
}
+ }
+ if best == nil {
+ return nil
+ }
- for j, paramName := range rt.params {
- m.Params[paramName] = matches[j+1]
- }
- if len(rt.params) > 0 {
- m.resourceID = matches[len(rt.params)]
+ m := &Match{
+ Operations: best.route.operations,
+ Resource: best.route.resource,
+ Params: make(map[string]string, len(best.route.params)),
+ }
+
+ // Pick the default operation: prefer GET, then first alphabetically.
+ if op, ok := best.route.operations["GET"]; ok {
+ m.Operation = op
+ } else {
+ for _, op := range best.route.operations {
+ if m.Operation == "" || op < m.Operation {
+ m.Operation = op
+ }
}
+ }
+
+ for j, paramName := range best.route.params {
+ m.Params[paramName] = best.captures[j+1]
+ }
+ if len(best.route.params) > 0 {
+ m.resourceID = best.captures[len(best.route.params)]
+ }
- return m
+ return m
+}
+
+// betterRouteMatch prefers more literal routes before considering whether the
+// match used a .json alias. This lets /topics/sent resolve to the literal
+// /topics/sent.json route instead of /topics/{topicId}, while an exact
+// /entries/{id}/replies route still beats its equally-specific .json sibling.
+func betterRouteMatch(candidate, current *routeMatch) bool {
+ if len(candidate.route.params) != len(current.route.params) {
+ return len(candidate.route.params) < len(current.route.params)
+ }
+ if candidate.alias != current.alias {
+ return !candidate.alias
}
- return nil
+ return candidate.routeRank < current.routeRank
}
diff --git a/go/pkg/hey/url_test.go b/go/pkg/hey/url_test.go
index 44c4f3e..dd52c52 100644
--- a/go/pkg/hey/url_test.go
+++ b/go/pkg/hey/url_test.go
@@ -46,6 +46,44 @@ func TestRouterMatchPath(t *testing.T) {
name: "topic entries",
input: "/topics/456/entries",
wantOp: "GetTopicEntries",
+ wantRes: "456",
+ wantRsrc: "Topics",
+ },
+ {
+ name: "topic entries json send route remains distinct",
+ input: "/topics/456/entries.json",
+ wantOp: "CreateTopicMessage",
+ wantRes: "456",
+ wantRsrc: "Messages",
+ },
+ {
+ name: "sent topics suffixless alias beats topic parameter",
+ input: "/topics/sent",
+ wantOp: "GetSentTopics",
+ wantRsrc: "Topics",
+ },
+ {
+ name: "sent topics json",
+ input: "/topics/sent.json",
+ wantOp: "GetSentTopics",
+ wantRsrc: "Topics",
+ },
+ {
+ name: "spam topics suffixless alias beats topic parameter",
+ input: "/topics/spam",
+ wantOp: "GetSpamTopics",
+ wantRsrc: "Topics",
+ },
+ {
+ name: "trash topics suffixless alias beats topic parameter",
+ input: "/topics/trash",
+ wantOp: "GetTrashTopics",
+ wantRsrc: "Topics",
+ },
+ {
+ name: "everything topics suffixless alias beats topic parameter",
+ input: "/topics/everything",
+ wantOp: "GetEverythingTopics",
wantRsrc: "Topics",
},
{
@@ -164,3 +202,32 @@ func TestRouterOperations(t *testing.T) {
t.Error("expected DELETE operation (UncompleteCalendarTodo)")
}
}
+
+func TestRouterDistinguishesReplyDraftFromSentReply(t *testing.T) {
+ r := DefaultRouter()
+
+ draft := r.MatchPath("/entries/123/replies")
+ if draft == nil || draft.Operation != "CreateReplyDraft" {
+ t.Fatalf("draft route = %+v, want CreateReplyDraft", draft)
+ }
+
+ sent := r.MatchPath("/entries/123/replies.json")
+ if sent == nil || sent.Operation != "CreateReply" {
+ t.Fatalf("sent route = %+v, want CreateReply", sent)
+ }
+}
+
+func TestRouterIncludesBrowserDraftDeleteTransport(t *testing.T) {
+ r := DefaultRouter()
+
+ message := r.MatchPath("/messages/987")
+ if message == nil {
+ t.Fatal("expected message route")
+ }
+ if got := message.Operations["POST"]; got != "DeleteDraft" {
+ t.Fatalf("POST operation = %q, want DeleteDraft", got)
+ }
+ if got := message.Operations["GET"]; got != "GetMessage" {
+ t.Fatalf("GET operation = %q, want GetMessage", got)
+ }
+}
diff --git a/go/templates/client.tmpl b/go/templates/client.tmpl
index 559c432..8dd81d3 100644
--- a/go/templates/client.tmpl
+++ b/go/templates/client.tmpl
@@ -82,6 +82,33 @@ func NewClient(server string, opts ...ClientOption) (*{{ $clientTypeName }}, err
return &client, nil
}
+func isFormURLEncoded(contentType string) bool {
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ return err == nil && mediaType == "application/x-www-form-urlencoded"
+}
+
+// doRequest prevents a concrete *http.Client from following redirects for
+// browser-compatible form operations, allowing callers to inspect the
+// resource-identifying Location header. It leaves the exported Client doer
+// unchanged and preserves configured behavior for custom doers and non-form
+// requests.
+func (c *{{ $clientTypeName }}) doRequest(req *http.Request) (*http.Response, error) {
+ if !isFormURLEncoded(req.Header.Get("Content-Type")) {
+ return c.Client.Do(req)
+ }
+
+ httpClient, ok := c.Client.(*http.Client)
+ if !ok {
+ return c.Client.Do(req)
+ }
+
+ noRedirect := *httpClient
+ noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }
+ return noRedirect.Do(req)
+}
+
// WithHTTPClient allows overriding the default Doer, which is
// automatically created using http.Client. This is useful for tests.
func WithHTTPClient(doer HttpRequestDoer) ClientOption {
@@ -116,6 +143,48 @@ func WithLogger(logger *slog.Logger) ClientOption {
}
}
+// normalizeRailsArrayFormValues converts the indexed keys produced by
+// runtime.MarshalForm for explicitly bracketed Rails array fields back into
+// repeated [] keys. For example, recipients[][0] and recipients[][1] become
+// two recipients[] values, matching browser form submission semantics.
+func normalizeRailsArrayFormValues(values url.Values) url.Values {
+ type formValue struct {
+ key string
+ normalized string
+ index int
+ indexed bool
+ items []string
+ }
+
+ entries := make([]formValue, 0, len(values))
+ for key, items := range values {
+ entry := formValue{key: key, normalized: key, items: items}
+ if marker := strings.LastIndex(key, "[]["); marker >= 0 && strings.HasSuffix(key, "]") {
+ if index, err := strconv.Atoi(key[marker+3 : len(key)-1]); err == nil {
+ entry.normalized = key[:marker+2]
+ entry.index = index
+ entry.indexed = true
+ }
+ }
+ entries = append(entries, entry)
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ if entries[i].normalized != entries[j].normalized {
+ return entries[i].normalized < entries[j].normalized
+ }
+ if entries[i].indexed && entries[j].indexed && entries[i].index != entries[j].index {
+ return entries[i].index < entries[j].index
+ }
+ return entries[i].key < entries[j].key
+ })
+
+ normalized := make(url.Values, len(values))
+ for _, entry := range entries {
+ normalized[entry.normalized] = append(normalized[entry.normalized], entry.items...)
+ }
+ return normalized
+}
+
// isRetryableStatus returns true if the HTTP status code indicates a retryable error.
func isRetryableStatus(statusCode int) bool {
switch statusCode {
@@ -151,7 +220,7 @@ func (c *{{ $clientTypeName }}) doWithRetry(ctx context.Context, buildRequest fu
return nil, err
}
- resp, err := c.Client.Do(req)
+ resp, err := c.doRequest(req)
if err != nil {
lastErr = err
if c.Logger != nil {
@@ -279,7 +348,7 @@ func (c *{{ $clientTypeName }}) {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx cont
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
{{end}}
}
@@ -299,7 +368,7 @@ func (c *{{ $clientTypeName }}) {{$opid}}{{.Suffix}}(ctx context.Context{{genPar
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
- return c.Client.Do(req)
+ return c.doRequest(req)
{{end}}
}
{{end -}}{{/* if .IsSupported */}}
@@ -329,6 +398,7 @@ func New{{$opid}}Request{{.Suffix}}(server string{{genParamArgs $pathParams}}{{i
if err != nil {
return nil, err
}
+ bodyStr = normalizeRailsArrayFormValues(bodyStr)
bodyReader = strings.NewReader(bodyStr.Encode())
{{else if eq .NameTag "Text" -}}
bodyReader = strings.NewReader(string(body))
diff --git a/go/templates/imports.tmpl b/go/templates/imports.tmpl
index 386d144..7245d39 100644
--- a/go/templates/imports.tmpl
+++ b/go/templates/imports.tmpl
@@ -12,8 +12,10 @@ import (
"io"
"log/slog"
"math/rand"
+ "mime"
"net/http"
"net/url"
+ "sort"
"strconv"
"strings"
"sync"
diff --git a/openapi.json b/openapi.json
index 7e2883a..48c26d1 100644
--- a/openapi.json
+++ b/openapi.json
@@ -1537,6 +1537,182 @@
}
}
},
+ "/entries/{entryId}/replies": {
+ "post": {
+ "description": "Save an editable reply draft without sending it.\n\nThis is HEY's browser-compatible form endpoint. The Location response\nheader identifies the saved draft. Smithy models the success code as 201,\nwhile the service wrapper accepts the verified form outcomes (any 2xx, 302,\nor 303) and preserves Location without following redirects.",
+ "operationId": "CreateReplyDraft",
+ "requestBody": {
+ "content": {
+ "application/x-www-form-urlencoded": {
+ "schema": {
+ "type": "object",
+ "description": "Rails form fields used by the reply composer.\nRecipient fields are repeated array parameters, not comma-separated JSON\nstrings. Content is already-rich ActionText HTML and is transmitted exactly\nas provided.",
+ "properties": {
+ "acting_sender_id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "entry[status]": {
+ "type": "string"
+ },
+ "message[content]": {
+ "type": "string",
+ "format": "password",
+ "x-hey-sensitive": {
+ "category": "pii",
+ "redact": true
+ }
+ },
+ "message[subject]": {
+ "type": "string"
+ },
+ "message[auto_quoting]": {
+ "type": "boolean",
+ "x-go-type-skip-optional-pointer": false
+ },
+ "entry[addressed][directly][]": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "entry[addressed][copied][]": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "entry[addressed][blindcopied][]": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "authenticity_token": {
+ "type": "string",
+ "format": "password",
+ "x-hey-sensitive": {
+ "category": "credential",
+ "redact": true
+ }
+ }
+ },
+ "required": [
+ "acting_sender_id",
+ "entry[status]",
+ "message[content]"
+ ]
+ }
+ }
+ },
+ "required": true
+ },
+ "parameters": [
+ {
+ "name": "entryId",
+ "in": "path",
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "required": true
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "CreateReplyDraft 201 response",
+ "headers": {
+ "Location": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "BadRequestError 400 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BadRequestErrorResponseContent"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "UnauthorizedError 401 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UnauthorizedErrorResponseContent"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "ForbiddenError 403 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ForbiddenErrorResponseContent"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "NotFoundError 404 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NotFoundErrorResponseContent"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "UnprocessableEntityError 422 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UnprocessableEntityErrorResponseContent"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "InternalServerError 500 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InternalServerErrorResponseContent"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "ServiceUnavailableError 503 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ServiceUnavailableErrorResponseContent"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Entries"
+ ],
+ "x-hey-form-urlencoded": {},
+ "x-hey-retry": {
+ "maxAttempts": 1,
+ "baseDelayMs": 1000,
+ "backoff": "constant",
+ "retryOn": []
+ },
+ "x-hey-sensitive": true
+ }
+ },
"/entries/{entryId}/replies.json": {
"post": {
"description": "Reply to an entry",
@@ -1981,6 +2157,153 @@
503
]
}
+ },
+ "post": {
+ "description": "Delete an editable message draft without sending it.\n\nHEY exposes this as a Rails form: the canonical router operation is DELETE\n`/messages/{messageId}`, while browsers submit POST with `_method=delete`.\nThe generated transport preserves that browser-compatible wire contract.",
+ "operationId": "DeleteDraft",
+ "requestBody": {
+ "content": {
+ "application/x-www-form-urlencoded": {
+ "schema": {
+ "type": "object",
+ "description": "Rails form fields emitted by the draft edit page's discard form.",
+ "properties": {
+ "_method": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "_method",
+ "status"
+ ]
+ }
+ }
+ },
+ "required": true
+ },
+ "parameters": [
+ {
+ "name": "messageId",
+ "in": "path",
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "required": true
+ },
+ {
+ "name": "X-CSRF-Token",
+ "in": "header",
+ "schema": {
+ "type": "string",
+ "format": "password",
+ "x-hey-sensitive": {
+ "category": "credential",
+ "redact": true
+ }
+ },
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "DeleteDraft 200 response",
+ "headers": {
+ "Location": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "BadRequestError 400 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BadRequestErrorResponseContent"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "UnauthorizedError 401 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UnauthorizedErrorResponseContent"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "ForbiddenError 403 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ForbiddenErrorResponseContent"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "NotFoundError 404 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NotFoundErrorResponseContent"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "UnprocessableEntityError 422 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UnprocessableEntityErrorResponseContent"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "InternalServerError 500 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InternalServerErrorResponseContent"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "ServiceUnavailableError 503 response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ServiceUnavailableErrorResponseContent"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Entries"
+ ],
+ "x-hey-form-method-override": {
+ "overrideMethod": "DELETE"
+ },
+ "x-hey-form-urlencoded": {},
+ "x-hey-retry": {
+ "maxAttempts": 1,
+ "baseDelayMs": 1000,
+ "backoff": "constant",
+ "retryOn": []
+ },
+ "x-hey-sensitive": true
}
},
"/my/navigation.json": {
@@ -3584,6 +3907,17 @@
"id"
]
},
+ "BadRequestErrorResponseContent": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "message"
+ ]
+ },
"Box": {
"type": "object",
"description": "Box — a HEY mailbox",
@@ -4021,9 +4355,68 @@
"message"
]
},
+ "CreateReplyDraftRequestContent": {
+ "type": "object",
+ "description": "Rails form fields used by the reply composer.\nRecipient fields are repeated array parameters, not comma-separated JSON\nstrings. Content is already-rich ActionText HTML and is transmitted exactly\nas provided.",
+ "properties": {
+ "acting_sender_id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "entry[status]": {
+ "type": "string"
+ },
+ "message[content]": {
+ "type": "string",
+ "format": "password",
+ "x-hey-sensitive": {
+ "category": "pii",
+ "redact": true
+ }
+ },
+ "message[subject]": {
+ "type": "string"
+ },
+ "message[auto_quoting]": {
+ "type": "boolean",
+ "x-go-type-skip-optional-pointer": false
+ },
+ "entry[addressed][directly][]": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "entry[addressed][copied][]": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "entry[addressed][blindcopied][]": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "authenticity_token": {
+ "type": "string",
+ "format": "password",
+ "x-hey-sensitive": {
+ "category": "credential",
+ "redact": true
+ }
+ }
+ },
+ "required": [
+ "acting_sender_id",
+ "entry[status]",
+ "message[content]"
+ ]
+ },
"CreateReplyRequestContent": {
"type": "object",
- "description": "Wire format: {acting_sender_id, message: {content}}",
+ "description": "Wire format: {acting_sender_id, message: {content}, entry: {addressed: {...}}}",
"properties": {
"acting_sender_id": {
"type": "integer",
@@ -4031,6 +4424,14 @@
},
"message": {
"$ref": "#/components/schemas/ReplyMessagePayload"
+ },
+ "entry": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MessageEntryPayload"
+ }
+ ],
+ "x-go-type-skip-optional-pointer": false
}
},
"required": [
@@ -4055,6 +4456,22 @@
"message"
]
},
+ "DeleteDraftRequestContent": {
+ "type": "object",
+ "description": "Rails form fields emitted by the draft edit page's discard form.",
+ "properties": {
+ "_method": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "_method",
+ "status"
+ ]
+ },
"Domain": {
"type": "object",
"description": "Domain — email domain",
@@ -4253,6 +4670,17 @@
"id"
]
},
+ "ForbiddenErrorResponseContent": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "message"
+ ]
+ },
"GetAsideboxResponseContent": {
"$ref": "#/components/schemas/BoxShowResponse"
},
diff --git a/scripts/check-service-drift.sh b/scripts/check-service-drift.sh
index ac6ba7e..26c4ee6 100755
--- a/scripts/check-service-drift.sh
+++ b/scripts/check-service-drift.sh
@@ -25,26 +25,58 @@ fi
GEN_OPS=$(mktemp)
SVC_OPS=$(mktemp)
-trap 'rm -f "$GEN_OPS" "$SVC_OPS"' EXIT
+GEN_FORM_OPS=$(mktemp)
+SVC_FORM_OPS=$(mktemp)
+WRAPPED_FORM_OPS=$(mktemp)
+trap 'rm -f "$GEN_OPS" "$SVC_OPS" "$GEN_FORM_OPS" "$SVC_FORM_OPS" "$WRAPPED_FORM_OPS"' EXIT
-# Extract generated operations, normalizing WithBodyWithResponse to base name
+# Normalize generated method variants to their Smithy operation name.
# e.g., CreateMessageWithBodyWithResponse -> CreateMessage
+# CreateReplyDraftWithFormdataBodyWithResponse -> CreateReplyDraft
# ListBoxesWithResponse -> ListBoxes
+normalize_operations() {
+ sed -E 's/WithResponse$//' \
+ | sed -E 's/With[A-Za-z0-9]*Body$//'
+}
+
+# Extract generated operations. Generic and typed body variants collapse to
+# the same operation after normalization.
grep "^func (c \*ClientWithResponses)" "$GENERATED_FILE" 2>/dev/null \
- | sed 's/.*) \([A-Za-z]*\)WithResponse.*/\1/' \
- | sed 's/WithBody$//' \
+ | sed -E 's/^func \(c \*ClientWithResponses\) ([A-Za-z0-9_]+).*/\1/' \
+ | normalize_operations \
| sort -u > "$GEN_OPS"
-# Extract service layer calls to gen.*WithResponse (excluding test files)
+# Record operations with a typed form transport separately. Operation-level
+# normalization remains useful for coverage, but service wrappers for these
+# operations must not silently switch to the generic WithBody helper.
+grep "^func (c \*ClientWithResponses)" "$GENERATED_FILE" 2>/dev/null \
+ | sed -E 's/^func \(c \*ClientWithResponses\) ([A-Za-z0-9_]+).*/\1/' \
+ | sed -nE 's/^([A-Za-z0-9_]+)WithFormdataBodyWithResponse$/\1/p' \
+ | sort -u > "$GEN_FORM_OPS"
+
+# Extract generated-client calls made by the service layer (excluding tests).
+# Some form operations intentionally use the raw WithFormdataBody method so
+# they can inspect 302/303 responses without the generated response parser.
for f in "$SERVICE_DIR"/*.go; do
case "$f" in
*_test.go) continue ;;
esac
- grep "\.gen\.[A-Za-z]*WithResponse" "$f" 2>/dev/null || true
-done | sed 's/.*\.gen\.\([A-Za-z]*\)WithResponse.*/\1/' \
- | sed 's/WithBody$//' \
+ grep -oE '\.gen\.[A-Za-z0-9_]+' "$f" 2>/dev/null || true
+done | sed -E 's/^\.gen\.//' \
+ | normalize_operations \
| sort -u > "$SVC_OPS"
+for f in "$SERVICE_DIR"/*.go; do
+ case "$f" in
+ *_test.go) continue ;;
+ esac
+ grep -oE '\.gen\.[A-Za-z0-9_]+' "$f" 2>/dev/null || true
+done | sed -E 's/^\.gen\.//' \
+ | sed -nE 's/^([A-Za-z0-9_]+)WithFormdataBody(WithResponse)?$/\1/p' \
+ | sort -u > "$SVC_FORM_OPS"
+
+comm -12 "$GEN_FORM_OPS" "$SVC_OPS" > "$WRAPPED_FORM_OPS"
+
GEN_COUNT=$(wc -l < "$GEN_OPS" | tr -d ' ')
SVC_COUNT=$(wc -l < "$SVC_OPS" | tr -d ' ')
@@ -60,6 +92,11 @@ UNWRAPPED_COUNT=$(echo "$UNWRAPPED" | grep -c . || true)
MISSING=$(comm -13 "$GEN_OPS" "$SVC_OPS")
MISSING_COUNT=$(echo "$MISSING" | grep -c . || true)
+# Form operations wrapped by the service layer must use their typed form
+# helper so browser media type and encoding cannot drift to generic bodies.
+MISSING_FORM=$(comm -23 "$WRAPPED_FORM_OPS" "$SVC_FORM_OPS")
+MISSING_FORM_COUNT=$(echo "$MISSING_FORM" | grep -c . || true)
+
HAS_DRIFT=0
if [ "$UNWRAPPED_COUNT" -gt 0 ]; then
@@ -77,6 +114,14 @@ if [ "$MISSING_COUNT" -gt 0 ]; then
HAS_DRIFT=1
fi
+if [ "$MISSING_FORM_COUNT" -gt 0 ]; then
+ echo "=== ERROR: FORM OPERATIONS USING NON-FORM SERVICE TRANSPORT ($MISSING_FORM_COUNT) ==="
+ echo "$MISSING_FORM"
+ echo ""
+ echo "These service wrappers must call their generated WithFormdataBody helper."
+ HAS_DRIFT=1
+fi
+
if [ "$GEN_COUNT" -eq 0 ]; then
echo "ERROR: No generated operations found. Check GENERATED_FILE path or parsing."
exit 1
diff --git a/scripts/enhance-openapi-go-types.sh b/scripts/enhance-openapi-go-types.sh
index 434cd80..eb912a0 100755
--- a/scripts/enhance-openapi-go-types.sh
+++ b/scripts/enhance-openapi-go-types.sh
@@ -8,6 +8,8 @@
set -euo pipefail
OPENAPI_FILE="${1:-openapi.json}"
+TMP_FILE="${OPENAPI_FILE}.tmp.$$"
+trap 'rm -f "$TMP_FILE"' EXIT
if ! command -v jq &> /dev/null; then
echo "ERROR: jq is required but not installed."
@@ -34,7 +36,9 @@ jq '
end
)
)
-' "$OPENAPI_FILE" > "${OPENAPI_FILE}.tmp" && mv "${OPENAPI_FILE}.tmp" "$OPENAPI_FILE"
+' "$OPENAPI_FILE" > "$TMP_FILE"
+chmod 0644 "$TMP_FILE"
+mv "$TMP_FILE" "$OPENAPI_FILE"
# Pass 2: Optional booleans and timestamps in RequestContent schemas → pointers
# Without this, Go's JSON encoder sends zero-valued time.Time as "0001-01-01T00:00:00Z"
@@ -54,13 +58,61 @@ jq '
)
else .
end
- )
+ ) |
+ if (.key == "CreateReplyRequestContent" and (.value.properties.entry["$ref"] // "") != "") then
+ .value.properties.entry = {
+ "allOf": [{"$ref": .value.properties.entry["$ref"]}],
+ "x-go-type-skip-optional-pointer": false
+ }
+ else .
+ end
else .
end
)
-' "$OPENAPI_FILE" > "${OPENAPI_FILE}.tmp" && mv "${OPENAPI_FILE}.tmp" "$OPENAPI_FILE"
+' "$OPENAPI_FILE" > "$TMP_FILE"
+chmod 0644 "$TMP_FILE"
+mv "$TMP_FILE" "$OPENAPI_FILE"
+
+# Pass 3: Convert Smithy-modeled form operations from restJson1's JSON media
+# type to application/x-www-form-urlencoded. Inline the top-level component so
+# oapi-codegen emits form tags; the schema itself remains generated from Smithy.
+jq '
+ . as $root |
+ (.paths // {}) |= with_entries(
+ .value |= with_entries(
+ if (.key | test("^(get|put|post|delete|options|head|patch|trace)$")) then
+ .value |= (
+ if (.["x-hey-form-urlencoded"] != null and .requestBody.content["application/json"] != null) then
+ .requestBody.content = {
+ "application/x-www-form-urlencoded": (
+ .requestBody.content["application/json"] as $body |
+ if ($body.schema["$ref"] // "") | startswith("#/components/schemas/") then
+ ($body.schema["$ref"] | split("/") | last) as $schema_name |
+ $body + {schema: $root.components.schemas[$schema_name]}
+ else
+ $body
+ end
+ )
+ }
+ else .
+ end |
+ if (([.requestBody.content[]?.schema.properties[]? | has("x-hey-sensitive")] | any)
+ or ([.parameters[]? | (has("x-hey-sensitive") or ((.schema // {}) | has("x-hey-sensitive")))] | any)) then
+ . + {"x-hey-sensitive": true}
+ else .
+ end
+ )
+ else
+ .
+ end
+ )
+ )
+' "$OPENAPI_FILE" > "$TMP_FILE"
+chmod 0644 "$TMP_FILE"
+mv "$TMP_FILE" "$OPENAPI_FILE"
-# Pass 3: Nothing here — self-referential types are fixed via post-codegen sed
+# Pass 4: Nothing here — self-referential types are fixed via post-codegen sed
# in go/Makefile (oapi-codegen ignores type overrides on bare $ref properties).
+trap - EXIT
echo "Enhanced $OPENAPI_FILE with Go type annotations"
diff --git a/scripts/generate-route-coverage b/scripts/generate-route-coverage
index 688fc38..2e6bc29 100755
--- a/scripts/generate-route-coverage
+++ b/scripts/generate-route-coverage
@@ -1,26 +1,130 @@
#!/usr/bin/env bash
# generate-route-coverage — Extract modeled operation routes from OpenAPI
#
-# Reads: openapi.json
+# Reads: openapi.json and spec/route-coverage-scope.json
# Writes: spec/route-coverage.json
+#
+# The scope file accounts for every modeled operation. Its operations array is
+# the intentional drift-audit allowlist; excludedOperations documents API-only
+# or otherwise unresolved Smithy routes that are not yet part of the Rails
+# route audit. Selected operations still refresh their generated method/path.
+# Per-operation overrides represent reviewed canonical Rails routes that differ
+# from the public API transport path.
set -euo pipefail
OPENAPI_FILE="${1:-openapi.json}"
-OUTPUT_FILE="spec/route-coverage.json"
+OUTPUT_FILE="${2:-spec/route-coverage.json}"
+SCOPE_FILE="${3:-spec/route-coverage-scope.json}"
if [[ ! -f "$OPENAPI_FILE" ]]; then
echo "ERROR: $OPENAPI_FILE not found. Run 'make smithy-build' first."
exit 1
fi
+if [[ ! -f "$SCOPE_FILE" ]]; then
+ echo "ERROR: $SCOPE_FILE not found."
+ exit 1
+fi
-jq -S '[
+OUTPUT_DIR="$(dirname "$OUTPUT_FILE")"
+TEMP_OUTPUT="$(mktemp "$OUTPUT_DIR/.route-coverage.XXXXXX")"
+trap 'rm -f "$TEMP_OUTPUT"' EXIT
+
+jq -S --slurpfile scope "$SCOPE_FILE" '
+($scope[0].operations // []) as $operations |
+($scope[0].overrides // {}) as $overrides |
+[
.paths | to_entries[] | .key as $path |
- .value | to_entries[] | select(.key != "parameters") |
+ .value | to_entries[] |
+ select(.key | test("^(get|put|post|delete|options|head|patch|trace)$")) |
{
operationId: .value.operationId,
- method: (.key | ascii_upcase),
+ method: ((.value["x-hey-form-method-override"].overrideMethod // .key) | ascii_upcase),
path: $path
- }
-] | sort_by(.operationId)' "$OPENAPI_FILE" > "$OUTPUT_FILE"
+ } |
+ select(.operationId as $operation | $operations | index($operation)) |
+ . as $route |
+ $route * ($overrides[$route.operationId] // {})
+] | sort_by(.operationId)' "$OPENAPI_FILE" > "$TEMP_OUTPUT"
+
+jq -e --slurpfile coverage "$TEMP_OUTPUT" --slurpfile openapi "$OPENAPI_FILE" '
+def httpMethod:
+ test("^(get|put|post|delete|options|head|patch|trace)$");
+def validOperationId:
+ type == "string" and length > 0;
+def validOverride:
+ if type != "object" then
+ false
+ else
+ ((keys - ["method", "path"]) | length) == 0 and
+ (if has("method") then
+ (.method | type == "string" and test("^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$"))
+ else
+ true
+ end) and
+ (if has("path") then
+ (.path | type == "string" and test("^/[^[:space:]]*$"))
+ else
+ true
+ end)
+ end;
+def validExclusionReason:
+ type == "string" and test("[^[:space:]]");
+. as $scopeDocument |
+(.operations // error("scope must define an operations array")) as $scope |
+($scopeDocument | if has("overrides") then .overrides else {} end) as $overrides |
+($scopeDocument | if has("excludedOperations") then .excludedOperations else {} end) as $excluded |
+($overrides | if type == "object" then keys else [] end) as $overrideOperations |
+($excluded | if type == "object" then keys else [] end) as $excludedOperations |
+([$overrides | if type == "object" then to_entries[] else empty end | select(.value | validOverride | not) | .key]) as $invalidOverrides |
+([$excluded | if type == "object" then to_entries[] else empty end | select(.value | validExclusionReason | not) | .key]) as $invalidExclusions |
+([
+ ($openapi[0].paths // {}) | to_entries[] |
+ .value | to_entries[] |
+ select(.key | httpMethod) |
+ .value.operationId
+]) as $modeledRaw |
+($modeledRaw | map(select(validOperationId)) | unique) as $modeled |
+($scope + $excludedOperations | unique) as $accounted |
+($coverage[0] | map(.operationId)) as $actual |
+if ($scope | type) != "array" or ($scope | all(.[]; type == "string" and length > 0) | not) then
+ error("scope operations must be non-empty strings")
+elif ($scope | length) == 0 then
+ error("scope must include at least one operation")
+elif ($scope | unique | length) != ($scope | length) then
+ error("scope operations must be unique")
+elif ($overrides | type) != "object" then
+ error("scope overrides must be an object")
+elif ($excluded | type) != "object" then
+ error("scope excludedOperations must be an object")
+elif ($invalidExclusions | length) > 0 then
+ error("invalid excluded operation reasons: \($invalidExclusions | join(", "))")
+elif ($modeledRaw | all(.[]; validOperationId) | not) then
+ error("OpenAPI operations must have non-empty operation IDs")
+elif ($modeledRaw | length) != ($modeled | length) then
+ error("OpenAPI contains duplicate operation IDs")
+elif (($scope + $excludedOperations | length) != ($accounted | length)) then
+ error("scope operations and excludedOperations must be disjoint")
+elif ($overrideOperations - $scope | length) > 0 then
+ error("overrides reference unscoped operations: \($overrideOperations - $scope | join(", "))")
+elif ($invalidOverrides | length) > 0 then
+ error("invalid route overrides: \($invalidOverrides | join(", "))")
+elif ($accounted - $modeled | length) > 0 then
+ error("scope references unknown OpenAPI operations: \($accounted - $modeled | join(", "))")
+elif ($modeled - $accounted | length) > 0 then
+ error("OpenAPI operations missing from scope: \($modeled - $accounted | join(", "))")
+elif ($actual | unique | length) != ($actual | length) then
+ error("generated coverage contains duplicate operation IDs")
+elif ($scope - $actual | length) > 0 then
+ error("scoped operations missing from OpenAPI coverage: \($scope - $actual | join(", "))")
+elif ($actual - $scope | length) > 0 then
+ error("generated coverage contains unscoped operations: \($actual - $scope | join(", "))")
+else
+ true
+end
+' "$SCOPE_FILE" >/dev/null
+
+chmod 0644 "$TEMP_OUTPUT"
+mv "$TEMP_OUTPUT" "$OUTPUT_FILE"
+trap - EXIT
echo "Generated $OUTPUT_FILE with $(jq length "$OUTPUT_FILE") operations"
diff --git a/scripts/generate-url-routes b/scripts/generate-url-routes
index 47e573a..94a2eba 100755
--- a/scripts/generate-url-routes
+++ b/scripts/generate-url-routes
@@ -51,7 +51,9 @@ def param_type:
routes: [
.paths | to_entries[] |
.key as $raw_path |
- (.key | gsub("\\.json$"; "")) as $pattern |
+ # Preserve the exact path so routes that differ only by .json remain
+ # distinguishable. The Go router provides .json aliases as a fallback.
+ .key as $pattern |
.value as $path_item |
# Collect all operations on this path
diff --git a/spec/excluded-routes.json b/spec/excluded-routes.json
index 6c913df..3e3288c 100644
--- a/spec/excluded-routes.json
+++ b/spec/excluded-routes.json
@@ -1919,11 +1919,6 @@
"path": "/memberships/{id}",
"reason": "phase-2: membership management"
},
- {
- "method": "DELETE",
- "path": "/messages/{id}",
- "reason": "phase-2: message CRUD"
- },
{
"method": "PATCH",
"path": "/messages/{id}",
diff --git a/spec/hey-traits.smithy b/spec/hey-traits.smithy
index f514999..daaaa6e 100644
--- a/spec/hey-traits.smithy
+++ b/spec/hey-traits.smithy
@@ -111,6 +111,25 @@ structure heyEmptyOn {
statusCodes: HeyEmptyOnStatusCodes
}
+/// Marks an operation whose Smithy payload is serialized as an
+/// application/x-www-form-urlencoded request rather than JSON.
+/// Emits x-hey-form-urlencoded so the OpenAPI generation pipeline can retain
+/// the Smithy-modeled shape while selecting the correct wire encoding.
+@trait(selector: "operation")
+@specificationExtension(as: "x-hey-form-urlencoded")
+structure heyFormUrlEncoded {}
+
+/// Marks a browser-compatible HTML form operation that uses a Rails `_method`
+/// field to select a different canonical router method. The Smithy `http`
+/// method remains the actual transport method sent on the wire.
+@trait(selector: "operation")
+@specificationExtension(as: "x-hey-form-method-override")
+structure heyFormMethodOverride {
+ /// Canonical HTTP method selected by the form override (for example, DELETE).
+ @required
+ overrideMethod: String
+}
+
list HeyEmptyOnStatusCodes {
member: Integer
}
diff --git a/spec/hey.smithy b/spec/hey.smithy
index 7f95bfa..9489119 100644
--- a/spec/hey.smithy
+++ b/spec/hey.smithy
@@ -31,9 +31,11 @@ namespace hey
use smithy.api#documentation
use smithy.api#http
+use smithy.api#httpHeader
use smithy.api#httpLabel
use smithy.api#httpQuery
use smithy.api#httpPayload
+use smithy.api#jsonName
use smithy.api#required
use smithy.api#readonly
use smithy.api#idempotent
@@ -51,6 +53,8 @@ use hey.traits#heyIdempotent
use hey.traits#heySensitive
use hey.traits#heyPolymorphic
use hey.traits#heyEmptyOn
+use hey.traits#heyFormUrlEncoded
+use hey.traits#heyFormMethodOverride
/// ISO 8601 date-time timestamp (overrides restJson1 epoch-seconds default)
@timestampFormat("date-time")
@@ -88,9 +92,11 @@ service HEY {
CreateMessage
CreateTopicMessage
- // Entries (2 MVP)
+ // Entries (4 MVP)
ListDrafts
CreateReply
+ CreateReplyDraft
+ DeleteDraft
// Contacts (2 MVP)
ListContacts
@@ -1307,13 +1313,15 @@ structure CreateReplyInput {
body: CreateReplyRequestContent
}
-/// Wire format: {acting_sender_id, message: {content}}
+/// Wire format: {acting_sender_id, message: {content}, entry: {addressed: {...}}}
structure CreateReplyRequestContent {
@required
acting_sender_id: Long
@required
message: ReplyMessagePayload
+
+ entry: MessageEntryPayload
}
structure ReplyMessagePayload {
@@ -1321,6 +1329,132 @@ structure ReplyMessagePayload {
content: String
}
+/// Save an editable reply draft without sending it.
+///
+/// This is HEY's browser-compatible form endpoint. The Location response
+/// header identifies the saved draft. Smithy models the success code as 201,
+/// while the service wrapper accepts the verified form outcomes (any 2xx, 302,
+/// or 303) and preserves Location without following redirects.
+@http(method: "POST", uri: "/entries/{entryId}/replies", code: 201)
+@tags(["Entries"])
+@heyRetry(maxAttempts: 1, baseDelayMs: 1000, backoff: "constant", retryOn: [])
+@heyFormUrlEncoded
+operation CreateReplyDraft {
+ input: CreateReplyDraftInput
+ output: CreateReplyDraftOutput
+ errors: [BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, UnprocessableEntityError, InternalServerError, ServiceUnavailableError]
+}
+
+structure CreateReplyDraftInput {
+ @httpLabel
+ @required
+ entryId: Long
+
+ @httpPayload
+ @required
+ body: CreateReplyDraftRequestContent
+}
+
+list ReplyDraftRecipientList {
+ member: String
+}
+
+@sensitive
+string ReplyDraftContent
+
+@sensitive
+string ReplyDraftAuthenticityToken
+
+/// Rails form fields used by the reply composer.
+/// Recipient fields are repeated array parameters, not comma-separated JSON
+/// strings. Content is already-rich ActionText HTML and is transmitted exactly
+/// as provided.
+structure CreateReplyDraftRequestContent {
+ @required
+ acting_sender_id: Long
+
+ @required
+ @jsonName("entry[status]")
+ entry_status: String
+
+ @required
+ @heySensitive(category: "pii", redact: true)
+ @jsonName("message[content]")
+ message_content: ReplyDraftContent
+
+ @jsonName("message[subject]")
+ message_subject: String
+
+ @jsonName("message[auto_quoting]")
+ message_auto_quoting: Boolean
+
+ @jsonName("entry[addressed][directly][]")
+ entry_addressed_directly: ReplyDraftRecipientList
+
+ @jsonName("entry[addressed][copied][]")
+ entry_addressed_copied: ReplyDraftRecipientList
+
+ @jsonName("entry[addressed][blindcopied][]")
+ entry_addressed_blindcopied: ReplyDraftRecipientList
+
+ @heySensitive(category: "credential", redact: true)
+ authenticity_token: ReplyDraftAuthenticityToken
+}
+
+structure CreateReplyDraftOutput {
+ @httpHeader("Location")
+ location: String
+}
+
+/// Delete an editable message draft without sending it.
+///
+/// HEY exposes this as a Rails form: the canonical router operation is DELETE
+/// `/messages/{messageId}`, while browsers submit POST with `_method=delete`.
+/// The generated transport preserves that browser-compatible wire contract.
+@http(method: "POST", uri: "/messages/{messageId}")
+@tags(["Entries"])
+@heyRetry(maxAttempts: 1, baseDelayMs: 1000, backoff: "constant", retryOn: [])
+@heyFormUrlEncoded
+@heyFormMethodOverride(overrideMethod: "DELETE")
+operation DeleteDraft {
+ input: DeleteDraftInput
+ output: DeleteDraftOutput
+ errors: [BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, UnprocessableEntityError, InternalServerError, ServiceUnavailableError]
+}
+
+@sensitive
+string DraftAuthenticityToken
+
+structure DeleteDraftInput {
+ @httpLabel
+ @required
+ messageId: Long
+
+ @httpHeader("X-CSRF-Token")
+ @required
+ @heySensitive(category: "credential", redact: true)
+ authenticity_token: DraftAuthenticityToken
+
+ @httpPayload
+ @required
+ body: DeleteDraftRequestContent
+}
+
+/// Rails form fields emitted by the draft edit page's discard form.
+structure DeleteDraftRequestContent {
+ @required
+ @jsonName("_method")
+ method_override: String
+
+ @required
+ status: String
+}
+
+structure DeleteDraftOutput {
+ @httpHeader("Location")
+ location: String
+}
+
// =============================================================================
// CONTACT OPERATIONS
// =============================================================================
diff --git a/spec/route-coverage-scope.json b/spec/route-coverage-scope.json
new file mode 100644
index 0000000..dbf3873
--- /dev/null
+++ b/spec/route-coverage-scope.json
@@ -0,0 +1,58 @@
+{
+ "operations": [
+ "CompleteCalendarTodo",
+ "CompleteHabit",
+ "CreateCalendarTodo",
+ "CreateMessage",
+ "CreateReply",
+ "CreateReplyDraft",
+ "CreateTopicMessage",
+ "DeleteCalendarTodo",
+ "DeleteDraft",
+ "GetAsidebox",
+ "GetBox",
+ "GetBubblebox",
+ "GetCalendarRecordings",
+ "GetContact",
+ "GetEverythingTopics",
+ "GetFeedbox",
+ "GetIdentity",
+ "GetImbox",
+ "GetJournalEntry",
+ "GetLaterbox",
+ "GetMessage",
+ "GetNavigation",
+ "GetOngoingTimeTrack",
+ "GetSentTopics",
+ "GetSpamTopics",
+ "GetTopic",
+ "GetTopicEntries",
+ "GetTrailbox",
+ "GetTrashTopics",
+ "ListBoxes",
+ "ListCalendars",
+ "ListContacts",
+ "ListDrafts",
+ "MarkPostingsSeen",
+ "MarkPostingsUnseen",
+ "Search",
+ "StartTimeTrack",
+ "UncompleteCalendarTodo",
+ "UncompleteHabit",
+ "UpdateJournalEntry",
+ "UpdateTimeTrack"
+ ],
+ "excludedOperations": {
+ "IgnorePosting": "Public API route differs from the unresolved browser muting route",
+ "MovePostingToFeed": "Public API route differs from the generic browser postings move route",
+ "MovePostingToPaperTrail": "Public API route differs from the generic browser postings move route",
+ "MovePostingToReplyLater": "Public API route differs from the generic browser postings move route",
+ "MovePostingToSetAside": "Public API route differs from the generic browser postings move route",
+ "MovePostingToTrash": "Public API route differs from the unresolved browser trash route"
+ },
+ "overrides": {
+ "CreateTopicMessage": {
+ "path": "/topics/{topicId}/messages"
+ }
+ }
+}
diff --git a/spec/route-coverage.json b/spec/route-coverage.json
index e1028ec..234b6c9 100644
--- a/spec/route-coverage.json
+++ b/spec/route-coverage.json
@@ -22,6 +22,11 @@
{
"method": "POST",
"operationId": "CreateReply",
+ "path": "/entries/{entryId}/replies.json"
+ },
+ {
+ "method": "POST",
+ "operationId": "CreateReplyDraft",
"path": "/entries/{entryId}/replies"
},
{
@@ -34,6 +39,11 @@
"operationId": "DeleteCalendarTodo",
"path": "/calendar/todos/{todoId}"
},
+ {
+ "method": "DELETE",
+ "operationId": "DeleteDraft",
+ "path": "/messages/{messageId}"
+ },
{
"method": "GET",
"operationId": "GetAsidebox",
@@ -154,6 +164,16 @@
"operationId": "ListDrafts",
"path": "/entries/drafts.json"
},
+ {
+ "method": "POST",
+ "operationId": "MarkPostingsSeen",
+ "path": "/postings/seen.json"
+ },
+ {
+ "method": "POST",
+ "operationId": "MarkPostingsUnseen",
+ "path": "/postings/unseen.json"
+ },
{
"method": "GET",
"operationId": "Search",