3
0

Support attachment management in workflow

This commit is contained in:
Denis Arh
2021-11-29 21:11:15 +01:00
parent f2bec9b390
commit b9f96d920a
12 changed files with 1025 additions and 6 deletions

View File

@@ -0,0 +1,461 @@
package automation
// This file is auto-generated.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
//
// Definitions file that controls how this file is generated:
// compose/automation/attachment_handler.yaml
import (
"context"
atypes "github.com/cortezaproject/corteza-server/automation/types"
"github.com/cortezaproject/corteza-server/compose/types"
"github.com/cortezaproject/corteza-server/pkg/expr"
"github.com/cortezaproject/corteza-server/pkg/wfexec"
"io"
)
var _ wfexec.ExecResponse
type (
attachmentHandlerRegistry interface {
AddFunctions(ff ...*atypes.Function)
Type(ref string) expr.Type
}
)
func (h attachmentHandler) register() {
h.reg.AddFunctions(
h.Lookup(),
h.Create(),
h.Delete(),
h.OpenOriginal(),
h.OpenPreview(),
)
}
type (
attachmentLookupArgs struct {
hasAttachment bool
Attachment uint64
}
attachmentLookupResults struct {
Attachment *types.Attachment
}
)
// Lookup function Compose record lookup
//
// expects implementation of lookup function:
// func (h attachmentHandler) lookup(ctx context.Context, args *attachmentLookupArgs) (results *attachmentLookupResults, err error) {
// return
// }
func (h attachmentHandler) Lookup() *atypes.Function {
return &atypes.Function{
Ref: "attachmentLookup",
Kind: "function",
Labels: map[string]string{"attachment": "step,workflow"},
Meta: &atypes.FunctionMeta{
Short: "Compose record lookup",
Description: "Find specific record by ID",
},
Parameters: []*atypes.Param{
{
Name: "attachment",
Types: []string{"ID"}, Required: true,
},
},
Results: []*atypes.Param{
{
Name: "attachment",
Types: []string{"Attachment"},
},
},
Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) {
var (
args = &attachmentLookupArgs{
hasAttachment: in.Has("attachment"),
}
)
if err = in.Decode(args); err != nil {
return
}
var results *attachmentLookupResults
if results, err = h.lookup(ctx, args); err != nil {
return
}
out = &expr.Vars{}
{
// converting results.Attachment (*types.Attachment) to Attachment
var (
tval expr.TypedValue
)
if tval, err = h.reg.Type("Attachment").Cast(results.Attachment); err != nil {
return
} else if err = expr.Assign(out, "attachment", tval); err != nil {
return
}
}
return
},
}
}
type (
attachmentCreateArgs struct {
hasName bool
Name string
hasResource bool
Resource *types.Record
hasContent bool
Content interface{}
contentString string
contentStream io.Reader
contentBytes []byte
}
attachmentCreateResults struct {
Attachment *types.Attachment
}
)
func (a attachmentCreateArgs) GetContent() (bool, string, io.Reader, []byte) {
return a.hasContent, a.contentString, a.contentStream, a.contentBytes
}
// Create function Create file and attach it to a resource
//
// expects implementation of create function:
// func (h attachmentHandler) create(ctx context.Context, args *attachmentCreateArgs) (results *attachmentCreateResults, err error) {
// return
// }
func (h attachmentHandler) Create() *atypes.Function {
return &atypes.Function{
Ref: "attachmentCreate",
Kind: "function",
Labels: map[string]string{"attachment": "step,workflow", "create": "step"},
Meta: &atypes.FunctionMeta{
Short: "Create file and attach it to a resource",
},
Parameters: []*atypes.Param{
{
Name: "name",
Types: []string{"String"},
},
{
Name: "resource",
Types: []string{"ComposeRecord"}, Required: true,
},
{
Name: "content",
Types: []string{"String", "Reader", "Bytes"}, Required: true,
},
},
Results: []*atypes.Param{
{
Name: "attachment",
Types: []string{"Attachment"},
},
},
Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) {
var (
args = &attachmentCreateArgs{
hasName: in.Has("name"),
hasResource: in.Has("resource"),
hasContent: in.Has("content"),
}
)
if err = in.Decode(args); err != nil {
return
}
// Converting Content argument
if args.hasContent {
aux := expr.Must(expr.Select(in, "content"))
switch aux.Type() {
case h.reg.Type("String").Type():
args.contentString = aux.Get().(string)
case h.reg.Type("Reader").Type():
args.contentStream = aux.Get().(io.Reader)
case h.reg.Type("Bytes").Type():
args.contentBytes = aux.Get().([]byte)
}
}
var results *attachmentCreateResults
if results, err = h.create(ctx, args); err != nil {
return
}
out = &expr.Vars{}
{
// converting results.Attachment (*types.Attachment) to Attachment
var (
tval expr.TypedValue
)
if tval, err = h.reg.Type("Attachment").Cast(results.Attachment); err != nil {
return
} else if err = expr.Assign(out, "attachment", tval); err != nil {
return
}
}
return
},
}
}
type (
attachmentDeleteArgs struct {
hasAttachment bool
Attachment uint64
}
)
// Delete function Delete attachment
//
// expects implementation of delete function:
// func (h attachmentHandler) delete(ctx context.Context, args *attachmentDeleteArgs) (err error) {
// return
// }
func (h attachmentHandler) Delete() *atypes.Function {
return &atypes.Function{
Ref: "attachmentDelete",
Kind: "function",
Labels: map[string]string{"attachment": "step,workflow", "delete": "step"},
Meta: &atypes.FunctionMeta{
Short: "Delete attachment",
},
Parameters: []*atypes.Param{
{
Name: "attachment",
Types: []string{"ID"}, Required: true,
},
},
Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) {
var (
args = &attachmentDeleteArgs{
hasAttachment: in.Has("attachment"),
}
)
if err = in.Decode(args); err != nil {
return
}
return out, h.delete(ctx, args)
},
}
}
type (
attachmentOpenOriginalArgs struct {
hasAttachment bool
Attachment interface{}
attachmentID uint64
attachmentAttachment *types.Attachment
}
attachmentOpenOriginalResults struct {
Content io.Reader
}
)
func (a attachmentOpenOriginalArgs) GetAttachment() (bool, uint64, *types.Attachment) {
return a.hasAttachment, a.attachmentID, a.attachmentAttachment
}
// OpenOriginal function Open original attachment
//
// expects implementation of openOriginal function:
// func (h attachmentHandler) openOriginal(ctx context.Context, args *attachmentOpenOriginalArgs) (results *attachmentOpenOriginalResults, err error) {
// return
// }
func (h attachmentHandler) OpenOriginal() *atypes.Function {
return &atypes.Function{
Ref: "attachmentOpenOriginal",
Kind: "function",
Labels: map[string]string{"attachment": "step,workflow", "original-attachment": "step"},
Meta: &atypes.FunctionMeta{
Short: "Open original attachment",
},
Parameters: []*atypes.Param{
{
Name: "attachment",
Types: []string{"ID", "Attachment"}, Required: true,
},
},
Results: []*atypes.Param{
{
Name: "content",
Types: []string{"Reader"},
},
},
Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) {
var (
args = &attachmentOpenOriginalArgs{
hasAttachment: in.Has("attachment"),
}
)
if err = in.Decode(args); err != nil {
return
}
// Converting Attachment argument
if args.hasAttachment {
aux := expr.Must(expr.Select(in, "attachment"))
switch aux.Type() {
case h.reg.Type("ID").Type():
args.attachmentID = aux.Get().(uint64)
case h.reg.Type("Attachment").Type():
args.attachmentAttachment = aux.Get().(*types.Attachment)
}
}
var results *attachmentOpenOriginalResults
if results, err = h.openOriginal(ctx, args); err != nil {
return
}
out = &expr.Vars{}
{
// converting results.Content (io.Reader) to Reader
var (
tval expr.TypedValue
)
if tval, err = h.reg.Type("Reader").Cast(results.Content); err != nil {
return
} else if err = expr.Assign(out, "content", tval); err != nil {
return
}
}
return
},
}
}
type (
attachmentOpenPreviewArgs struct {
hasAttachment bool
Attachment interface{}
attachmentID uint64
attachmentAttachment *types.Attachment
}
attachmentOpenPreviewResults struct {
Content io.Reader
}
)
func (a attachmentOpenPreviewArgs) GetAttachment() (bool, uint64, *types.Attachment) {
return a.hasAttachment, a.attachmentID, a.attachmentAttachment
}
// OpenPreview function Open attachment preview
//
// expects implementation of openPreview function:
// func (h attachmentHandler) openPreview(ctx context.Context, args *attachmentOpenPreviewArgs) (results *attachmentOpenPreviewResults, err error) {
// return
// }
func (h attachmentHandler) OpenPreview() *atypes.Function {
return &atypes.Function{
Ref: "attachmentOpenPreview",
Kind: "function",
Labels: map[string]string{"attachment": "step,workflow", "preview-attachment": "step"},
Meta: &atypes.FunctionMeta{
Short: "Open attachment preview",
},
Parameters: []*atypes.Param{
{
Name: "attachment",
Types: []string{"ID", "Attachment"}, Required: true,
},
},
Results: []*atypes.Param{
{
Name: "content",
Types: []string{"Reader"},
},
},
Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) {
var (
args = &attachmentOpenPreviewArgs{
hasAttachment: in.Has("attachment"),
}
)
if err = in.Decode(args); err != nil {
return
}
// Converting Attachment argument
if args.hasAttachment {
aux := expr.Must(expr.Select(in, "attachment"))
switch aux.Type() {
case h.reg.Type("ID").Type():
args.attachmentID = aux.Get().(uint64)
case h.reg.Type("Attachment").Type():
args.attachmentAttachment = aux.Get().(*types.Attachment)
}
}
var results *attachmentOpenPreviewResults
if results, err = h.openPreview(ctx, args); err != nil {
return
}
out = &expr.Vars{}
{
// converting results.Content (io.Reader) to Reader
var (
tval expr.TypedValue
)
if tval, err = h.reg.Type("Reader").Cast(results.Content); err != nil {
return
} else if err = expr.Assign(out, "content", tval); err != nil {
return
}
}
return
},
}
}

