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:
@@ -3,9 +3,15 @@ package cabana_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -20,10 +26,13 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"gocloud.dev/blob"
|
||||
"gocloud.dev/blob/memblob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -112,6 +121,17 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
|
||||
}, into[cabana.Envelope[[]cabana.FilterOption]]()},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
|
||||
}, into[cabana.Envelope[cabana.FileItem]]()},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
|
||||
var body cabana.Envelope[[]cabana.FileItem]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) != 1 || !body.Data[0].Pending || body.Data[0].URL == "" || body.Data[0].ThumbURL == "" {
|
||||
t.Fatalf("pending file list = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[[]cabana.FileItem]]()},
|
||||
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID}, true)
|
||||
e.gadgetID = dataID(t, rec.Body.Bytes())
|
||||
@@ -255,13 +275,78 @@ func conformSpec(t *testing.T) conformSpecDoc {
|
||||
}
|
||||
|
||||
type conformEnv struct {
|
||||
h http.Handler
|
||||
login string
|
||||
token string
|
||||
stamp string
|
||||
groupID uint
|
||||
memberID uint
|
||||
gadgetID uint
|
||||
h http.Handler
|
||||
db *gorm.DB
|
||||
bucket *blob.Bucket
|
||||
login string
|
||||
token string
|
||||
stamp string
|
||||
sessionKey string
|
||||
groupID uint
|
||||
memberID uint
|
||||
gadgetID uint
|
||||
}
|
||||
|
||||
// sendWith sends a raw body with extra headers through the assembled router.
|
||||
func (e *conformEnv) sendWith(t *testing.T, method, rel string, body []byte, contentType string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(body))
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+e.token)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// upload posts one multipart file_data part to a gadget's file field.
|
||||
func (e *conformEnv) upload(t *testing.T, id uint, field, name string, data []byte, key string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("file_data", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := map[string]string{}
|
||||
if key != "" {
|
||||
headers[cabana.SessionKeyHeader] = key
|
||||
}
|
||||
return e.sendWith(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/files/%s", id, field), buf.Bytes(), mw.FormDataContentType(), headers)
|
||||
}
|
||||
|
||||
// conformPNG is a small valid PNG.
|
||||
func conformPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
|
||||
for x := 0; x < 4; x++ {
|
||||
img.Set(x, 1, color.RGBA{R: 200, A: 255})
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// newSessionKey is a random form session key as the SPA makes one.
|
||||
func newSessionKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func (e *conformEnv) send(t *testing.T, method, rel string, body any, auth bool) *httptest.ResponseRecorder {
|
||||
@@ -340,6 +425,14 @@ func newConformEnv(t *testing.T) *conformEnv {
|
||||
if err := gdb.AutoMigrate(models...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gadget ids restart with the recreated table: drop the files and
|
||||
// bindings an earlier run left for them.
|
||||
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type = 'acme.conform.gadget' OR attachment_id IS NULL OR attachment_id = ''`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type = 'acme.conform.gadget'`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamp := fmt.Sprintf("c%d", time.Now().UnixNano())
|
||||
group := conformGroup{Title: "Group " + stamp}
|
||||
member := conformMember{Email: "member-" + stamp + "@example.test"}
|
||||
@@ -369,6 +462,11 @@ func newConformEnv(t *testing.T) *conformEnv {
|
||||
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := memblob.OpenBucket(nil)
|
||||
t.Cleanup(func() { _ = bucket.Close() })
|
||||
if err := attach.Publish(app, bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugins := []party.Plugin{conformPlugin{stamp: stamp}}
|
||||
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -377,7 +475,7 @@ func newConformEnv(t *testing.T) *conformEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &conformEnv{h: h, login: login, stamp: stamp, groupID: group.ID, memberID: member.ID}
|
||||
return &conformEnv{h: h, db: gdb, bucket: bucket, login: login, stamp: stamp, sessionKey: newSessionKey(t), groupID: group.ID, memberID: member.ID}
|
||||
}
|
||||
|
||||
type conformGadget struct {
|
||||
@@ -391,6 +489,10 @@ type conformGadget struct {
|
||||
}
|
||||
|
||||
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
|
||||
func (conformGadget) MorphName() string { return "acme.conform.gadget" }
|
||||
func (conformGadget) AttachRelations() []attach.Relation {
|
||||
return []attach.Relation{{Name: "photos", Many: true, Public: true}}
|
||||
}
|
||||
func (conformGadget) Fillable() []string { return []string{"name", "active"} }
|
||||
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} }
|
||||
@@ -623,6 +725,14 @@ update:
|
||||
label: Summary
|
||||
type: partial
|
||||
path: summary
|
||||
photos:
|
||||
label: Photos
|
||||
type: fileupload
|
||||
mode: image
|
||||
maxFiles: 5
|
||||
maxFilesize: 0.5
|
||||
thumbOptions:
|
||||
mode: crop
|
||||
`),
|
||||
"models/settings/fields.yaml": file(`fields:
|
||||
enabled:
|
||||
|
||||
Reference in New Issue
Block a user