feat(12.2-02): add the fileupload field with deferred uploads committed on save

- type: fileupload compiles the D-08 keys and binds to the model's attach.Relation at boot
- X-Session-Key (cabana.SessionKeyHeader) carries the form session key; RecordInput.SessionKey
- GET and POST .../{id}/files/{field}: list with pending uploads, multipart upload into attach.Store
- the create and update save attaches the session's pending files in its transaction
- swagger2openapi folds formData parameters into a multipart requestBody
- admin OpenAPI, TS types, conformance cases, README and forms docs
This commit is contained in:
Jakub Zych
2026-10-02 18:04:34 +02:00
parent edda803dc1
commit 044e0450ef
22 changed files with 2183 additions and 21 deletions

View File

@@ -183,12 +183,17 @@ func splitParameters(v any, consumes []string) (params []any, body map[string]an
if !ok {
return nil, nil
}
var form map[string]any
for _, item := range list {
p, ok := item.(map[string]any)
if !ok {
params = append(params, item)
continue
}
if in, _ := p["in"].(string); in == "formData" {
form = addFormField(form, p)
continue
}
if in, _ := p["in"].(string); in == "body" {
content := map[string]any{}
for _, ct := range consumes {
@@ -209,9 +214,61 @@ func splitParameters(v any, consumes []string) (params []any, body map[string]an
}
params = append(params, convertParameter(p))
}
if form != nil && body == nil {
body = formBody(form, consumes)
}
return params, body
}
// addFormField folds one Swagger 2.0 formData parameter into an object
// schema: a file becomes a binary string, any other type keeps its schema.
func addFormField(form map[string]any, p map[string]any) map[string]any {
if form == nil {
form = map[string]any{"type": "object", "properties": map[string]any{}}
}
name, _ := p["name"].(string)
prop := map[string]any{}
for k, v := range p {
if _, ok := paramSchemaKeys[k]; ok {
prop[k] = v
}
}
if prop["type"] == "file" {
prop["type"] = "string"
prop["format"] = "binary"
}
if desc, ok := p["description"]; ok {
prop["description"] = desc
}
form["properties"].(map[string]any)[name] = prop
if req, _ := p["required"].(bool); req {
required, _ := form["required"].([]any)
form["required"] = append(required, name)
}
return form
}
// formBody is the OpenAPI 3 requestBody of an operation's formData
// parameters, under multipart/form-data unless the operation declares only
// application/x-www-form-urlencoded.
func formBody(form map[string]any, consumes []string) map[string]any {
ct := "multipart/form-data"
for _, c := range consumes {
if c == "application/x-www-form-urlencoded" {
ct = c
}
if c == "multipart/form-data" {
ct = c
break
}
}
body := map[string]any{"content": map[string]any{ct: map[string]any{"schema": form}}}
if req, ok := form["required"].([]any); ok && len(req) > 0 {
body["required"] = true
}
return body
}
var paramSchemaKeys = map[string]struct{}{
"type": {}, "format": {}, "items": {}, "enum": {}, "default": {},
"minimum": {}, "maximum": {}, "minLength": {}, "maxLength": {},

View File

@@ -201,3 +201,36 @@ func TestMalformedShapesPassThrough(t *testing.T) {
t.Fatalf("foreign ref rewritten: %v", got)
}
}
func TestConvertFormData(t *testing.T) {
op := decode(t, `{
"consumes": ["multipart/form-data"],
"parameters": [
{"name": "id", "in": "path", "required": true, "type": "integer"},
{"name": "file_data", "in": "formData", "required": true, "type": "file", "description": "The file"},
{"name": "note", "in": "formData", "type": "string"}
],
"responses": {"201": {"description": "Created"}}
}`)
out := convertOperation(op, nil, nil)
params := out["parameters"].([]any)
if len(params) != 1 || params[0].(map[string]any)["name"] != "id" {
t.Fatalf("parameters = %#v", params)
}
body := out["requestBody"].(map[string]any)
if body["required"] != true {
t.Fatalf("request body = %#v", body)
}
schema := body["content"].(map[string]any)["multipart/form-data"].(map[string]any)["schema"].(map[string]any)
want := map[string]any{
"type": "object",
"properties": map[string]any{
"file_data": map[string]any{"type": "string", "format": "binary", "description": "The file"},
"note": map[string]any{"type": "string"},
},
"required": []any{"file_data"},
}
if !reflect.DeepEqual(schema, want) {
t.Fatalf("form schema = %#v", schema)
}
}