View File

@@ -0,0 +1,139 @@
package automation
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"github.com/cortezaproject/corteza-server/compose/types"
)
type (
attachmentService interface {
FindByID(ctx context.Context, namespaceID, attachmentID uint64) (*types.Attachment, error)
CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64) (att *types.Attachment, err error)
DeleteByID(ctx context.Context, namespaceID uint64, attachmentID uint64) error
OpenOriginal(att *types.Attachment) (io.ReadSeeker, error)
OpenPreview(att *types.Attachment) (io.ReadSeeker, error)
}
attachmentHandler struct {
reg modulesHandlerRegistry
svc attachmentService
}
attachmentLookup interface {
GetAttachment() (bool, uint64, *types.Attachment)
}
)
func AttachmentHandler(reg modulesHandlerRegistry, svc attachmentService) *attachmentHandler {
h := &attachmentHandler{
reg: reg,
svc: svc,
}
h.register()
return h
}
func (h attachmentHandler) lookup(ctx context.Context, args *attachmentLookupArgs) (results *attachmentLookupResults, err error) {
results = &attachmentLookupResults{}
results.Attachment, err = h.svc.FindByID(ctx, 0, args.Attachment)
return
}
func (h attachmentHandler) delete(ctx context.Context, args *attachmentDeleteArgs) error {
return h.svc.DeleteByID(ctx, 0, args.Attachment)
}
func (h attachmentHandler) openOriginal(ctx context.Context, args *attachmentOpenOriginalArgs) (*attachmentOpenOriginalResults, error) {
att, err := lookupAttachment(ctx, h.svc, args)
if err != nil {
return nil, err
}
r := &attachmentOpenOriginalResults{}
r.Content, err = h.svc.OpenOriginal(att)
if err != nil {
return nil, err
}
return r, nil
}
func (h attachmentHandler) openPreview(ctx context.Context, args *attachmentOpenPreviewArgs) (*attachmentOpenPreviewResults, error) {
att, err := lookupAttachment(ctx, h.svc, args)
if err != nil {
return nil, err
}
r := &attachmentOpenPreviewResults{}
r.Content, err = h.svc.OpenOriginal(att)
if err != nil {
return nil, err
}
return r, nil
}
func (h attachmentHandler) create(ctx context.Context, args *attachmentCreateArgs) (*attachmentCreateResults, error) {
var (
err error
att *types.Attachment
fh io.ReadSeeker
size int64
)
switch {
case len(args.contentBytes) > 0:
size = int64(len(args.contentString))
fh = bytes.NewReader(args.contentBytes)
case args.contentStream != nil:
if rs, is := args.contentStream.(io.ReadSeeker); is {
fh = rs
}
default:
fh = strings.NewReader(args.contentString)
size = int64(len(args.contentString))
}
switch {
case args.Resource != nil:
att, err = h.svc.CreateRecordAttachment(
ctx,
args.Resource.NamespaceID,
args.Name,
size,
fh,
args.Resource.ModuleID,
args.Resource.ID,
)
default:
return nil, fmt.Errorf("unknown resource")
}
if err != nil {
return nil, err
}
return &attachmentCreateResults{att}, nil
}
func lookupAttachment(ctx context.Context, svc attachmentService, args attachmentLookup) (*types.Attachment, error) {
_, ID, attachment := args.GetAttachment()
switch {
case attachment != nil:
return attachment, nil
case ID > 0:
return svc.FindByID(ctx, 0, ID)
}
return nil, fmt.Errorf("empty attachment lookup params")
}

