Commit cb9682e4 authored by Denis Arh's avatar Denis Arh
Browse files

Merge branch 'feature-corredor-iterators' into develop

parents 314cf76f 2fac997b
......@@ -1073,6 +1073,22 @@
}
]
}
},
{
"name": "triggerScriptOnList",
"method": "POST",
"title": "Fire compose:record trigger",
"path": "/trigger",
"parameters": {
"post": [
{
"name": "script",
"type": "string",
"title": "Script to execute",
"required": true
}
]
}
}
]
},
......
......@@ -364,6 +364,22 @@
}
]
}
},
{
"Name": "triggerScriptOnList",
"Method": "POST",
"Title": "Fire compose:record trigger",
"Path": "/trigger",
"Parameters": {
"post": [
{
"name": "script",
"required": true,
"title": "Script to execute",
"type": "string"
}
]
}
}
]
}
\ No newline at end of file
......@@ -48,5 +48,5 @@ func (ctrl *Automation) Bundle(ctx context.Context, r *request.AutomationBundle)
}
func (ctrl *Automation) TriggerScript(ctx context.Context, r *request.AutomationTriggerScript) (interface{}, error) {
return resputil.OK(), corredor.Service().ExecOnManual(ctx, r.Script, event.ComposeOnManual())
return resputil.OK(), corredor.Service().Exec(ctx, r.Script, event.ComposeOnManual())
}
......@@ -43,24 +43,26 @@ type RecordAPI interface {
Delete(context.Context, *request.RecordDelete) (interface{}, error)
Upload(context.Context, *request.RecordUpload) (interface{}, error)
TriggerScript(context.Context, *request.RecordTriggerScript) (interface{}, error)
TriggerScriptOnList(context.Context, *request.RecordTriggerScriptOnList) (interface{}, error)
}
// HTTP API interface
type Record struct {
Report func(http.ResponseWriter, *http.Request)
List func(http.ResponseWriter, *http.Request)
ImportInit func(http.ResponseWriter, *http.Request)
ImportRun func(http.ResponseWriter, *http.Request)
ImportProgress func(http.ResponseWriter, *http.Request)
Export func(http.ResponseWriter, *http.Request)
Exec func(http.ResponseWriter, *http.Request)
Create func(http.ResponseWriter, *http.Request)
Read func(http.ResponseWriter, *http.Request)
Update func(http.ResponseWriter, *http.Request)
BulkDelete func(http.ResponseWriter, *http.Request)
Delete func(http.ResponseWriter, *http.Request)
Upload func(http.ResponseWriter, *http.Request)
TriggerScript func(http.ResponseWriter, *http.Request)
Report func(http.ResponseWriter, *http.Request)
List func(http.ResponseWriter, *http.Request)
ImportInit func(http.ResponseWriter, *http.Request)
ImportRun func(http.ResponseWriter, *http.Request)
ImportProgress func(http.ResponseWriter, *http.Request)
Export func(http.ResponseWriter, *http.Request)
Exec func(http.ResponseWriter, *http.Request)
Create func(http.ResponseWriter, *http.Request)
Read func(http.ResponseWriter, *http.Request)
Update func(http.ResponseWriter, *http.Request)
BulkDelete func(http.ResponseWriter, *http.Request)
Delete func(http.ResponseWriter, *http.Request)
Upload func(http.ResponseWriter, *http.Request)
TriggerScript func(http.ResponseWriter, *http.Request)
TriggerScriptOnList func(http.ResponseWriter, *http.Request)
}
func NewRecord(h RecordAPI) *Record {
......@@ -345,6 +347,26 @@ func NewRecord(h RecordAPI) *Record {
resputil.JSON(w, value)
}
},
TriggerScriptOnList: func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
params := request.NewRecordTriggerScriptOnList()
if err := params.Fill(r); err != nil {
logger.LogParamError("Record.TriggerScriptOnList", r, err)
resputil.JSON(w, err)
return
}
value, err := h.TriggerScriptOnList(r.Context(), params)
if err != nil {
logger.LogControllerError("Record.TriggerScriptOnList", r, err, params.Auditable())
resputil.JSON(w, err)
return
}
logger.LogControllerCall("Record.TriggerScriptOnList", r, params.Auditable())
if !serveHTTP(value, w, r) {
resputil.JSON(w, value)
}
},
}
}
......@@ -365,5 +387,6 @@ func (h Record) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http
r.Delete("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}", h.Delete)
r.Post("/namespace/{namespaceID}/module/{moduleID}/record/attachment", h.Upload)
r.Post("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}/trigger", h.TriggerScript)
r.Post("/namespace/{namespaceID}/module/{moduleID}/record/trigger", h.TriggerScriptOnList)
})
}
......@@ -151,7 +151,7 @@ func (ctrl *Module) TriggerScript(ctx context.Context, r *request.ModuleTriggerS
}
// @todo implement same behaviour as we have on record - module+oldModule
err = corredor.Service().ExecOnManual(ctx, r.Script, event.ModuleOnManual(module, module, namespace))
err = corredor.Service().Exec(ctx, r.Script, event.ModuleOnManual(module, module, namespace))
return ctrl.makePayload(ctx, module, err)
}
......
......@@ -131,7 +131,7 @@ func (ctrl *Namespace) TriggerScript(ctx context.Context, r *request.NamespaceTr
return
}
err = corredor.Service().ExecOnManual(ctx, r.Script, event.NamespaceOnManual(namespace, nil))
err = corredor.Service().Exec(ctx, r.Script, event.NamespaceOnManual(namespace, nil))
return ctrl.makePayload(ctx, namespace, err)
}
......
......@@ -172,7 +172,7 @@ func (ctrl *Page) TriggerScript(ctx context.Context, r *request.PageTriggerScrip
}
// @todo implement same behaviour as we have on record - page+oldPage
err = corredor.Service().ExecOnManual(ctx, r.Script, event.PageOnManual(page, page, namespace))
err = corredor.Service().Exec(ctx, r.Script, event.PageOnManual(page, page, namespace))
return ctrl.makePayload(ctx, page, err)
}
......
......@@ -426,7 +426,7 @@ func (ctrl *Record) TriggerScript(ctx context.Context, r *request.RecordTriggerS
record.Values = values.Sanitizer().Run(module, r.Values)
validated := values.Validator().Run(module, record)
err = corredor.Service().ExecOnManual(
err = corredor.Service().Exec(
ctx,
r.Script,
event.RecordOnManual(record, oldRecord, module, namespace, validated),
......@@ -436,6 +436,27 @@ func (ctrl *Record) TriggerScript(ctx context.Context, r *request.RecordTriggerS
return ctrl.makePayload(ctx, module, record, err)
}
func (ctrl *Record) TriggerScriptOnList(ctx context.Context, r *request.RecordTriggerScriptOnList) (rsp interface{}, err error) {
//var (
// module *types.Module
// namespace *types.Namespace
//)
//
//if module, err = ctrl.module.With(ctx).FindByID(r.NamespaceID, r.ModuleID); err != nil {
// return
//}
//
//if namespace, err = ctrl.namespace.With(ctx).FindByID(r.NamespaceID); err != nil {
// return
//}
// @todo this does not need to be under /record ... where then?!?!
err = corredor.Service().ExecIterator(ctx, r.Script)
// Script can return modified record and we'll pass it on to the caller
return resputil.OK(), err
}
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
......
......@@ -923,3 +923,62 @@ func (r *RecordTriggerScript) Fill(req *http.Request) (err error) {
}
var _ RequestFiller = NewRecordTriggerScript()
// Record triggerScriptOnList request parameters
type RecordTriggerScriptOnList struct {
Script string
NamespaceID uint64 `json:",string"`
ModuleID uint64 `json:",string"`
}
func NewRecordTriggerScriptOnList() *RecordTriggerScriptOnList {
return &RecordTriggerScriptOnList{}
}
func (r RecordTriggerScriptOnList) Auditable() map[string]interface{} {
var out = map[string]interface{}{}
out["script"] = r.Script
out["namespaceID"] = r.NamespaceID
out["moduleID"] = r.ModuleID
return out
}
func (r *RecordTriggerScriptOnList) 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 := post["script"]; ok {
r.Script = val
}
r.NamespaceID = parseUInt64(chi.URLParam(req, "namespaceID"))
r.ModuleID = parseUInt64(chi.URLParam(req, "moduleID"))
return err
}
var _ RequestFiller = NewRecordTriggerScriptOnList()
......@@ -38,7 +38,7 @@ compose:module:
immutable: true
compose:record:
on: ['manual']
on: ['manual', 'iteration']
ba: ['create', 'update', 'delete']
props:
- name: 'record'
......
......@@ -7,11 +7,14 @@ import (
// Match returns false if given conditions do not match event & resource internals
func (res moduleBase) Match(c eventbus.ConstraintMatcher) bool {
return namespaceMatch(res.namespace, c, moduleMatch(res.module, c, false))
return eventbus.MatchFirst(
func() bool { return moduleMatch(res.module, c) },
func() bool { return namespaceMatch(res.namespace, c) },
)
}
// Handles module matchers
func moduleMatch(r *types.Module, c eventbus.ConstraintMatcher, def bool) bool {
func moduleMatch(r *types.Module, c eventbus.ConstraintMatcher) bool {
switch c.Name() {
case "module", "module.handle":
return c.Match(r.Handle)
......@@ -19,5 +22,5 @@ func moduleMatch(r *types.Module, c eventbus.ConstraintMatcher, def bool) bool {
return c.Match(r.Name)
}
return def
return false
}
......@@ -7,11 +7,11 @@ import (
// Match returns false if given conditions do not match event & resource internals
func (res namespaceBase) Match(c eventbus.ConstraintMatcher) bool {
return namespaceMatch(res.namespace, c, false)
return namespaceMatch(res.namespace, c)
}
// Handles namespace matchers
func namespaceMatch(r *types.Namespace, c eventbus.ConstraintMatcher, def bool) bool {
func namespaceMatch(r *types.Namespace, c eventbus.ConstraintMatcher) bool {
switch c.Name() {
case "namespace", "namespace.slug":
return c.Match(r.Slug)
......@@ -19,5 +19,5 @@ func namespaceMatch(r *types.Namespace, c eventbus.ConstraintMatcher, def bool)
return c.Match(r.Name)
}
return def
return false
}
......@@ -7,11 +7,14 @@ import (
// Match returns false if given conditions do not match event & resource internals
func (res pageBase) Match(c eventbus.ConstraintMatcher) bool {
return namespaceMatch(res.namespace, c, pageMatch(res.page, c, false))
return eventbus.MatchFirst(
func() bool { return pageMatch(res.page, c) },
func() bool { return namespaceMatch(res.namespace, c) },
)
}
// Handles namespace matchers
func pageMatch(r *types.Page, c eventbus.ConstraintMatcher, def bool) bool {
func pageMatch(r *types.Page, c eventbus.ConstraintMatcher) bool {
switch c.Name() {
case "page", "page.handle":
return c.Match(r.Handle)
......@@ -19,5 +22,5 @@ func pageMatch(r *types.Page, c eventbus.ConstraintMatcher, def bool) bool {
return c.Match(r.Title)
}
return def
return false
}
......@@ -38,6 +38,13 @@ type (
*recordBase
}
// recordOnIteration
//
// This type is auto-generated.
recordOnIteration struct {
*recordBase
}
// recordBeforeCreate
//
// This type is auto-generated.
......@@ -95,6 +102,13 @@ func (recordOnManual) EventType() string {
return "onManual"
}
// EventType on recordOnIteration returns "onIteration"
//
// This function is auto-generated.
func (recordOnIteration) EventType() string {
return "onIteration"
}
// EventType on recordBeforeCreate returns "beforeCreate"
//
// This function is auto-generated.
......@@ -183,6 +197,52 @@ func RecordOnManualImmutable(
}
}
// RecordOnIteration creates onIteration for compose:record resource
//
// This function is auto-generated.
func RecordOnIteration(
argRecord *types.Record,
argOldRecord *types.Record,
argModule *types.Module,
argNamespace *types.Namespace,
argRecordValueErrors *types.RecordValueErrorSet,
) *recordOnIteration {
return &recordOnIteration{
recordBase: &recordBase{
immutable: false,
record: argRecord,
oldRecord: argOldRecord,
module: argModule,
namespace: argNamespace,
recordValueErrors: argRecordValueErrors,
},
}
}
// RecordOnIterationImmutable creates onIteration for compose:record resource
//
// None of the arguments will be mutable!
//
// This function is auto-generated.
func RecordOnIterationImmutable(
argRecord *types.Record,
argOldRecord *types.Record,
argModule *types.Module,
argNamespace *types.Namespace,
argRecordValueErrors *types.RecordValueErrorSet,
) *recordOnIteration {
return &recordOnIteration{
recordBase: &recordBase{
immutable: true,
record: argRecord,
oldRecord: argOldRecord,
module: argModule,
namespace: argNamespace,
recordValueErrors: argRecordValueErrors,
},
}
}
// RecordBeforeCreate creates beforeCreate for compose:record resource
//
// This function is auto-generated.
......
......@@ -13,10 +13,14 @@ const (
// Match returns false if given conditions do not match event & resource internals
func (res recordBase) Match(c eventbus.ConstraintMatcher) bool {
return recordMatch(res.record, c, namespaceMatch(res.namespace, c, moduleMatch(res.module, c, false)))
return eventbus.MatchFirst(
func() bool { return recordMatch(res.record, c) },
func() bool { return moduleMatch(res.module, c) },
func() bool { return namespaceMatch(res.namespace, c) },
)
}
func recordMatch(r *types.Record, c eventbus.ConstraintMatcher, def bool) bool {
func recordMatch(r *types.Record, c eventbus.ConstraintMatcher) bool {
switch c.Name() {
case "record.updatedAt":
return c.Match(r.UpdatedAt.Format(time.RFC3339))
......@@ -35,5 +39,5 @@ func recordMatch(r *types.Record, c eventbus.ConstraintMatcher, def bool) bool {
}
}
return def
return false
}
......@@ -2,6 +2,7 @@ package service
import (
"context"
"strconv"
"github.com/titpetric/factory"
"go.uber.org/zap"
......@@ -47,6 +48,7 @@ type (
FindByID(namespaceID, moduleID uint64) (*types.Module, error)
FindByName(namespaceID uint64, name string) (*types.Module, error)
FindByHandle(namespaceID uint64, handle string) (*types.Module, error)
FindByAny(namespaceID uint64, identifier interface{}) (*types.Module, error)
Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error)
Create(module *types.Module) (*types.Module, error)
......@@ -109,6 +111,30 @@ func (svc module) FindByHandle(namespaceID uint64, handle string) (m *types.Modu
}
}
// FindByAny tries to find module in a particular namespace by id, handle or name
func (svc module) FindByAny(namespaceID uint64, identifier interface{}) (r *types.Module, err error) {
if ID, ok := identifier.(uint64); ok {
r, err = svc.FindByID(namespaceID, ID)
} else if strIdentifier, ok := identifier.(string); ok {
if ID, _ := strconv.ParseUint(strIdentifier, 10, 64); ID > 0 {
r, err = svc.FindByID(namespaceID, ID)
} else {
r, err = svc.FindByHandle(namespaceID, strIdentifier)
if err == nil && r.ID == 0 {
r, err = svc.FindByName(namespaceID, strIdentifier)
}
}
} else {
err = ErrInvalidID.withStack()
}
if err != nil {
return
}
return
}
func (svc module) loader(m *types.Module, err error) (*types.Module, error) {
if err != nil {
return nil, err
......
......@@ -2,6 +2,7 @@ package service
import (
"context"
"strconv"
"github.com/titpetric/factory"
"go.uber.org/zap"
......@@ -45,6 +46,7 @@ type (
FindByID(namespaceID uint64) (*types.Namespace, error)
FindByHandle(handle string) (*types.Namespace, error)
Find(types.NamespaceFilter) (types.NamespaceSet, types.NamespaceFilter, error)
FindByAny(interface{}) (*types.Namespace, error)
Create(namespace *types.Namespace) (*types.Namespace, error)
Update(namespace *types.Namespace) (*types.Namespace, error)
......@@ -91,6 +93,30 @@ func (svc namespace) FindBySlug(slug string) (ns *types.Namespace, err error) {
return svc.checkPermissions(svc.namespaceRepo.FindBySlug(slug))
}
// FindByAny tries to find namespace by id, handle or slug
func (svc namespace) FindByAny(identifier interface{}) (r *types.Namespace, err error) {
if ID, ok := identifier.(uint64); ok {
r, err = svc.FindByID(ID)
} else if strIdentifier, ok := identifier.(string); ok {
if ID, _ := strconv.ParseUint(strIdentifier, 10, 64); ID > 0 {
r, err = svc.FindByID(ID)
} else {
r, err = svc.FindByHandle(strIdentifier)
if err == nil && r.ID == 0 {
r, err = svc.FindBySlug(strIdentifier)
}
}
} else {
err = ErrInvalidID.withStack()
}
if err != nil {
return
}
return
}
func (svc namespace) checkPermissions(p *types.Namespace, err error) (*types.Namespace, error) {
if err != nil {
return nil, err
......
......@@ -82,6 +82,8 @@ type (
DeleteByID(namespaceID, moduleID uint64, recordID ...uint64) error
Organize(namespaceID, moduleID, recordID uint64, sortingField, sortingValue, sortingFilter, valueField, value string) error
Iterator(f types.RecordFilter, fn eventbus.HandlerFn, action string) (err error)
}
Encoder interface {
......@@ -795,6 +797,123 @@ func (svc record) Organize(namespaceID, moduleID, recordID uint64, posField, pos
})
}
// Iterator loads and iterates through list of records
//
// For each record, RecordOnIteration is generated and passed to fn()
// to be then passed to automation script that invoked the iteration
//
// No other triggers (before/after update/delete/create) are fired when (if)
// records are changed
//
// action arg enables one of the following scenarios:
// - clone: make new record (unless aborted)
// - update: update records (unless aborted)
// - delete: delete records (unless aborted)
// - default: only iterates over records, records are not changed, return value is ignored
//
//
// Iterator can be invoked only when defined in corredor script:
//
// return default {
// iterator (each) {
// return each({
// resourceType: 'compose:record',
// // action: 'update',
// filter: {
// namespace: '122709101053521922',
// module: '122709116471783426',
// query: 'Status = "foo"',
// sort: 'Status DESC',
// limit: 3,
// },
// })
// },
//
// // this is required in case of a deferred iterator
// // security: { runAs: .... } }
//
// // exec gets called for every record found by iterator
// exec () { ... }
// }
func (svc record) Iterator(f types.RecordFilter, fn eventbus.HandlerFn, action string) (err error) {
var (
invokerID = auth.GetIdentityFromContext(svc.ctx).Identity()
ns *types.Namespace
m *types.Module
set types.RecordSet
)
return svc.db.Transaction(func() (err error) {
if ns, m, _, err = svc.loadCombo(f.NamespaceID, f.ModuleID, 0); err != nil {
return
}
if !svc.ac.CanUpdateRecord(svc.ctx, m) {
return ErrNoUpdatePermissions.withStack()
}
// @todo might be good to split set into smaller chunks
set, f, err = svc.recordRepo.Find(m, f)
if err != nil {
return
}
if err = svc.preloadValues(m, set...); err != nil {
return
}
for _, rec := range set {
if err = fn(svc.ctx, event.RecordOnIteration(rec, nil, m, ns, nil)); err != nil {
if err.Error() != "Aborted" {
// When script was softly aborted (return false),
// proceed with iteration but do not clone, update or delete
// current record!
return
}
}
switch action {
case "clone":
var cln *types.Record
// Assign defaults (only on missing values)
rec.Values = svc.setDefaultValues(m, rec.Values)
// Handle payload from automation scripts
if rve := svc.procCreate(invokerID, m, rec); !rve.IsValid() {
return rve
}
if cln, err = svc.recordRepo.Create(rec); err != nil {
return
} else if err = svc.recordRepo.UpdateValues(cln.ID, cln.Values); err != nil {
return
}
case "update":
// Handle input payload
if rve := svc.procUpdate(invokerID, m, rec, rec); !rve.IsValid() {
return rve
}
if rec, err = svc.recordRepo.Update(rec); err != nil {
return
} else if err = svc.recordRepo.UpdateValues(rec.ID, rec.Values); err != nil {
return
}
case "delete":
if err = svc.recordRepo.Delete(rec); err != nil {
return err
} else if err = svc.recordRepo.DeleteValues(rec); err != nil {
return err
}
}
}
return
})
}
// loadCombo Loads everything we need for record manipulation
//
// Loads namespace, module, record and set of triggers.
......
......@@ -2,6 +2,8 @@ package service
import (
"context"
"errors"
"github.com/cortezaproject/corteza-server/pkg/corredor"
"time"
"go.uber.org/zap"
......@@ -141,6 +143,8 @@ func Initialize(ctx context.Context, log *zap.Logger, c Config) (err error) {
DefaultNotification = Notification()
DefaultAttachment = Attachment(DefaultStore)
RegisterIteratorProviders()
return nil
}
......@@ -159,6 +163,39 @@ func Watchers(ctx context.Context) {
DefaultPermissions.Watch(ctx)
}
func RegisterIteratorProviders() {
// Register resource finders on iterator
corredor.Service().RegisterIteratorProvider(
"compose:record",
func(ctx context.Context, f map[string]string, h eventbus.HandlerFn, action string) error {
rf := types.RecordFilter{
Filter: f["filter"],
Sort: f["sort"],
}
rf.ParsePagination(f)
if nsLookup, has := f["namespace"]; !has {
return errors.New("namespace for record iteration filter not defined")
} else if ns, err := DefaultNamespace.With(ctx).FindByAny(nsLookup); err != nil {
return err
} else {
rf.NamespaceID = ns.ID
}
if mLookup, has := f["module"]; !has {
return errors.New("module for record iteration filter not defined")
} else if m, err := DefaultModule.With(ctx).FindByAny(rf.NamespaceID, mLookup); err != nil {
return err
} else {
rf.ModuleID = m.ID
}
return DefaultRecord.With(ctx).Iterator(rf, h, action)
},
)
}
// Data is stale when new date does not match updatedAt or createdAt (before first update)
func isStale(new *time.Time, updatedAt *time.Time, createdAt time.Time) bool {
if new == nil {
......
......@@ -651,6 +651,7 @@ Compose pages
| title | string | POST | Title | N/A | YES |
| handle | string | POST | Handle | N/A | NO |
| description | string | POST | Description | N/A | NO |
| weight | int | POST | Page tree weight | N/A | NO |
| visible | bool | POST | Visible in navigation | N/A | NO |
| blocks | sqlxTypes.JSONText | POST | Blocks JSON | N/A | NO |
......@@ -827,6 +828,7 @@ Compose records
| `DELETE` | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}` | Delete record row from module section |
| `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/attachment` | Uploads attachment and validates it against record field requirements |
| `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}/trigger` | Fire compose:record trigger |
| `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/trigger` | Fire compose:record trigger |
## Generates report from module records
......@@ -1069,6 +1071,22 @@ Compose records
| script | string | POST | Script to execute | N/A | YES |
| values | types.RecordValueSet | POST | Record values | N/A | YES |
## Fire compose:record trigger
#### Method
| URI | Protocol | Method | Authentication |
| --- | -------- | ------ | -------------- |
| `/namespace/{namespaceID}/module/{moduleID}/record/trigger` | HTTP/S | POST | |
#### Request parameters
| Parameter | Type | Method | Description | Default | Required? |
| --------- | ---- | ------ | ----------- | ------- | --------- |
| script | string | POST | Script to execute | N/A | YES |
| namespaceID | uint64 | PATH | Namespace ID | N/A | YES |
| moduleID | uint64 | PATH | Module ID | N/A | YES |
---
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment