Skip to content

Commit 2cb8331

Browse files
alicodingclaude
andcommitted
ADR-0016 Phase B/C: open the Method field, support RFC 10008 QUERY
Closes the concrete complaint that started this ADR: integration-http's Method is now an open FieldText with datalist suggestions (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS/QUERY), not a closed 5-value Select -- any method is accepted and sent as-is, so a new or uncommon method never needs a Mill code change again. ConfigField gained Suggestions []string (meaningful only for FieldText, non-restrictive unlike Options/FieldOptions), NodeInspector.tsx renders a single-line TextInput + datalist for it instead of the generic multi-row Textarea. QUERY support required no execution-layer code change -- net/http and retryablehttp.NewRequest already send a body on any method string -- only tests proving that directly: TestExecute_QueryMethod_SendsBody (real httptest.Server), TestExecuteWorkflow_IntegrationHTTP_QueryMethod_Accepted (through the real ExecuteWorkflow path), and a new e2e spec (request-method-field.spec.ts) proving the datalist offers QUERY, it persists through a real save/reopen round trip, and the existing integration-bindings.spec.ts's stale .selectOption('POST') call (from when Method was still a closed Select) got caught and fixed by running the suite, not assumed compatible. The Manual Schema Editor's separate, OpenAPI-backed operation Method field grew to all 8 methods kin-openapi's PathItem struct actually has fields for (adding HEAD/OPTIONS/TRACE) but deliberately excludes QUERY, verified directly against kin-openapi's source: OpenAPI 3.x has no spec-defined field for it yet, so a schema-authored operation has to stay representable as a real OpenAPI document. The Params tab and Body-type picker -- Phase B's bigger, genuinely separate Postman/Bruno-style request-builder rework -- remain real, tracked OPEN work, not folded into this pass. Verified: full Go build/vet/test/lint (both build tags), frontend tsc/eslint/boundaries/vitest, and the complete 58-test Playwright e2e suite (57 + the new spec) run twice with no leakage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7zUjYuMtgetjNaxMQPg2h
1 parent 13c3d70 commit 2cb8331

9 files changed

Lines changed: 219 additions & 6 deletions

File tree

frontend/bindings/github.com/alicoding/mill/internal/domain/composition/models.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ export interface ConfigField {
7171
* still just read the plain string ID out of Node.Config.
7272
*/
7373
"RefKind": string;
74+
75+
/**
76+
* Suggestions is only meaningful when Type == FieldText -- unlike
77+
* Options (FieldOptions' closed enum), any value is still accepted;
78+
* these are offered as autocomplete hints only (an HTML5 datalist on
79+
* the frontend). ADR-0016: the open-vs-closed distinction this field
80+
* exists for was decided directly against real precedent -- Bruno's
81+
* own .bru format offers named HTTP methods but keeps an explicit
82+
* `method: CUSTOM` escape hatch rather than a closed enum, since a
83+
* closed list can't express a new or uncommon method (e.g. RFC
84+
* 10008's QUERY, published June 2026) without a code change.
85+
*/
86+
"Suggestions": string[] | null;
7487
}
7588