View File

@@ -0,0 +1,87 @@
# Note: we're intentionally not prefixing this with "compose"
# due to plans in the (near) future to refactor attachments
# from system & compose components into one single service
imports:
- github.com/cortezaproject/corteza-server/compose/types
- io
labels: &labels
attachment: "step,workflow"
functions:
lookup:
meta:
short: Compose record lookup
description: Find specific record by ID
labels:
<<: *labels
params:
attachment:
required: true
types: [ { wf: ID } ]
results:
attachment: { wf: Attachment }
create:
meta:
short: Create file and attach it to a resource
labels:
<<: *labels
create: "step"
params:
name:
types:
- { wf: String }
resource:
required: true
types:
- { wf: ComposeRecord }
content:
required: true
types:
- { wf: String, suffix: String }
- { wf: Reader, suffix: Stream }
- { wf: Bytes, suffix: Bytes }
results:
attachment: { wf: Attachment }
delete:
meta:
short: Delete attachment
labels:
<<: *labels
delete: "step"
params:
attachment:
required: true
types: [ { wf: ID } ]
openOriginal:
meta:
short: Open original attachment
labels:
<<: *labels
original-attachment: "step"
params:
attachment:
required: true
types: [ { wf: ID }, { wf: Attachment } ]
results:
content: { wf: Reader }
openPreview:
meta:
short: Open attachment preview
labels:
<<: *labels
preview-attachment: "step"
params:
attachment:
required: true
types: [ { wf: ID }, { wf: Attachment } ]
results:
content: { wf: Reader }

