diff --git a/api/compose/spec.json b/api/compose/spec.json index c803b52b0..157ec81d4 100644 --- a/api/compose/spec.json +++ b/api/compose/spec.json @@ -655,6 +655,48 @@ ] } }, + { + "name": "export", + "path": "/export{filename}.{ext}", + "method": "GET", + "title": "Exports records that match ", + "parameters": { + "path": [ + { + "type": "string", + "name": "filename", + "required": false, + "title": "Filename to use" + }, + { + "type": "string", + "name": "ext", + "required": true, + "title": "Export format" + } + ], + "get": [ + { + "name": "filter", + "type": "string", + "required": false, + "title": "Filtering condition" + }, + { + "name": "sort", + "type": "string", + "required": false, + "title": "Sort field (default id desc)" + }, + { + "name": "download", + "type": "bool", + "required": false, + "title": "Send headers to browser to trigger download/save-as" + } + ] + } + }, { "name": "create", "method": "POST", diff --git a/api/compose/spec/record.json b/api/compose/spec/record.json index 2f9cedeb5..7f0d44cc3 100644 --- a/api/compose/spec/record.json +++ b/api/compose/spec/record.json @@ -91,6 +91,48 @@ ] } }, + { + "Name": "export", + "Method": "GET", + "Title": "Exports records that match ", + "Path": "/export{filename}.{ext}", + "Parameters": { + "get": [ + { + "name": "filter", + "required": false, + "title": "Filtering condition", + "type": "string" + }, + { + "name": "sort", + "required": false, + "title": "Sort field (default id desc)", + "type": "string" + }, + { + "name": "download", + "required": false, + "title": "Send headers to browser to trigger download/save-as", + "type": "bool" + } + ], + "path": [ + { + "name": "filename", + "required": false, + "title": "Filename to use", + "type": "string" + }, + { + "name": "ext", + "required": true, + "title": "Export format", + "type": "string" + } + ] + } + }, { "Name": "create", "Method": "POST", diff --git a/compose/rest/handlers/record.go b/compose/rest/handlers/record.go index be01c1417..46a2a6e13 100644 --- a/compose/rest/handlers/record.go +++ b/compose/rest/handlers/record.go @@ -31,6 +31,7 @@ import ( type RecordAPI interface { Report(context.Context, *request.RecordReport) (interface{}, error) List(context.Context, *request.RecordList) (interface{}, error) + Export(context.Context, *request.RecordExport) (interface{}, error) Create(context.Context, *request.RecordCreate) (interface{}, error) Read(context.Context, *request.RecordRead) (interface{}, error) Update(context.Context, *request.RecordUpdate) (interface{}, error) @@ -42,6 +43,7 @@ type RecordAPI interface { type Record struct { Report func(http.ResponseWriter, *http.Request) List func(http.ResponseWriter, *http.Request) + Export func(http.ResponseWriter, *http.Request) Create func(http.ResponseWriter, *http.Request) Read func(http.ResponseWriter, *http.Request) Update func(http.ResponseWriter, *http.Request) @@ -91,6 +93,26 @@ func NewRecord(h RecordAPI) *Record { resputil.JSON(w, value) } }, + Export: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordExport() + if err := params.Fill(r); err != nil { + logger.LogParamError("Record.Export", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.Export(r.Context(), params) + if err != nil { + logger.LogControllerError("Record.Export", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Record.Export", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, Create: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewRecordCreate() @@ -199,6 +221,7 @@ func (h Record) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http r.Use(middlewares...) r.Get("/namespace/{namespaceID}/module/{moduleID}/record/report", h.Report) r.Get("/namespace/{namespaceID}/module/{moduleID}/record/", h.List) + r.Get("/namespace/{namespaceID}/module/{moduleID}/record/export{filename}.{ext}", h.Export) r.Post("/namespace/{namespaceID}/module/{moduleID}/record/", h.Create) r.Get("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}", h.Read) r.Post("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}", h.Update) diff --git a/compose/rest/record.go b/compose/rest/record.go index 39086b76f..7c9e4a988 100644 --- a/compose/rest/record.go +++ b/compose/rest/record.go @@ -2,6 +2,9 @@ package rest import ( "context" + "encoding/json" + "fmt" + "net/http" "github.com/titpetric/factory/resputil" @@ -154,6 +157,45 @@ func (ctrl *Record) Upload(ctx context.Context, r *request.RecordUpload) (interf return makeAttachmentPayload(ctx, a, err) } +func (ctrl *Record) Export(ctx context.Context, r *request.RecordExport) (interface{}, error) { + var ( + m *types.Module + err error + ) + + if m, err = ctrl.module.With(ctx).FindByID(r.NamespaceID, r.ModuleID); err != nil { + return nil, err + } + + _ = m + + // will probably have to rewrite exporting into something more optimal: + // maybe pass encoding function/callback directly + rr, _, err := ctrl.record.With(ctx).Find(types.RecordFilter{ + NamespaceID: r.NamespaceID, + ModuleID: r.ModuleID, + Filter: r.Filter, + Sort: r.Sort, + }) + + return func(w http.ResponseWriter, req *http.Request) { + var ( + enc = json.NewEncoder(w) + filename = fmt.Sprintf("; filename=%s.%s", r.Filename, r.Ext) + ) + + if r.Download { + w.Header().Add("Content-Disposition", "attachment"+filename) + } else { + w.Header().Add("Content-Disposition", "inline"+filename) + } + + _ = rr.Walk(func(record *types.Record) error { + return enc.Encode(record) + }) + }, nil +} + func (ctrl Record) makePayload(ctx context.Context, m *types.Module, r *types.Record, err error) (*recordPayload, error) { if err != nil || r == nil { return nil, err diff --git a/compose/rest/request/record.go b/compose/rest/request/record.go index 0b7473672..c29dc2f58 100644 --- a/compose/rest/request/record.go +++ b/compose/rest/request/record.go @@ -175,6 +175,81 @@ func (r *RecordList) Fill(req *http.Request) (err error) { var _ RequestFiller = NewRecordList() +// Record export request parameters +type RecordExport struct { + Filter string + Sort string + Download bool + Filename string + Ext string + NamespaceID uint64 `json:",string"` + ModuleID uint64 `json:",string"` +} + +func NewRecordExport() *RecordExport { + return &RecordExport{} +} + +func (r RecordExport) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + out["filter"] = r.Filter + out["sort"] = r.Sort + out["download"] = r.Download + out["filename"] = r.Filename + out["ext"] = r.Ext + out["namespaceID"] = r.NamespaceID + out["moduleID"] = r.ModuleID + + return out +} + +func (r *RecordExport) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["filter"]; ok { + r.Filter = val + } + if val, ok := get["sort"]; ok { + r.Sort = val + } + if val, ok := get["download"]; ok { + r.Download = parseBool(val) + } + r.Filename = chi.URLParam(req, "filename") + r.Ext = chi.URLParam(req, "ext") + r.NamespaceID = parseUInt64(chi.URLParam(req, "namespaceID")) + r.ModuleID = parseUInt64(chi.URLParam(req, "moduleID")) + + return err +} + +var _ RequestFiller = NewRecordExport() + // Record create request parameters type RecordCreate struct { Values types.RecordValueSet diff --git a/docs/compose/README.md b/docs/compose/README.md index f0f5e8d22..5275b6980 100644 --- a/docs/compose/README.md +++ b/docs/compose/README.md @@ -681,6 +681,7 @@ Compose records | ------ | -------- | ------- | | `GET` | `/namespace/{namespaceID}/module/{moduleID}/record/report` | Generates report from module records | | `GET` | `/namespace/{namespaceID}/module/{moduleID}/record/` | List/read records from module section | +| `GET` | `/namespace/{namespaceID}/module/{moduleID}/record/export{filename}.{ext}` | Exports records that match | | `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/` | Create record in module section | | `GET` | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}` | Read records by ID from module section | | `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}` | Update records in module section | @@ -724,6 +725,26 @@ Compose records | namespaceID | uint64 | PATH | Namespace ID | N/A | YES | | moduleID | uint64 | PATH | Module ID | N/A | YES | +## Exports records that match + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/namespace/{namespaceID}/module/{moduleID}/record/export{filename}.{ext}` | HTTP/S | GET | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| filter | string | GET | Filtering condition | N/A | NO | +| sort | string | GET | Sort field (default id desc) | N/A | NO | +| download | bool | GET | Send headers to browser to trigger download/save-as | N/A | NO | +| filename | string | PATH | Filename to use | N/A | NO | +| ext | string | PATH | Export format | N/A | YES | +| namespaceID | uint64 | PATH | Namespace ID | N/A | YES | +| moduleID | uint64 | PATH | Module ID | N/A | YES | + ## Create record in module section #### Method