7689
/**

frontend/e2e/integration-bindings.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ test('Matching an Integration node to a declared operation shows a binding edito
9191
const configFields = inspector.getByTestId('canvas-config-field')
9292
await configFields.nth(0).fill('/widgets/{id}') // path
9393
await configFields.nth(0).blur()
94-
await configFields.nth(1).selectOption('POST') // method
94+
// Method is an open TextInput with a datalist of suggestions, not a
95+
// closed Select (ADR-0016) -- fill/blur, not selectOption.
96+
await configFields.nth(1).fill('POST') // method
97+
await configFields.nth(1).blur()
9598

9699
const editor = inspector.getByTestId('integration-bindings-editor')
97100
await expect(editor).toBeVisible()
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
// Exercises ADR-0016 Phase B/C: integration-http's Method field is an
4+
// open TextInput with a datalist of suggestions, not a closed Select --
5+
// any value (including RFC 10008's QUERY, not one of the old 5-item
6+
// list) is accepted and persists, over real Go bindings (Wails3 server
7+
// mode), not mocks.
8+
9+
function workflowRow(page: import('@playwright/test').Page, label: string) {
10+
return page.locator('[data-testid="workflow-row"]', { has: page.getByText(label, { exact: true }) })
11+
}
12+
13+
function activePanel(page: import('@playwright/test').Page) {
14+
return page.locator('[role="tabpanel"]:not([hidden])')
15+
}
16+
17+
async function dragPaletteItemToCanvas(page: import('@playwright/test').Page, nodeTypeID: string) {
18+
await page.evaluate((id) => {
19+
const panel = document.querySelector('[role="tabpanel"]:not([hidden])')
20+
if (!panel) throw new Error('no active tabpanel')
21+
const palette = panel.querySelector(`[data-node-type-id="${id}"]`)
22+
const canvas = panel.querySelector('.react-flow__pane')
23+
if (!palette || !canvas) {
24+
throw new Error(`drag setup failed: palette found=${!!palette} canvas found=${!!canvas}`)
25+
}
26+
const dataTransfer = new DataTransfer()
27+
const rect = canvas.getBoundingClientRect()
28+
const clientX = rect.x + rect.width / 2
29+
const clientY = rect.y + rect.height / 2
30+
palette.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer }))
31+
canvas.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer, clientX, clientY }))
32+
canvas.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer, clientX, clientY }))
33+
}, nodeTypeID)
34+
}
35+
36+
async function deleteStarterNode(page: import('@playwright/test').Page) {
37+
await activePanel(page).locator('.react-flow__node').click()
38+
await activePanel(page).getByRole('button', { name: 'Delete selected' }).click()
39+
await expect(activePanel(page).locator('.react-flow__node')).toHaveCount(0)
40+
}
41+
42+
test('The Method field accepts QUERY, an offered suggestion outside the old closed list, and persists it', async ({ page }) => {
43+
await page.goto('/')
44+
await page.getByRole('link', { name: 'Composition' }).click()
45+
await page.getByTestId('new-workflow').click()
46+
await deleteStarterNode(page)
47+
await activePanel(page).getByTestId('toggle-palette').click()
48+
49+
await dragPaletteItemToCanvas(page, 'integration-http')
50+
await activePanel(page).locator('.react-flow__node').click()
51+
52+
const inspector = activePanel(page).getByTestId('composition-inspector')
53+
const methodField = inspector.getByTestId('canvas-config-field').nth(1)
54+
55+
// Not a closed Select -- a plain text input.
56+
await expect(methodField).toHaveJSProperty('tagName', 'INPUT')
57+
58+
// QUERY is offered as a suggestion via the field's datalist, not
59+
// required to type blind.
60+
const listId = await methodField.getAttribute('list')
61+
expect(listId).toBeTruthy()
62+
const datalist = inspector.locator(`datalist#${listId}`)
63+
await expect(datalist.locator('option[value="QUERY"]')).toHaveCount(1)
64+
65+
await methodField.fill('QUERY')
66+
await methodField.blur()
67+
68+
// Save and reopen via Edit -- the real "did it persist in Node.Config,
69+
// not just left in the input's own local DOM state" proof, same
70+
// pattern composition.spec.ts's own save-then-reopen tests already
71+
// use, rather than a same-tab reselect (which the canvas toolbar's
72+
// top-left docking makes fiddly to click around).
73+
await activePanel(page).getByLabel('Label').fill('E2E QUERY method workflow')
74+
await activePanel(page).getByTestId('save-workflow').click()
75+
76+
const row = workflowRow(page, 'E2E QUERY method workflow')
77+
await expect(row).toBeVisible()
78+
await row.getByRole('button', { name: /Edit/ }).click()
79+
await activePanel(page).locator('.react-flow__node').click()
80+
await expect(activePanel(page).getByTestId('canvas-config-field').nth(1)).toHaveValue('QUERY')
81+
82+
await page.getByRole('tab', { name: 'Workflows' }).click()
83+
await row.getByRole('button', { name: /Delete/ }).click()
84+
await expect(row).toHaveCount(0)
85+
})

frontend/src/composition/NodeInspector.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,25 @@ export function NodeInspector({ node, attrs, nodeType, sameKindNodeTypes, hasWor
188188
data-testid="canvas-config-field"
189189
onBlur={(e) => onConfigChange(field.Key, e.target.value)}
190190
/>
191+
) : field.Suggestions && field.Suggestions.length > 0 ? (
192+
// FieldText with Suggestions (e.g. integration-http's Method,
193+
// ADR-0016) -- a single-line input with an HTML5 datalist of
194+
// hints, not a closed Select: any value is still accepted,
195+
// matching Bruno's own "named methods, or an explicit CUSTOM
196+
// escape hatch" shape rather than a fixed enum a new or
197+
// uncommon method (RFC 10008's QUERY) can't express.
198+
<>
199+
<TextInput
200+
defaultValue={node.data.config[field.Key] ?? ''}
201+
list={`${field.Key}-suggestions`}
202+
block
203+
data-testid="canvas-config-field"
204+
onBlur={(e) => onConfigChange(field.Key, e.target.value)}
205+
/>
206+
<datalist id={`${field.Key}-suggestions`}>
207+
{field.Suggestions.map((s) => <option key={s} value={s} />)}
208+
</datalist>
209+
</>
191210
) : (
192211
<Textarea
193212
defaultValue={node.data.config[field.Key] ?? ''}

frontend/src/configure/ManualSchemaEditor.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,16 @@ import styles from '../shared/ListCard.module.css'
2323
// ever populates them from a JSON response body (bodyFields() in
2424
// openapispec.go), so every output field is payload, never protocol.
2525

26-
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
26+
// The full set kin-openapi's PathItem struct actually recognizes
27+
// (openapi3/path_item.go: Get/Put/Post/Delete/Options/Head/Patch/
28+
// Trace, verified directly against its source, not assumed) --
29+
// deliberately does NOT include RFC 10008's QUERY (published June
30+
// 2026): OpenAPI 3.x has no spec-defined field for it yet, so an
31+
// operation declared here has to stay representable as a real OpenAPI
32+
// document. integration-http's own literal Method field (ADR-0016,
33+
// composition/integration.go) is unconstrained by this and does
34+
// support QUERY -- this list is specific to the schema-authoring path.
35+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE']
2736
const FIELD_TYPES: ManualField['type'][] = ['string', 'number', 'integer', 'boolean', 'object', 'array', 'map', 'date', 'datetime']
2837
const FIELD_INS: ManualField['in'][] = ['path', 'query', 'header', 'body']
2938
const PARAM_INS: ManualField['in'][] = ['path', 'query', 'header']

internal/adapters/httpconnector/httpconnector_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,39 @@ func TestExecute_SendsBody(t *testing.T) {
6262
}
6363
}
6464

65+
// ADR-0016 Phase C: RFC 10008's QUERY method (published June 2026)
66+
// carries a request body like POST but isn't one of net/http's
67+
// pre-defined method constants -- proves directly, not assumed, that
68+
// Execute (and the retryablehttp.NewRequest/http.NewRequest chain
69+
// underneath it) sends a body on an arbitrary non-standard method
70+
// string exactly like it does for POST, since neither actually
71+
// special-cases the method when attaching a body.
72+
func TestExecute_QueryMethod_SendsBody(t *testing.T) {
73+
var gotMethod, gotBody string
74+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+
gotMethod = r.Method
76+
b, _ := io.ReadAll(r.Body)
77+
gotBody = string(b)
78+
w.WriteHeader(http.StatusOK)
79+
_, _ = w.Write([]byte(`{"results":[]}`))
80+
}))
81+
defer srv.Close()
82+
83+
resp, err := Execute(Request{Method: "QUERY", URL: srv.URL, Body: `{"filter":{"status":"active"}}`})
84+
if err != nil {
85+
t.Fatalf("Execute returned error: %v", err)
86+
}
87+
if gotMethod != "QUERY" {
88+
t.Errorf("server received method %q, want QUERY", gotMethod)
89+
}
90+
if gotBody != `{"filter":{"status":"active"}}` {
91+
t.Errorf("server received body %q, want the QUERY body", gotBody)
92+
}
93+
if resp.StatusCode != http.StatusOK || resp.Body != `{"results":[]}` {
94+
t.Errorf("resp = %+v, want a normal 200 response", resp)
95+
}
96+
}
97+
6598
// go-retryablehttp's DefaultRetryPolicy doesn't retry a plain 400 (only
6699
// 429 and 5xx-except-501 are retryable, verified directly against its
67100
// source -- see httpconnector.go's own newClient comment), so this

internal/domain/composition/execute_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,38 @@ func TestExecuteWorkflow_IntegrationHTTP_APIKeyAuth(t *testing.T) {
204204
}
205205
}
206206

207+
// ADR-0016 Phase B/C: method is an open FieldText field, not a closed
208+
// FieldOptions enum -- proves ResolveNodeDefaults accepts a method
209+
// value outside the old 5-item list (RFC 10008's QUERY) and that it
210+
// actually reaches the server unmodified, through the real
211+
// ExecuteWorkflow path, not just httpconnector's own lower-level test.
212+
func TestExecuteWorkflow_IntegrationHTTP_QueryMethod_Accepted(t *testing.T) {
213+
var gotMethod string
214+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
215+
gotMethod = r.Method
216+
w.WriteHeader(http.StatusOK)
217+
}))
218+
defer srv.Close()
219+
220+
withHTTPRequestLookup(t, func(string) (ResolvedHTTPRequest, error) {
221+
return ResolvedHTTPRequest{BaseURL: srv.URL, AuthType: httprequest.AuthNone}, nil
222+
})
223+
224+
nodes, err := ResolveNodeDefaults([]Node{{
225+
NodeTypeID: "integration-http",
226+
Config: map[string]string{"requestId": "conn-1", "path": "/x", "method": "QUERY", "bodyTemplate": `{"filter":"active"}`},
227+
}})
228+
if err != nil {
229+
t.Fatalf("ResolveNodeDefaults rejected method %q, want it accepted (open FieldText, ADR-0016): %v", "QUERY", err)
230+
}
231+
if _, err := ExecuteWorkflow(nodes, nil, nil); err != nil {
232+
t.Fatalf("ExecuteWorkflow returned error: %v", err)
233+
}
234+
if gotMethod != "QUERY" {
235+
t.Errorf("server received method %q, want QUERY", gotMethod)
236+
}
237+
}
238+
207239
func TestExecuteWorkflow_IntegrationHTTP_UnknownHTTPRequest_Rejected(t *testing.T) {
208240
withHTTPRequestLookup(t, func(id string) (ResolvedHTTPRequest, error) {
209241
return ResolvedHTTPRequest{}, errors.New("no such request")

internal/domain/composition/integration.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,21 @@ func SetHTTPRequestLookup(fn func(requestID string) (ResolvedHTTPRequest, error)
8888
lookupHTTPRequestFn = fn
8989
}
9090

91+
// httpMethodSuggestions are offered as autocomplete hints on the open
92+
// Method field below, not a closed set -- ADR-0016. Includes RFC
93+
// 10008's QUERY (published June 2026: safe + idempotent like GET, but
94+
// carries a request body like POST) alongside the traditional verbs;
95+
// any string is still accepted and sent as-is.
96+
var httpMethodSuggestions = []string{
97+
http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete,
98+
http.MethodHead, http.MethodOptions, "QUERY",
99+
}
100+
91101
func init() {
92102
RegisterNodeType(NodeType{
93103
ID: "integration-http", Kind: KindProcess,
94104
Label: "Integration: HTTP call",
95-
Description: "Calls a Configure-authored request's API and replaces the payload with the response body. requestId isn't a closed FieldOptions set (unlike method below) because requests are runtime, Configure-authored data composition.go has no compile-time knowledge of -- the frontend Inspector renders a live picker for it (RefKind, docs/adr/0009), not a closed option list composition.go could declare here.",
105+
Description: "Calls a Configure-authored request's API and replaces the payload with the response body. requestId and method are both open FieldText, not a closed FieldOptions set -- requests are runtime, Configure-authored data composition.go has no compile-time knowledge of (the frontend Inspector renders a live picker for requestId, RefKind, docs/adr/0009), and method must accept any HTTP method (including a new or custom one, e.g. RFC 10008's QUERY) without a code change here (ADR-0016).",
96106
ConfigFields: []ConfigField{
97107
{
98108
Key: "requestId", Label: "Request ID",
@@ -106,9 +116,8 @@ func init() {
106116
},
107117
{
108118
Key: "method", Label: "Method",
109-
Description: "HTTP method for this call.",
110-
Default: http.MethodGet, Type: FieldOptions,
111-
Options: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch},
119+
Description: "HTTP method for this call -- any method is accepted, not just the suggested common ones.",
120+
Default: http.MethodGet, Type: FieldText, Suggestions: httpMethodSuggestions,
112121
},
113122
{
114123
Key: "bodyTemplate", Label: "Body",

internal/domain/composition/types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ type ConfigField struct {
8181
// box. composition itself never reads RefKind -- nodeExec functions
8282
// still just read the plain string ID out of Node.Config.
8383
RefKind string
84+
// Suggestions is only meaningful when Type == FieldText -- unlike
85+
// Options (FieldOptions' closed enum), any value is still accepted;
86+
// these are offered as autocomplete hints only (an HTML5 datalist on
87+
// the frontend). ADR-0016: the open-vs-closed distinction this field
88+
// exists for was decided directly against real precedent -- Bruno's
89+
// own .bru format offers named HTTP methods but keeps an explicit
90+
// `method: CUSTOM` escape hatch rather than a closed enum, since a
91+
// closed list can't express a new or uncommon method (e.g. RFC
92+
// 10008's QUERY, published June 2026) without a code change.
93+
Suggestions []string
8494
}
8595

8696
type NodeType struct {

0 commit comments

Comments
 (0)