View File

@@ -19,6 +19,207 @@ import (
var _ = context.Background
var _ = fmt.Errorf
// Attachment is an expression type, wrapper for *types.Attachment type
type Attachment struct {
value *types.Attachment
mux sync.RWMutex
}
// NewAttachment creates new instance of Attachment expression type
func NewAttachment(val interface{}) (*Attachment, error) {
if c, err := CastToAttachment(val); err != nil {
return nil, fmt.Errorf("unable to create Attachment: %w", err)
} else {
return &Attachment{value: c}, nil
}
}
// Get return underlying value on Attachment
func (t *Attachment) Get() interface{} {
t.mux.RLock()
defer t.mux.RUnlock()
return t.value
}
// GetValue returns underlying value on Attachment
func (t *Attachment) GetValue() *types.Attachment {
t.mux.RLock()
defer t.mux.RUnlock()
return t.value
}
// Type return type name
func (Attachment) Type() string { return "Attachment" }
// Cast converts value to *types.Attachment
func (Attachment) Cast(val interface{}) (TypedValue, error) {
return NewAttachment(val)
}
// Assign new value to Attachment
//
// value is first passed through CastToAttachment
func (t *Attachment) Assign(val interface{}) error {
if c, err := CastToAttachment(val); err != nil {
return err
} else {
t.value = c
return nil
}
}
func (t *Attachment) AssignFieldValue(key string, val TypedValue) error {
t.mux.Lock()
defer t.mux.Unlock()
return assignToAttachment(t.value, key, val)
}
// SelectGVal implements gval.Selector requirements
//
// It allows gval lib to access Attachment's underlying value (*types.Attachment)
// and it's fields
//
func (t *Attachment) SelectGVal(ctx context.Context, k string) (interface{}, error) {
t.mux.RLock()
defer t.mux.RUnlock()
return attachmentGValSelector(t.value, k)
}
// Select is field accessor for *types.Attachment
//
// Similar to SelectGVal but returns typed values
func (t *Attachment) Select(k string) (TypedValue, error) {
t.mux.RLock()
defer t.mux.RUnlock()
return attachmentTypedValueSelector(t.value, k)
}
func (t *Attachment) Has(k string) bool {
t.mux.RLock()
defer t.mux.RUnlock()
switch k {
case "ID":
return true
case "kind":
return true
case "url":
return true
case "previewUrl":
return true
case "name":
return true
case "createdAt":
return true
case "updatedAt":
return true
case "deletedAt":
return true
}
return false
}
// attachmentGValSelector is field accessor for *types.Attachment
func attachmentGValSelector(res *types.Attachment, k string) (interface{}, error) {
if res == nil {
return nil, nil
}
switch k {
case "ID":
return res.ID, nil
case "kind":
return res.Kind, nil
case "url":
return res.Url, nil
case "previewUrl":
return res.PreviewUrl, nil
case "name":
return res.Name, nil
case "createdAt":
return res.CreatedAt, nil
case "updatedAt":
return res.UpdatedAt, nil
case "deletedAt":
return res.DeletedAt, nil
}
return nil, fmt.Errorf("unknown field '%s'", k)
}
// attachmentTypedValueSelector is field accessor for *types.Attachment
func attachmentTypedValueSelector(res *types.Attachment, k string) (TypedValue, error) {
if res == nil {
return nil, nil
}
switch k {
case "ID":
return NewID(res.ID)
case "kind":
return NewString(res.Kind)
case "url":
return NewHandle(res.Url)
case "previewUrl":
return NewHandle(res.PreviewUrl)
case "name":
return NewHandle(res.Name)
case "createdAt":
return NewDateTime(res.CreatedAt)
case "updatedAt":
return NewDateTime(res.UpdatedAt)
case "deletedAt":
return NewDateTime(res.DeletedAt)
}
return nil, fmt.Errorf("unknown field '%s'", k)
}
// assignToAttachment is field value setter for *types.Attachment
func assignToAttachment(res *types.Attachment, k string, val interface{}) error {
switch k {
case "ID":
return fmt.Errorf("field '%s' is read-only", k)
case "kind":
aux, err := CastToString(val)
if err != nil {
return err
}
res.Kind = aux
return nil
case "url":
aux, err := CastToHandle(val)
if err != nil {
return err
}
res.Url = aux
return nil
case "previewUrl":
aux, err := CastToHandle(val)
if err != nil {
return err
}
res.PreviewUrl = aux
return nil
case "name":
aux, err := CastToHandle(val)
if err != nil {
return err
}
res.Name = aux
return nil
case "createdAt":
return fmt.Errorf("field '%s' is read-only", k)
case "updatedAt":
return fmt.Errorf("field '%s' is read-only", k)
case "deletedAt":
return fmt.Errorf("field '%s' is read-only", k)
}
return fmt.Errorf("unknown field '%s'", k)
}
// ComposeModule is an expression type, wrapper for *types.Module type
type ComposeModule struct {
value *types.Module

View File

@@ -509,3 +509,12 @@ func isNil(i interface{}) bool {
return false
}
func CastToAttachment(val interface{}) (out *types.Attachment, err error) {
switch val := expr.UntypedValue(val).(type) {
case *types.Attachment:
return val, nil
default:
return nil, fmt.Errorf("unable to cast type %T to %T", val, out)
}
}

View File

@@ -51,8 +51,14 @@ types:
ComposeRecordValueErrorSet:
as: '*types.RecordValueErrorSet'
#
# Page:
# as: '*types.Page'
# Chart:
# as: '*types.Chart'
Attachment:
as: '*types.Attachment'
struct:
- { name: 'ID', exprType: 'ID', goType: 'uint64', mode: ro}
- { name: 'kind', exprType: 'String', goType: 'string' }
- { name: 'url', exprType: 'Handle', goType: 'string' }
- { name: 'previewUrl', exprType: 'Handle', goType: 'string' }
- { name: 'name', exprType: 'Handle', goType: 'string' }
- { name: 'createdAt', exprType: 'DateTime', goType: 'time.Time', mode: ro }
- { name: 'updatedAt', exprType: 'DateTime', goType: '*time.Time', mode: ro }
- { name: 'deletedAt', exprType: 'DateTime', goType: '*time.Time', mode: ro }

View File

@@ -157,6 +157,7 @@ func Initialize(ctx context.Context, log *zap.Logger, s store.Storer, c Config)
automation.ComposeModule{},
automation.ComposeRecord{},
automation.ComposeRecordValues{},
automation.Attachment{},
)
automation.RecordsHandler(
@@ -177,6 +178,11 @@ func Initialize(ctx context.Context, log *zap.Logger, s store.Storer, c Config)
DefaultNamespace,
)
automation.AttachmentHandler(
automationService.Registry(),
DefaultAttachment,
)
// Register reporters
// @todo additional datasource providers; generate?
systemService.DefaultReport.RegisterReporter("composeRecords", DefaultRecord)

1
tests/.gitignore vendored
View File

@@ -1 +1,2 @@
.env
workflows/var

View File

@@ -0,0 +1,43 @@
package workflows
import (
"context"
"io"
"io/ioutil"
"testing"
"github.com/cortezaproject/corteza-server/automation/types"
cmpTypes "github.com/cortezaproject/corteza-server/compose/types"
"github.com/stretchr/testify/require"
)
func Test_attachment_management(t *testing.T) {
var (
ctx = bypassRBAC(context.Background())
req = require.New(t)
)
req.NoError(defStore.TruncateAttachments(ctx))
loadNewScenario(ctx, t)
var (
aux = struct {
Base64blackGif string
LoadedAttBlackGif *cmpTypes.Attachment
StoredAttBlackGif *cmpTypes.Attachment
LoadedContent io.ReadSeeker
}{}
)
vars, _ := mustExecWorkflow(ctx, t, "attachments", types.WorkflowExecParams{})
req.NoError(vars.Decode(&aux))
req.Equal(int(aux.LoadedAttBlackGif.Meta.Original.Size), len(aux.Base64blackGif))
req.Equal(int(aux.StoredAttBlackGif.Meta.Original.Size), len(aux.Base64blackGif))
b, err := ioutil.ReadAll(aux.LoadedContent)
req.NoError(err)
req.Equal(aux.Base64blackGif, string(b))
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/cortezaproject/corteza-server/pkg/envoy/json"
envoyStore "github.com/cortezaproject/corteza-server/pkg/envoy/store"
"github.com/cortezaproject/corteza-server/pkg/envoy/yaml"
"github.com/cortezaproject/corteza-server/pkg/errors"
"github.com/cortezaproject/corteza-server/pkg/eventbus"
"github.com/cortezaproject/corteza-server/pkg/expr"
"github.com/cortezaproject/corteza-server/pkg/id"

View File

@@ -0,0 +1,9 @@
namespaces:
ns1:
name: Namespace#1
modules:
mod1:
name: Module#1
fields:
f1: { label: 'Field1' }

View File

@@ -0,0 +1,58 @@
workflows:
attachments:
enabled: true
trace: true
triggers:
- enabled: true
stepID: 10
steps:
- stepID: 10
kind: expressions
arguments:
- { target: base64blackGif, value: "R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=" }
- stepID: 11
kind: function
ref: composeRecordsNew
arguments:
- { target: module, type: Handle, value: "mod1" }
- { target: namespace, type: Handle, value: "ns1" }
results:
- { target: attachable, expr: "record" }
# Store attachment from given string
- stepID: 20
kind: function
ref: attachmentCreate
arguments:
- { target: content, type: String, expr: "base64blackGif" }
- { target: name, type: String, value: "black-from-base64-string.gif" }
- { target: resource, type: ComposeRecord, expr: "attachable" }
results:
- { target: storedAttBlackGif, expr: "attachment" }
# Load attachment
- stepID: 30
kind: function
ref: attachmentLookup
arguments:
- { target: attachment, type: ID, expr: "storedAttBlackGif.ID" }
results:
- { target: loadedAttBlackGif, expr: "attachment" }
# Open attachment
- stepID: 31
kind: function
ref: attachmentOpenOriginal
arguments:
- { target: attachment, type: "Attachment", expr: "storedAttBlackGif" }
results:
- { target: loadedContent, expr: "content" }
paths:
- { parentID: 10, childID: 11 }
- { parentID: 11, childID: 20 }
- { parentID: 20, childID: 30 }
- { parentID: 30, childID: 31 }