snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
21
server/pkg/actionlog/canned_policies.go
Normal file
21
server/pkg/actionlog/canned_policies.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package actionlog
|
||||
|
||||
func MakeDebugPolicy() policyMatcher {
|
||||
return NewPolicyAll()
|
||||
}
|
||||
|
||||
func MakeProductionPolicy() policyMatcher {
|
||||
return NewPolicyAll(
|
||||
NewPolicyAny(
|
||||
// Match all actions from automation
|
||||
NewPolicyMatchRequestOrigin(RequestOrigin_Automation),
|
||||
|
||||
// Ignore debug actions
|
||||
NewPolicyNegate(NewPolicyMatchSeverity(Debug, Info)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func MakeDisabledPolicy() policyMatcher {
|
||||
return NewPolicyNone()
|
||||
}
|
||||
50
server/pkg/actionlog/canned_policies_test.go
Normal file
50
server/pkg/actionlog/canned_policies_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package actionlog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCannedPolies(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actn *Action
|
||||
mtch policyMatcher
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"debug policy should pass on anything",
|
||||
&Action{},
|
||||
MakeDebugPolicy(),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"production policy should record notice",
|
||||
&Action{Severity: Notice},
|
||||
MakeProductionPolicy(),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"production policy should not record debug",
|
||||
&Action{Severity: Debug},
|
||||
MakeProductionPolicy(),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"disabled policy should not record anything",
|
||||
&Action{Severity: Alert},
|
||||
MakeDisabledPolicy(),
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.want != tt.mtch.Match(tt.actn) {
|
||||
if tt.want {
|
||||
t.Errorf("expecting to match")
|
||||
} else {
|
||||
t.Errorf("expecting not to match ")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
server/pkg/actionlog/context.go
Normal file
38
server/pkg/actionlog/context.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package actionlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Key to use when setting the request ID.
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
RequestOrigin_APP_Init = "app/init"
|
||||
RequestOrigin_APP_Upgrade = "app/upgrade"
|
||||
RequestOrigin_APP_Activate = "app/activate"
|
||||
RequestOrigin_APP_Provision = "app/provision"
|
||||
RequestOrigin_APP_Run = "app/run"
|
||||
RequestOrigin_API_REST = "api/rest"
|
||||
RequestOrigin_API_GRPC = "api/grpc"
|
||||
RequestOrigin_Auth = "auth"
|
||||
RequestOrigin_Automation = "automation"
|
||||
)
|
||||
|
||||
// RequestOriginKey is the key that holds th unique request ID in a request context.
|
||||
const requestOriginKey ctxKey = 0
|
||||
|
||||
// RequestOriginToContext stores request origin to context
|
||||
func RequestOriginToContext(ctx context.Context, origin string) context.Context {
|
||||
return context.WithValue(ctx, requestOriginKey, origin)
|
||||
}
|
||||
|
||||
// RequestOriginFromContext returns remote IP address from context
|
||||
func RequestOriginFromContext(ctx context.Context) string {
|
||||
v := ctx.Value(requestOriginKey)
|
||||
if str, ok := v.(string); ok {
|
||||
return str
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
132
server/pkg/actionlog/policy.go
Normal file
132
server/pkg/actionlog/policy.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package actionlog
|
||||
|
||||
import "github.com/cortezaproject/corteza/server/pkg/slice"
|
||||
|
||||
type (
|
||||
policyMatcher interface {
|
||||
Match(*Action) bool
|
||||
}
|
||||
|
||||
logPolicyAny struct {
|
||||
mm []policyMatcher
|
||||
}
|
||||
|
||||
logPolicyAll struct {
|
||||
mm []policyMatcher
|
||||
}
|
||||
|
||||
logPolicyNegate struct {
|
||||
m policyMatcher
|
||||
}
|
||||
|
||||
logPolicyMatchRequestOrigin struct {
|
||||
origin map[string]bool
|
||||
}
|
||||
|
||||
logPolicyMatchResource struct {
|
||||
resources map[string]bool
|
||||
}
|
||||
|
||||
logPolicyMatchAction struct {
|
||||
actions map[string]bool
|
||||
}
|
||||
|
||||
logPolicyMatchSeverity struct {
|
||||
severities map[Severity]bool
|
||||
}
|
||||
|
||||
logPolicyNoop struct {
|
||||
v bool
|
||||
}
|
||||
)
|
||||
|
||||
// NewPolicyNone ignores all action logs
|
||||
func NewPolicyNone() policyMatcher {
|
||||
return &logPolicyNoop{v: false}
|
||||
}
|
||||
|
||||
// NewPolicyAny returns policy where at least one of the sub-policies should match
|
||||
func NewPolicyAny(mm ...policyMatcher) policyMatcher {
|
||||
return &logPolicyAny{mm: mm}
|
||||
}
|
||||
|
||||
func (p logPolicyAny) Match(a *Action) bool {
|
||||
for _, m := range p.mm {
|
||||
if m.Match(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// NewPolicyAll returns policy where all sub-policies should match
|
||||
func NewPolicyAll(mm ...policyMatcher) policyMatcher {
|
||||
return &logPolicyAll{mm: mm}
|
||||
}
|
||||
|
||||
func (p logPolicyAll) Match(a *Action) bool {
|
||||
for _, m := range p.mm {
|
||||
if !m.Match(a) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// NewPolicyNegate negates passed policy
|
||||
func NewPolicyNegate(m policyMatcher) policyMatcher {
|
||||
return &logPolicyNegate{m: m}
|
||||
}
|
||||
|
||||
func (p logPolicyNegate) Match(a *Action) bool {
|
||||
return !p.m.Match(a)
|
||||
}
|
||||
|
||||
// NewPolicyMatchResource matches resources
|
||||
func NewPolicyMatchResource(rr ...string) policyMatcher {
|
||||
return &logPolicyMatchResource{resources: slice.ToStringBoolMap(rr)}
|
||||
}
|
||||
|
||||
func (p logPolicyMatchResource) Match(a *Action) bool {
|
||||
return p.resources[a.Resource]
|
||||
}
|
||||
|
||||
// NewPolicyMatchAction matches action
|
||||
func NewPolicyMatchAction(aa ...string) policyMatcher {
|
||||
return &logPolicyMatchAction{actions: slice.ToStringBoolMap(aa)}
|
||||
}
|
||||
|
||||
func (p logPolicyMatchAction) Match(a *Action) bool {
|
||||
return p.actions[a.Action]
|
||||
}
|
||||
|
||||
// NewPolicyMatchSeverity matches severity
|
||||
func NewPolicyMatchSeverity(ss ...Severity) policyMatcher {
|
||||
var p = &logPolicyMatchSeverity{severities: make(map[Severity]bool)}
|
||||
|
||||
for _, s := range ss {
|
||||
p.severities[s] = true
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p logPolicyMatchSeverity) Match(a *Action) bool {
|
||||
return p.severities[a.Severity]
|
||||
}
|
||||
|
||||
// NewPolicyMatchRequestOrigin matches resources
|
||||
func NewPolicyMatchRequestOrigin(rr ...string) policyMatcher {
|
||||
return &logPolicyMatchRequestOrigin{origin: slice.ToStringBoolMap(rr)}
|
||||
}
|
||||
|
||||
func (p logPolicyMatchRequestOrigin) Match(a *Action) bool {
|
||||
return p.origin[a.RequestOrigin]
|
||||
}
|
||||
|
||||
// Match Internal policy
|
||||
func (p logPolicyNoop) Match(*Action) bool {
|
||||
return p.v
|
||||
}
|
||||
118
server/pkg/actionlog/policy_test.go
Normal file
118
server/pkg/actionlog/policy_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package actionlog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPolicyMatchers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actn *Action
|
||||
mtch policyMatcher
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"simple any",
|
||||
&Action{},
|
||||
NewPolicyAny(),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"simple all",
|
||||
&Action{},
|
||||
NewPolicyAll(),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"should match resource",
|
||||
&Action{Resource: "foo"},
|
||||
NewPolicyMatchResource("foo"),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"should not match resource",
|
||||
&Action{Resource: "bar"},
|
||||
NewPolicyMatchResource("foo"),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"should match one of resource",
|
||||
&Action{Resource: "baz"},
|
||||
NewPolicyMatchResource("foo", "bar", "baz"),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"should match action",
|
||||
&Action{Action: "foo"},
|
||||
NewPolicyMatchAction("foo"),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"should not match action",
|
||||
&Action{Action: "bar"},
|
||||
NewPolicyMatchAction("foo"),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"should match one of action",
|
||||
&Action{Action: "baz"},
|
||||
NewPolicyMatchAction("foo", "bar", "baz"),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"should match severity",
|
||||
&Action{Severity: Emergency},
|
||||
NewPolicyMatchSeverity(Emergency),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"should not match severity",
|
||||
&Action{Severity: Debug},
|
||||
NewPolicyMatchSeverity(Alert),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"should match one of severity",
|
||||
&Action{Severity: Warning},
|
||||
NewPolicyMatchSeverity(Emergency, Debug, Warning),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"should not match one of severity",
|
||||
&Action{Severity: Info},
|
||||
NewPolicyNegate(NewPolicyMatchSeverity(Emergency, Debug, Warning)),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"complex match",
|
||||
&Action{Severity: Warning, Resource: "foo", Action: "do"},
|
||||
NewPolicyAll(
|
||||
NewPolicyMatchResource("foo"),
|
||||
NewPolicyMatchAction("do"),
|
||||
NewPolicyMatchSeverity(Warning),
|
||||
),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"complex miss",
|
||||
&Action{Severity: Warning, Resource: "bar", Action: "do"},
|
||||
NewPolicyAll(
|
||||
NewPolicyMatchResource("foo"),
|
||||
NewPolicyMatchAction("do"),
|
||||
NewPolicyMatchSeverity(Warning),
|
||||
),
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.want != tt.mtch.Match(tt.actn) {
|
||||
if tt.want {
|
||||
t.Errorf("expecting to match")
|
||||
} else {
|
||||
t.Errorf("expecting not to match ")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
149
server/pkg/actionlog/service.go
Normal file
149
server/pkg/actionlog/service.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package actionlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/api"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
)
|
||||
|
||||
type (
|
||||
service struct {
|
||||
// where the audit log records are kept
|
||||
store actionlogStore
|
||||
|
||||
// Also write audit events here
|
||||
tee *zap.Logger
|
||||
|
||||
// logger for repository errors
|
||||
logger *zap.Logger
|
||||
|
||||
policy policyMatcher
|
||||
}
|
||||
|
||||
Recorder interface {
|
||||
Record(context.Context, *Action)
|
||||
Find(context.Context, Filter) (ActionSet, Filter, error)
|
||||
}
|
||||
|
||||
actionlogStore interface {
|
||||
SearchActionlogs(ctx context.Context, f Filter) (ActionSet, Filter, error)
|
||||
CreateActionlog(ctx context.Context, rr ...*Action) error
|
||||
}
|
||||
)
|
||||
|
||||
// NewService initializes action log service
|
||||
func NewService(s actionlogStore, logger, tee *zap.Logger, policy policyMatcher) (svc *service) {
|
||||
if tee == nil {
|
||||
tee = zap.NewNop()
|
||||
}
|
||||
|
||||
svc = &service{
|
||||
tee: tee.Named("actionlog"),
|
||||
logger: logger.Named("actionlog"),
|
||||
store: s,
|
||||
policy: policy,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc service) Record(ctx context.Context, a *Action) {
|
||||
if a == nil {
|
||||
// nothing to record
|
||||
return
|
||||
}
|
||||
|
||||
a = enrich(ctx, a)
|
||||
a.ID = id.Next()
|
||||
|
||||
svc.log(a)
|
||||
if !svc.policy.Match(a) {
|
||||
// policy does not allow us to record this
|
||||
return
|
||||
}
|
||||
|
||||
// We want to prevent any abrupt cancelation
|
||||
// (eg canceled request) that would cause
|
||||
// auditlog to fail...
|
||||
ctx = context.Background()
|
||||
|
||||
if err := svc.store.CreateActionlog(ctx, a); err != nil {
|
||||
svc.logger.With(zap.Error(err)).Error("could not record audit event")
|
||||
}
|
||||
}
|
||||
|
||||
func (svc service) log(a *Action) {
|
||||
zlf := []zap.Field{
|
||||
zap.Time("timestamp", a.Timestamp),
|
||||
zap.String("requestOrigin", a.RequestOrigin),
|
||||
zap.String("requestID", a.RequestID),
|
||||
zap.String("actorIPAddr", a.ActorIPAddr),
|
||||
logger.Uint64("actorID", a.ActorID),
|
||||
zap.String("resource", a.Resource),
|
||||
zap.String("action", a.Action),
|
||||
zap.Uint8("severity", uint8(a.Severity)),
|
||||
zap.String("error", a.Error),
|
||||
zap.String("description", a.Description),
|
||||
zap.Bool("policy-match", svc.policy.Match(a)),
|
||||
zap.Any("meta", a.Meta),
|
||||
}
|
||||
|
||||
svc.tee.
|
||||
With(zlf...).
|
||||
// Skipping 3 callers (the most common stack)
|
||||
// actionlog.service.log()
|
||||
// actionlog.service.Record()
|
||||
// (generated service function)
|
||||
//
|
||||
// One exception, access control, that calls Record fn directly,
|
||||
// without going through generated actionlog helpers
|
||||
WithOptions(zap.AddCallerSkip(3)).
|
||||
// This is debug logger and we log all recordings as debug
|
||||
Debug(a.Description)
|
||||
}
|
||||
|
||||
func (svc service) Find(ctx context.Context, flt Filter) (ActionSet, Filter, error) {
|
||||
return svc.store.SearchActionlogs(ctx, flt)
|
||||
}
|
||||
|
||||
// Enriches action with additional info (ip, actor id, request id...)
|
||||
func enrich(ctx context.Context, a *Action) *Action {
|
||||
if a.Timestamp.IsZero() {
|
||||
a.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
a.RequestOrigin = RequestOriginFromContext(ctx)
|
||||
|
||||
// Relies on chi's middleware to get to the request ID
|
||||
// This does not hurt us for now.
|
||||
a.RequestID = middleware.GetReqID(ctx)
|
||||
|
||||
// uses pkg/auth to extract stored identity from context
|
||||
a.ActorID = auth.GetIdentityFromContext(ctx).Identity()
|
||||
|
||||
// IP from the request,
|
||||
// we're splitting by space & colon to remove any additional (proxy) IPs
|
||||
// and ports from the string
|
||||
if tmp := strings.SplitN(api.RemoteAddrFromContext(ctx), " ", 2); len(tmp) > 0 {
|
||||
// split by : (ip:port)
|
||||
if tmp = strings.SplitN(tmp[0], ":", 2); len(tmp) > 0 {
|
||||
const maxLen = 16
|
||||
ipAddr := tmp[0]
|
||||
if len(ipAddr) > maxLen {
|
||||
ipAddr = ipAddr[:maxLen-1]
|
||||
}
|
||||
|
||||
a.ActorIPAddr = ipAddr
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
73
server/pkg/actionlog/type_set.gen.go
generated
Normal file
73
server/pkg/actionlog/type_set.gen.go
generated
Normal file
@@ -0,0 +1,73 @@
|
||||
package actionlog
|
||||
|
||||
// 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:
|
||||
// pkg/actionlog/types.yaml
|
||||
|
||||
type (
|
||||
|
||||
// ActionSet slice of Action
|
||||
//
|
||||
// This type is auto-generated.
|
||||
ActionSet []*Action
|
||||
)
|
||||
|
||||
// Walk iterates through every slice item and calls w(Action) err
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (set ActionSet) Walk(w func(*Action) error) (err error) {
|
||||
for i := range set {
|
||||
if err = w(set[i]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Filter iterates through every slice item, calls f(Action) (bool, err) and return filtered slice
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (set ActionSet) Filter(f func(*Action) (bool, error)) (out ActionSet, err error) {
|
||||
var ok bool
|
||||
out = ActionSet{}
|
||||
for i := range set {
|
||||
if ok, err = f(set[i]); err != nil {
|
||||
return
|
||||
} else if ok {
|
||||
out = append(out, set[i])
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// FindByID finds items from slice by its ID property
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (set ActionSet) FindByID(ID uint64) *Action {
|
||||
for i := range set {
|
||||
if set[i].ID == ID {
|
||||
return set[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IDs returns a slice of uint64s from all items in the set
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (set ActionSet) IDs() (IDs []uint64) {
|
||||
IDs = make([]uint64, len(set))
|
||||
|
||||
for i := range set {
|
||||
IDs[i] = set[i].ID
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
105
server/pkg/actionlog/type_set.gen_test.go
generated
Normal file
105
server/pkg/actionlog/type_set.gen_test.go
generated
Normal file
@@ -0,0 +1,105 @@
|
||||
package actionlog
|
||||
|
||||
// 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:
|
||||
// pkg/actionlog/types.yaml
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActionSetWalk(t *testing.T) {
|
||||
var (
|
||||
value = make(ActionSet, 3)
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
// check walk with no errors
|
||||
{
|
||||
err := value.Walk(func(*Action) error {
|
||||
return nil
|
||||
})
|
||||
req.NoError(err)
|
||||
}
|
||||
|
||||
// check walk with error
|
||||
req.Error(value.Walk(func(*Action) error { return fmt.Errorf("walk error") }))
|
||||
}
|
||||
|
||||
func TestActionSetFilter(t *testing.T) {
|
||||
var (
|
||||
value = make(ActionSet, 3)
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
// filter nothing
|
||||
{
|
||||
set, err := value.Filter(func(*Action) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
req.NoError(err)
|
||||
req.Equal(len(set), len(value))
|
||||
}
|
||||
|
||||
// filter one item
|
||||
{
|
||||
found := false
|
||||
set, err := value.Filter(func(*Action) (bool, error) {
|
||||
if !found {
|
||||
found = true
|
||||
return found, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
req.NoError(err)
|
||||
req.Len(set, 1)
|
||||
}
|
||||
|
||||
// filter error
|
||||
{
|
||||
_, err := value.Filter(func(*Action) (bool, error) {
|
||||
return false, fmt.Errorf("filter error")
|
||||
})
|
||||
req.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionSetIDs(t *testing.T) {
|
||||
var (
|
||||
value = make(ActionSet, 3)
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
// construct objects
|
||||
value[0] = new(Action)
|
||||
value[1] = new(Action)
|
||||
value[2] = new(Action)
|
||||
// set ids
|
||||
value[0].ID = 1
|
||||
value[1].ID = 2
|
||||
value[2].ID = 3
|
||||
|
||||
// Find existing
|
||||
{
|
||||
val := value.FindByID(2)
|
||||
req.Equal(uint64(2), val.ID)
|
||||
}
|
||||
|
||||
// Find non-existing
|
||||
{
|
||||
val := value.FindByID(4)
|
||||
req.Nil(val)
|
||||
}
|
||||
|
||||
// List IDs from set
|
||||
{
|
||||
val := value.IDs()
|
||||
req.Equal(len(val), len(value))
|
||||
}
|
||||
}
|
||||
243
server/pkg/actionlog/types.go
Normal file
243
server/pkg/actionlog/types.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package actionlog
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/sql"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
)
|
||||
|
||||
type (
|
||||
// Any additional data
|
||||
// that can be packed with the raised audit event
|
||||
Meta map[string]interface{}
|
||||
|
||||
// Severity determinants event severity level
|
||||
Severity uint8
|
||||
|
||||
// Standardized data structure for audit log events
|
||||
Action struct {
|
||||
ID uint64 `json:"actionID,string"`
|
||||
|
||||
// Timestamp of the raised event
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// Origin of the action (rest-api, cli, grpc, system)
|
||||
RequestOrigin string `json:"requestOrigin"`
|
||||
|
||||
// Request ID
|
||||
RequestID string `json:"requestID"`
|
||||
|
||||
// This can contain a series of IP addresses (when proxied)
|
||||
// https://en.wikipedia.org/wiki/X-Forwarded-For#Format
|
||||
ActorIPAddr string `json:"actorIPAddr"`
|
||||
|
||||
// ID of the user (if not anonymous)
|
||||
ActorID uint64 `json:"actorID,string"`
|
||||
|
||||
// Resource
|
||||
Resource string `json:"resource"`
|
||||
|
||||
// Type of action
|
||||
Action string `json:"action"`
|
||||
|
||||
// Error, if any
|
||||
Error string `json:"error"`
|
||||
|
||||
// Action severity
|
||||
Severity Severity `json:"severity"`
|
||||
|
||||
// Description of the event
|
||||
Description string `json:"description"`
|
||||
|
||||
// Meta data, resource specific values
|
||||
Meta Meta `json:"meta"`
|
||||
}
|
||||
|
||||
Filter struct {
|
||||
FromTimestamp *time.Time `json:"from"`
|
||||
ToTimestamp *time.Time `json:"to"`
|
||||
|
||||
BeforeActionID uint64 `json:"beforeActionID"`
|
||||
|
||||
ActorID []string `json:"actorID"`
|
||||
Origin string `json:"origin"`
|
||||
Resource string `json:"resource"`
|
||||
Action string `json:"action"`
|
||||
Limit uint `json:"limit"`
|
||||
|
||||
// Standard helpers for sorting
|
||||
filter.Sorting
|
||||
}
|
||||
|
||||
loggableMetaValue interface {
|
||||
ActionLogMetaValue() (interface{}, bool)
|
||||
}
|
||||
)
|
||||
|
||||
// Severity constants
|
||||
//
|
||||
// not using log/syslog LOG_* constants as they are only
|
||||
// available outside windows env.
|
||||
const (
|
||||
Emergency Severity = iota
|
||||
Alert
|
||||
Critical
|
||||
Error
|
||||
Warning
|
||||
Notice
|
||||
Info
|
||||
Debug
|
||||
|
||||
ActionResourceType = "corteza::generic:action"
|
||||
)
|
||||
|
||||
func (a *Action) ToAction() *Action { return a }
|
||||
|
||||
// Set value as key to meta, or skip it if value is empty (and omitempty is set)
|
||||
func (m Meta) Set(key string, in interface{}, omitempty bool) {
|
||||
// if value has fn() ActionLogMetaValue that can
|
||||
// reformat value and (maybe) omit empty values, call that first
|
||||
if lmv, is := in.(loggableMetaValue); is {
|
||||
if v, empty := lmv.ActionLogMetaValue(); !omitempty || !empty {
|
||||
m[key] = v
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// for the rest, we need to determine what kind of
|
||||
|
||||
if str, is := in.(string); is {
|
||||
if !omitempty || len(str) > 0 {
|
||||
m[key] = str
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if bb, is := in.([]byte); is {
|
||||
if !omitempty || len(bb) > 0 {
|
||||
m[key] = bb
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if b, is := in.(bool); is {
|
||||
m[key] = b
|
||||
return
|
||||
}
|
||||
|
||||
{
|
||||
// properly encode big numbers
|
||||
// before storing them as JSON
|
||||
|
||||
num := func(n interface{}) interface{} {
|
||||
switch n := n.(type) {
|
||||
case int:
|
||||
return int64(n)
|
||||
case int8:
|
||||
return int64(n)
|
||||
case int16:
|
||||
return int64(n)
|
||||
case int32:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return int64(n)
|
||||
case uint:
|
||||
return uint64(n)
|
||||
case uintptr:
|
||||
return uint64(n)
|
||||
case uint8:
|
||||
return uint64(n)
|
||||
case uint16:
|
||||
return uint64(n)
|
||||
case uint32:
|
||||
return uint64(n)
|
||||
case uint64:
|
||||
return uint64(n)
|
||||
case float32:
|
||||
return float64(n)
|
||||
case float64:
|
||||
return n
|
||||
}
|
||||
return n
|
||||
}(in)
|
||||
|
||||
switch num := num.(type) {
|
||||
case int64:
|
||||
if !omitempty || num > 0 {
|
||||
m[key] = strconv.FormatInt(num, 10)
|
||||
}
|
||||
return
|
||||
case uint64:
|
||||
if !omitempty || num > 0 {
|
||||
m[key] = strconv.FormatUint(num, 10)
|
||||
}
|
||||
return
|
||||
|
||||
case float64:
|
||||
if !omitempty || num > 0 {
|
||||
m[key] = in
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// for the rest (string, slices, etc..)
|
||||
// just set the value
|
||||
m[key] = in
|
||||
}
|
||||
|
||||
func (s Severity) String() string {
|
||||
switch s {
|
||||
case Emergency:
|
||||
return "emergency"
|
||||
case Alert:
|
||||
return "alert"
|
||||
case Critical:
|
||||
return "critical"
|
||||
case Error:
|
||||
return "err"
|
||||
case Warning:
|
||||
return "warning"
|
||||
case Notice:
|
||||
return "notice"
|
||||
case Info:
|
||||
return "info"
|
||||
case Debug:
|
||||
return "debug"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func NewSeverity(s string) Severity {
|
||||
switch s {
|
||||
case "emergency":
|
||||
return Emergency
|
||||
case "alert":
|
||||
return Alert
|
||||
case "critical":
|
||||
return Critical
|
||||
case "err":
|
||||
return Error
|
||||
case "warning":
|
||||
return Warning
|
||||
case "notice":
|
||||
return Notice
|
||||
case "info":
|
||||
return Info
|
||||
}
|
||||
|
||||
return Debug
|
||||
}
|
||||
|
||||
func (m *Meta) Scan(src any) error { return sql.ParseJSON(src, m) }
|
||||
func (m Meta) Value() (driver.Value, error) { return json.Marshal(m) }
|
||||
3
server/pkg/actionlog/types.yaml
Normal file
3
server/pkg/actionlog/types.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
package: actionlog
|
||||
types:
|
||||
Action: {}
|
||||
70
server/pkg/actionlog/types_test.go
Normal file
70
server/pkg/actionlog/types_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package actionlog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type (
|
||||
metaFoo struct{ val string }
|
||||
metaBar struct{ val string }
|
||||
)
|
||||
|
||||
func (f metaFoo) ActionLogMetaValue() (interface{}, bool) {
|
||||
return strings.ToUpper(f.val), len(f.val) == 0
|
||||
}
|
||||
|
||||
func TestMeta_Set(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val interface{}
|
||||
omitempty bool
|
||||
out Meta
|
||||
}{
|
||||
{"string value, omit", "str", true, Meta{"t": "str"}},
|
||||
{"string value, keep", "str", false, Meta{"t": "str"}},
|
||||
{"string empty, omit", "", true, Meta{}},
|
||||
{"string empty, keep", "", false, Meta{"t": ""}},
|
||||
|
||||
{"byte value, omit", []byte{'b', 'y', 't', 'e'}, true, Meta{"t": []byte{'b', 'y', 't', 'e'}}},
|
||||
{"byte value, keep", []byte{'b', 'y', 't', 'e'}, false, Meta{"t": []byte{'b', 'y', 't', 'e'}}},
|
||||
{"byte empty, omit", []byte{}, true, Meta{}},
|
||||
{"byte empty, keep", []byte{}, false, Meta{"t": []byte{}}},
|
||||
|
||||
{"bool true", true, false, Meta{"t": true}},
|
||||
{"bool false", false, false, Meta{"t": false}},
|
||||
|
||||
{"int value, omit", 1, true, Meta{"t": "1"}},
|
||||
{"int value, keep", 1, false, Meta{"t": "1"}},
|
||||
{"int empty, omit", 0, true, Meta{}},
|
||||
{"int empty, keep", 0, false, Meta{"t": "0"}},
|
||||
{"uint64 empty, omit", uint64(0), true, Meta{}},
|
||||
{"uint64 empty, keep", uint64(0), false, Meta{"t": "0"}},
|
||||
{"bigint", 244783268048994492, false, Meta{"t": "244783268048994492"}},
|
||||
{"int64", int64(244783268048994492), false, Meta{"t": "244783268048994492"}},
|
||||
{"uint64()", uint64(244783268048994492), false, Meta{"t": "244783268048994492"}},
|
||||
{"int", 42, false, Meta{"t": "42"}},
|
||||
{"float64", float64(42), false, Meta{"t": float64(42)}},
|
||||
{"float64", float64(244783268048994492), false, Meta{"t": 2.447832680489945e+17}},
|
||||
|
||||
{"slice", []int{0, 1}, false, Meta{"t": []int{0, 1}}},
|
||||
|
||||
{"meta foo", metaFoo{"value"}, false, Meta{"t": "VALUE"}},
|
||||
{"meta foo, omit", metaFoo{""}, true, Meta{}},
|
||||
// @todo raw
|
||||
{"meta bar", metaBar{"value"}, false, Meta{"t": metaBar{"value"}}},
|
||||
{"meta bar", metaBar{}, true, Meta{"t": metaBar{""}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
m := make(Meta)
|
||||
m.Set("t", tt.val, tt.omitempty)
|
||||
if !a.Equal(tt.out, m) {
|
||||
t.Fail()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
24
server/pkg/api/debug.go
Normal file
24
server/pkg/api/debug.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Debug context
|
||||
type ctxKeyDebug struct{}
|
||||
|
||||
// Packs remote address to context
|
||||
func DebugToContext(production bool) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
next.ServeHTTP(w, req.WithContext(context.WithValue(req.Context(), ctxKeyDebug{}, !production)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DebugFromContext returns remote IP address from context
|
||||
func DebugFromContext(ctx context.Context) bool {
|
||||
debug, ok := ctx.Value(ctxKeyDebug{}).(bool)
|
||||
return ok && debug
|
||||
}
|
||||
29
server/pkg/api/ipaddr.go
Normal file
29
server/pkg/api/ipaddr.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Key to use when setting the request ID.
|
||||
type ctxKeyRemoteAddr int
|
||||
|
||||
// RemoteAddrKey is the key that holds th unique request ID in a request context.
|
||||
const remoteAddrKey ctxKeyRemoteAddr = 0
|
||||
|
||||
// Packs remote address to context
|
||||
func RemoteAddrToContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
next.ServeHTTP(w, req.WithContext(context.WithValue(req.Context(), remoteAddrKey, req.RemoteAddr)))
|
||||
})
|
||||
}
|
||||
|
||||
// RemoteAddrFromContext returns remote IP address from context
|
||||
func RemoteAddrFromContext(ctx context.Context) string {
|
||||
v := ctx.Value(remoteAddrKey)
|
||||
if str, ok := v.(string); ok {
|
||||
return str
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
170
server/pkg/api/response.go
Normal file
170
server/pkg/api/response.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package api
|
||||
|
||||
// This code is modified version from
|
||||
// https://github.com/titpetric/factory/tree/master/resputil
|
||||
//
|
||||
// Parts of the code are rewritten to allow greater flexibility
|
||||
// but the general logic stays the same for now
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type (
|
||||
successWrap struct {
|
||||
Success struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"success"`
|
||||
}
|
||||
|
||||
CallFrame struct {
|
||||
Function string `json:"function"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
}
|
||||
|
||||
ErrorPayload struct {
|
||||
Message string `json:"message"`
|
||||
Context map[string]interface{} `json:"context,omitempty"`
|
||||
Callstack []*CallFrame `json:"callstack,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
// Success returns a structured success message for API responses
|
||||
func Success(success ...string) *successWrap {
|
||||
response := &successWrap{}
|
||||
response.Success.Message = "OK"
|
||||
if len(success) > 0 {
|
||||
response.Success.Message = success[0]
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// OK returns the default Success message
|
||||
func OK() *successWrap {
|
||||
return Success()
|
||||
}
|
||||
|
||||
// Writes response, according to type
|
||||
//
|
||||
// Primarily this function encodes given payload (directly or indirectly) as compact JSON
|
||||
//
|
||||
// In some specific scenarios, when:
|
||||
// - debug mode is enabled,
|
||||
// - no explicit accept header with /json mime-type is sent
|
||||
// and,
|
||||
//
|
||||
// if payload is an error:
|
||||
// it outputs formatted error with extended info
|
||||
//
|
||||
// if payload is non-error:
|
||||
// it outputs formatted and indented JSON
|
||||
//
|
||||
func encode(w http.ResponseWriter, r *http.Request, payload interface{}) {
|
||||
var (
|
||||
err error
|
||||
enc = json.NewEncoder(w)
|
||||
)
|
||||
|
||||
switch c := payload.(type) {
|
||||
|
||||
case error:
|
||||
err = c
|
||||
|
||||
case *successWrap:
|
||||
// main key is "success"
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err = enc.Encode(c); err != nil {
|
||||
err = fmt.Errorf("failed to encode response: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
// main key is "response"
|
||||
aux := struct {
|
||||
Response interface{} `json:"response"`
|
||||
}{c}
|
||||
if err = enc.Encode(aux); err != nil {
|
||||
err = fmt.Errorf("failed to encode response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err, is := err.(*errors.Error); is {
|
||||
// trim out the base stack we don't care about...
|
||||
_ = err.Apply(errors.StackTrimAtFn("http.HandlerFunc.ServeHTTP"))
|
||||
}
|
||||
|
||||
errors.ServeHTTP(w, r, err, !DebugFromContext(r.Context()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send handles first non-nil (and non-empty) payload and encodes it or it's results (when fn)
|
||||
//
|
||||
// See encode() for details
|
||||
func Send(w http.ResponseWriter, r *http.Request, rr ...interface{}) {
|
||||
for _, rsp := range rr {
|
||||
switch c := rsp.(type) {
|
||||
case nil:
|
||||
// this will match a nil error
|
||||
continue
|
||||
|
||||
case *successWrap:
|
||||
encode(w, r, c)
|
||||
|
||||
case func(http.ResponseWriter, *http.Request):
|
||||
c(w, r)
|
||||
|
||||
case func() ([]byte, error):
|
||||
result, err := c()
|
||||
Send(w, r, err, result)
|
||||
|
||||
case func() (interface{}, error):
|
||||
result, err := c()
|
||||
Send(w, r, err, result)
|
||||
|
||||
case func() error:
|
||||
err := c()
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
encode(w, r, err)
|
||||
|
||||
case error:
|
||||
encode(w, r, c)
|
||||
|
||||
case []byte:
|
||||
if len(c) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := w.Write(c); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return
|
||||
|
||||
case string:
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
encode(w, r, c)
|
||||
|
||||
case bool:
|
||||
if !c {
|
||||
continue
|
||||
}
|
||||
encode(w, r, c)
|
||||
|
||||
default:
|
||||
encode(w, r, c)
|
||||
}
|
||||
|
||||
// Exit on the first output...
|
||||
return
|
||||
}
|
||||
|
||||
encode(w, r, false)
|
||||
}
|
||||
66
server/pkg/api/response_test.go
Normal file
66
server/pkg/api/response_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"io/ioutil"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
func TestTests(t *testing.T) {
|
||||
testResponse := func(output interface{}) string {
|
||||
w := httptest.NewRecorder()
|
||||
r := &http.Request{Header: http.Header{}}
|
||||
r.Header.Add("accept", "application/json")
|
||||
Send(w, r, output)
|
||||
body, _ := ioutil.ReadAll(w.Result().Body)
|
||||
return string(body)
|
||||
}
|
||||
|
||||
var cc = []struct {
|
||||
name string
|
||||
inp interface{}
|
||||
out string
|
||||
}{
|
||||
{"nil", nil, `{"response":false}`},
|
||||
{"bool true", true, `{"response":true}`},
|
||||
{"bool false", false, `{"response":false}`},
|
||||
{"string empty", "", `{"response":false}`},
|
||||
{"string", "string", `{"response":"string"}`},
|
||||
{"int zero", 0, `{"response":0}`},
|
||||
{"int non-zero", 1337, `{"response":1337}`},
|
||||
{"int sub-zero", -1, `{"response":-1}`},
|
||||
{"error nil", func() error { return nil }, `{"response":false}`},
|
||||
{"error", func() error { return fmt.Errorf("error response") }, `{"error":{"message":"error response"}}`},
|
||||
{"value + error", func() (interface{}, error) { return "string response", fmt.Errorf("error response") }, `{"error":{"message":"error response"}}`},
|
||||
{"empty value + error", func() (interface{}, error) { return "", fmt.Errorf("error response") }, `{"error":{"message":"error response"}}`},
|
||||
{"value + empty error", func() (interface{}, error) { return "string response", nil }, `{"response":"string response"}`},
|
||||
{"success default", Success(), `{"success":{"message":"OK"}}`},
|
||||
{"ok", OK(), `{"success":{"message":"OK"}}`},
|
||||
{"success custom", Success("string"), `{"success":{"message":"string"}}`},
|
||||
{"error stdlib", fmt.Errorf("string"), `{"error":{"message":"string"}}`},
|
||||
{"error stdlib nil", func() interface{} { return func() error { return nil }() }(), `{"response":false}`},
|
||||
{"func json nil", func() ([]byte, error) { return json.Marshal(nil) }, `null`},
|
||||
{"func json false", func() ([]byte, error) { return json.Marshal(false) }, `false`},
|
||||
{"func json 0", func() ([]byte, error) { return json.Marshal(0) }, `0`},
|
||||
{"func json empty string", func() ([]byte, error) { return json.Marshal("") }, `""`},
|
||||
{"func writer/req", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("foo")) }, `foo`},
|
||||
{"custom struct", struct {
|
||||
Name string `json:"name"`
|
||||
}{"Corteza"}, `{"response":{"name":"Corteza"}}`},
|
||||
}
|
||||
|
||||
for _, c := range cc {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := strings.TrimSpace(testResponse(c.inp))
|
||||
if got != strings.TrimSpace(c.out) {
|
||||
t.Errorf("got %#v, expected %#v", got, c.out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
33
server/pkg/api/server/cors.go
Normal file
33
server/pkg/api/server/cors.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
// Sets up default CORS rules to use as a middleware
|
||||
func handleCORS(next http.Handler) http.Handler {
|
||||
return cors.New(cors.Options{
|
||||
AllowedOrigins: []string{
|
||||
"http://*",
|
||||
"https://*",
|
||||
},
|
||||
AllowedMethods: []string{
|
||||
http.MethodHead,
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
http.MethodPatch,
|
||||
http.MethodDelete,
|
||||
},
|
||||
AllowedHeaders: []string{
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-CSRF-ID",
|
||||
},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300, // Maximum value not ignored by any of major browsers
|
||||
}).Handler(next)
|
||||
}
|
||||
50
server/pkg/api/server/debug.go
Normal file
50
server/pkg/api/server/debug.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/corredor"
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func debugRoutes(r chi.Routes) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
var printRoutes func(chi.Routes, string)
|
||||
|
||||
printRoutes = func(r chi.Routes, pfix string) {
|
||||
routes := r.Routes()
|
||||
for _, route := range routes {
|
||||
if route.SubRoutes != nil && len(route.SubRoutes.Routes()) > 0 {
|
||||
printRoutes(route.SubRoutes, pfix+route.Pattern[:len(route.Pattern)-2])
|
||||
} else {
|
||||
if route.Handlers["*"] != nil {
|
||||
fmt.Fprintf(w, "%-8s %-80s\n", "*", pfix+route.Pattern)
|
||||
continue
|
||||
}
|
||||
|
||||
for method := range route.Handlers {
|
||||
fmt.Fprintf(w, "%-8s %-80s\n", method, pfix+route.Pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printRoutes(r, "")
|
||||
}
|
||||
}
|
||||
|
||||
func debugEventbus() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
spew.Fdump(w, eventbus.Service().Debug())
|
||||
}
|
||||
}
|
||||
|
||||
func debugCorredor() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
spew.Fdump(w, corredor.Service().Debug())
|
||||
}
|
||||
}
|
||||
53
server/pkg/api/server/demux.go
Normal file
53
server/pkg/api/server/demux.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/atomic"
|
||||
)
|
||||
|
||||
type (
|
||||
// demux (demultiplexer) routes request to one of the underlying routers
|
||||
// according to current state
|
||||
demux struct {
|
||||
state *atomic.Uint32
|
||||
routers map[uint32]chi.Router
|
||||
}
|
||||
)
|
||||
|
||||
var _ http.Handler = &demux{}
|
||||
|
||||
func Demux(state uint32, r chi.Router) *demux {
|
||||
return &demux{
|
||||
state: atomic.NewUint32(state),
|
||||
routers: map[uint32]chi.Router{state: r},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *demux) State(s uint32) {
|
||||
d.state.Store(s)
|
||||
}
|
||||
|
||||
func (d *demux) Router(s uint32, r chi.Router) {
|
||||
d.routers[s] = r
|
||||
}
|
||||
|
||||
func (d *demux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
defer panicRecovery(r.Context(), w)
|
||||
|
||||
var (
|
||||
state = d.state.Load()
|
||||
router, exists = d.routers[state]
|
||||
)
|
||||
|
||||
if !exists {
|
||||
_, _ = fmt.Fprintf(w, "unconfigured request demultiplexor state (%d)", state)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
}
|
||||
311
server/pkg/api/server/handlers.go
Normal file
311
server/pkg/api/server/handlers.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/assets"
|
||||
"github.com/cortezaproject/corteza/server/pkg/api"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/healthcheck"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/cortezaproject/corteza/server/pkg/version"
|
||||
"github.com/cortezaproject/corteza/server/webconsole"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// routes used when server is in waiting mode
|
||||
func waitingRoutes(log *zap.Logger, httpOpt options.HttpServerOpt) (r chi.Router) {
|
||||
r = chi.NewRouter()
|
||||
r.Use(handleCORS)
|
||||
|
||||
mountServiceHandlers(r, log, httpOpt, waiting)
|
||||
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
// For non GET requests, return 503 (service unavailable)
|
||||
errors.ServeHTTPWithCode(w, r,
|
||||
http.StatusServiceUnavailable,
|
||||
fmt.Errorf("corteza server initializing"),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Refresh the page in 15 seconds
|
||||
w.Header().Set("Refresh", "15; url=/")
|
||||
_, _ = fmt.Fprint(w, "Corteza server initializing\n\n")
|
||||
if httpOpt.EnableHealthcheckRoute {
|
||||
healthcheck.Defaults().Run(r.Context()).WriteTo(w)
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// routes used when server in shutdown mode
|
||||
func shutdownRoutes() (r chi.Router) {
|
||||
r = chi.NewRouter()
|
||||
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
// For non GET requests, return 503 (service unavailable)
|
||||
errors.ServeHTTPWithCode(w, r,
|
||||
http.StatusServiceUnavailable,
|
||||
fmt.Errorf("corteza server shutting down"),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Refresh the page in 15 seconds
|
||||
w.Header().Set("Refresh", "15; url=/")
|
||||
_, _ = fmt.Fprint(w, "corteza server shutting down")
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// routes used when in active mode
|
||||
func activeRoutes(log *zap.Logger, mountable []func(r chi.Router), opts *options.Options) (r chi.Router) {
|
||||
r = chi.NewRouter()
|
||||
r.Use(handleCORS)
|
||||
|
||||
httpOpt := opts.HTTPServer
|
||||
authOpt := opts.Auth
|
||||
envOpt := opts.Environment
|
||||
|
||||
r.Route("/"+strings.TrimPrefix(httpOpt.BaseUrl, "/"), func(r chi.Router) {
|
||||
// Reports error to Sentry if enabled
|
||||
if httpOpt.EnablePanicReporting {
|
||||
r.Use(sentryMiddleware())
|
||||
}
|
||||
|
||||
if httpOpt.EnableMetrics {
|
||||
// Metrics tracking middleware
|
||||
r.Use(metricsMiddleware(httpOpt.MetricsServiceLabel))
|
||||
}
|
||||
|
||||
// Handle panic (sets 500 server error headers)
|
||||
//r.Use(handlePanic)
|
||||
|
||||
// Base middleware, CORS, RealIP, RequestID, context-logger
|
||||
r.Use(BaseMiddleware(envOpt.IsProduction(), log)...)
|
||||
|
||||
// Logging request if enabled
|
||||
if httpOpt.LogRequest {
|
||||
r.Use(LogRequest)
|
||||
}
|
||||
|
||||
// Logging response if enabled
|
||||
if httpOpt.LogResponse {
|
||||
r.Use(LogResponse)
|
||||
}
|
||||
|
||||
// Verifies JWT in headers, cookies, ...
|
||||
r.Use(auth.HttpTokenVerifier)
|
||||
|
||||
for _, mount := range mountable {
|
||||
mount(r)
|
||||
}
|
||||
|
||||
if httpOpt.EnableMetrics {
|
||||
metricsMount(r, httpOpt.MetricsUsername, httpOpt.MetricsPassword)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
if httpOpt.BaseUrl != "/" {
|
||||
r.Handle("/", http.RedirectHandler(httpOpt.BaseUrl, http.StatusTemporaryRedirect))
|
||||
}
|
||||
|
||||
mountServiceHandlers(r, log, httpOpt, active)
|
||||
|
||||
r.HandleFunc(httpOpt.ApiBaseUrl, handleStaticPages(log, httpOpt, authOpt, "api-landing.html"))
|
||||
r.HandleFunc(httpOpt.ApiBaseUrl+"/", handleStaticPages(log, httpOpt, authOpt, "api-landing.html"))
|
||||
r.NotFound(handleStaticPages(log, httpOpt, authOpt, "api-404.html"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func mountServiceHandlers(r chi.Router, log *zap.Logger, opt options.HttpServerOpt, state uint32) {
|
||||
if opt.WebConsoleEnabled {
|
||||
path := "/console"
|
||||
log.Info("web console enabled (HTTP_SERVER_WEB_CONSOLE_ENABLED=true): " + path)
|
||||
r.Route(path, func(r chi.Router) {
|
||||
if len(opt.WebConsolePassword) > 0 {
|
||||
credentials := map[string]string{
|
||||
opt.WebConsoleUsername: opt.WebConsolePassword,
|
||||
}
|
||||
r.Use(middleware.BasicAuth("web-console", credentials))
|
||||
} else {
|
||||
// warn only in waiting state to avoid repeated log messages
|
||||
if state == waiting {
|
||||
// warn the user regardless of what environment Corteza is running in.
|
||||
log.Warn("SECURITY RISK: web console is enabled and unprotected, set " +
|
||||
"HTTP_SERVER_WEB_CONSOLE_USERNAME, HTTP_SERVER_WEB_CONSOLE_PASSWORD " +
|
||||
"if not running in development environment!")
|
||||
}
|
||||
}
|
||||
|
||||
webconsole.Mount(r)
|
||||
mountDebugLogViewer(r, log)
|
||||
|
||||
// redirect from /console to /console/ui/
|
||||
r.Mount("/", http.RedirectHandler(path+"/ui", http.StatusTemporaryRedirect))
|
||||
})
|
||||
}
|
||||
|
||||
if opt.EnableDebugRoute {
|
||||
mountDebugHandler(r, log)
|
||||
}
|
||||
|
||||
if opt.EnableVersionRoute {
|
||||
mountVersionHandler(r, log, opt.BaseUrl)
|
||||
}
|
||||
|
||||
if opt.EnableHealthcheckRoute {
|
||||
mountHealthCheckHandler(r, log, opt.BaseUrl)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// @todo move all these routes under /console and
|
||||
// output JSON instead of plain raw text
|
||||
func mountDebugHandler(r chi.Router, log *zap.Logger) {
|
||||
log.Debug("route debugger enabled: /__routes")
|
||||
r.Get("/__routes", debugRoutes(r))
|
||||
|
||||
log.Debug("profiler enabled: /debug/pprof")
|
||||
r.Mount("/debug", middleware.Profiler())
|
||||
|
||||
log.Debug("eventbus handlers debug enabled: /__eventbus")
|
||||
r.Get("/__eventbus", debugEventbus())
|
||||
|
||||
log.Debug("corredor service debug enabled: /__corredor")
|
||||
r.Get("/__corredor", debugCorredor())
|
||||
}
|
||||
|
||||
func mountVersionHandler(r chi.Router, log *zap.Logger, basePath string) {
|
||||
var (
|
||||
dPath = "/version"
|
||||
sPath = path.Join(basePath, dPath)
|
||||
handler = func(w http.ResponseWriter, r *http.Request) {
|
||||
api.Send(w, r, struct {
|
||||
BuildTime string `json:"buildTime"`
|
||||
Version string `json:"version"`
|
||||
}{version.BuildTime, version.Version})
|
||||
}
|
||||
)
|
||||
|
||||
r.Get(dPath, handler)
|
||||
log.Debug("version route enabled: " + dPath)
|
||||
|
||||
if dPath != sPath {
|
||||
r.Get(sPath, handler)
|
||||
log.Debug("version route enabled: " + sPath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func mountHealthCheckHandler(r chi.Router, log *zap.Logger, basePath string) {
|
||||
// default & sub path for health-check endpoint
|
||||
var (
|
||||
dPath = "/healthcheck"
|
||||
sPath = path.Join(basePath, dPath)
|
||||
)
|
||||
|
||||
r.Get(dPath, healthcheck.HttpHandler())
|
||||
log.Debug("health check route enabled: " + sPath)
|
||||
|
||||
if dPath != sPath {
|
||||
r.Get(sPath, healthcheck.HttpHandler())
|
||||
log.Debug("health check route enabled: " + sPath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func mountDebugLogViewer(r chi.Router, log *zap.Logger) {
|
||||
var (
|
||||
path = "/server-log-feed"
|
||||
)
|
||||
|
||||
r.Get(path+".json", func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
after int = 0
|
||||
limit int = 100
|
||||
err error
|
||||
q = r.URL.Query()
|
||||
)
|
||||
|
||||
if aux := q.Get("after"); len(aux) > 0 {
|
||||
after, err = strconv.Atoi(aux)
|
||||
if err != nil {
|
||||
errors.ProperlyServeHTTP(w, r, errors.InvalidData("invalid value format for after: %v", err), false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if aux := q.Get("limit"); len(aux) > 0 {
|
||||
limit, err = strconv.Atoi(aux)
|
||||
if err != nil {
|
||||
errors.ProperlyServeHTTP(w, r, errors.InvalidData("invalid value format for limit: %v", err), false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = logger.WriteLogBuffer(w, after, limit)
|
||||
})
|
||||
}
|
||||
|
||||
func handleStaticPages(log *zap.Logger, hOpt options.HttpServerOpt, aOpt options.AuthOpt, file string) http.HandlerFunc {
|
||||
// "good-enough" for now, plan to move to templates when
|
||||
// merging with auth
|
||||
const linkTpl = `<a class="btn btn-light font-weight-bold text-dark m-2" href="%s">%s</a>`
|
||||
var (
|
||||
links = make([]string, 0)
|
||||
buf []byte
|
||||
|
||||
placeholder = []byte("<!-- links -->")
|
||||
)
|
||||
|
||||
links = append(links, fmt.Sprintf(linkTpl, aOpt.BaseURL, "Login"))
|
||||
|
||||
if hOpt.ApiEnabled {
|
||||
links = append(links, fmt.Sprintf(linkTpl, "https://docs.cortezaproject.org/", "Documentation"))
|
||||
}
|
||||
|
||||
if hOpt.WebConsoleEnabled {
|
||||
links = append(links, fmt.Sprintf(linkTpl, "/console", "Console"))
|
||||
}
|
||||
|
||||
page, err := assets.Files(log, hOpt.AssetsPath).Open(file)
|
||||
if err != nil {
|
||||
log.Warn("could not open static page", zap.String("file", file), zap.Error(err))
|
||||
}
|
||||
|
||||
buf, err = io.ReadAll(page)
|
||||
if err != nil {
|
||||
log.Warn("could not prepare static page", zap.String("file", file), zap.Error(err))
|
||||
}
|
||||
|
||||
buf = bytes.ReplaceAll(buf, placeholder, []byte(strings.Join(links, "")))
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if page == nil {
|
||||
// fallback to default 404 handler
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write(buf)
|
||||
}
|
||||
}
|
||||
76
server/pkg/api/server/logger.go
Normal file
76
server/pkg/api/server/logger.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// contextLogger middleware binds logger to request's context.
|
||||
//
|
||||
// This allows us to use logger from context (with requestID)
|
||||
// inside our (generated) handlers and controllers
|
||||
func contextLogger(log *zap.Logger) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
var requestID = middleware.GetReqID(req.Context())
|
||||
|
||||
w.Header().Add("X-Request-Id", requestID)
|
||||
|
||||
req = req.WithContext(logger.ContextWithValue(
|
||||
req.Context(),
|
||||
log.With(zap.String("requestID", requestID)).Named("rest"),
|
||||
))
|
||||
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// LogRequest middleware logs request details
|
||||
//
|
||||
// It uses logger from context, see contextLogger()
|
||||
func LogRequest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
var remote = req.RemoteAddr
|
||||
if l := strings.LastIndex(remote, ":"); l > -1 {
|
||||
remote = remote[:l]
|
||||
}
|
||||
|
||||
logger.ContextValue(req.Context()).Info(
|
||||
"HTTP request "+req.Method+" "+req.URL.Path,
|
||||
zap.String("method", req.Method),
|
||||
zap.String("path", req.URL.Path),
|
||||
zap.Int64("size", req.ContentLength),
|
||||
zap.String("remote", remote),
|
||||
)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
// LogResponse middleware logs response details
|
||||
//
|
||||
// It uses logger from context, see contextLogger()
|
||||
func LogResponse(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
wrapped := middleware.NewWrapResponseWriter(w, req.ProtoMajor)
|
||||
t := time.Now()
|
||||
|
||||
defer func() {
|
||||
logger.ContextValue(req.Context()).Info(
|
||||
"HTTP response "+req.Method+" "+req.URL.Path,
|
||||
zap.String("method", req.Method),
|
||||
zap.String("path", req.URL.Path),
|
||||
zap.Int("status", wrapped.Status()),
|
||||
zap.Int("size", wrapped.BytesWritten()),
|
||||
zap.Float64("duration", time.Since(t).Seconds()),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(wrapped, req)
|
||||
})
|
||||
|
||||
}
|
||||
24
server/pkg/api/server/metrics.go
Normal file
24
server/pkg/api/server/metrics.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/99designs/basicauth-go"
|
||||
"net/http"
|
||||
|
||||
"github.com/766b/chi-prometheus"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// MetricsMiddleware is the request logger that provides metrics to prometheus
|
||||
func metricsMiddleware(name string) func(http.Handler) http.Handler {
|
||||
return chiprometheus.NewMiddleware(name)
|
||||
}
|
||||
|
||||
func metricsMount(r chi.Router, username, password string) {
|
||||
r.Route("/metrics", func(r chi.Router) {
|
||||
r.Use(basicauth.New("Metrics", map[string][]string{
|
||||
username: {password},
|
||||
}))
|
||||
r.Handle("/", promhttp.Handler())
|
||||
})
|
||||
}
|
||||
65
server/pkg/api/server/middleware.go
Normal file
65
server/pkg/api/server/middleware.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/api"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func BaseMiddleware(isProduction bool, log *zap.Logger) []func(http.Handler) http.Handler {
|
||||
return []func(http.Handler) http.Handler{
|
||||
handleCORS,
|
||||
locale.DetectLanguage(locale.Global()),
|
||||
middleware.RealIP,
|
||||
api.RemoteAddrToContext,
|
||||
middleware.RequestID,
|
||||
api.DebugToContext(isProduction),
|
||||
contextLogger(log),
|
||||
}
|
||||
}
|
||||
|
||||
func sentryMiddleware() func(http.Handler) http.Handler {
|
||||
return sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
}).Handle
|
||||
}
|
||||
|
||||
func panicRecovery(ctx context.Context, w http.ResponseWriter) {
|
||||
if err := recover(); err != nil {
|
||||
|
||||
if _, has := os.LookupEnv("LOG_DEBUG"); has {
|
||||
println("================================================================================")
|
||||
fmt.Printf("%v\n", err)
|
||||
println("--------------------------------------------------------------------------------")
|
||||
debug.PrintStack()
|
||||
println("================================================================================")
|
||||
} else {
|
||||
log := logger.ContextValue(ctx)
|
||||
if err, ok := err.(error); ok {
|
||||
log = log.With(zap.Error(err))
|
||||
} else {
|
||||
log = log.With(zap.Any("recover-value", err))
|
||||
}
|
||||
log.Debug("crashed on http request", zap.ByteString("stack", debug.Stack()))
|
||||
}
|
||||
|
||||
w.WriteHeader(500)
|
||||
|
||||
if _, has := os.LookupEnv("DEBUG_DUMP_STACK_IN_RESPONSE"); has {
|
||||
// Provide nice call stack on endpoint when
|
||||
// we crash
|
||||
_, _ = w.Write(debug.Stack())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
125
server/pkg/api/server/server.go
Normal file
125
server/pkg/api/server/server.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
server struct {
|
||||
log *zap.Logger
|
||||
opts *options.Options
|
||||
endpoints []func(r chi.Router)
|
||||
|
||||
// last error
|
||||
err error
|
||||
|
||||
demux *demux
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
waiting uint32 = iota
|
||||
active
|
||||
shutdown
|
||||
)
|
||||
|
||||
// New initializes new HTTP server with special powers
|
||||
// Server is started as early as possible and with a special request handler
|
||||
// that demultiplexes request to one of the configured routers according to the server state.
|
||||
//
|
||||
// Waiting state
|
||||
// This is initial state with the ofllowing route handlers:
|
||||
// - /version
|
||||
// - /healthcheck
|
||||
|
||||
func New(log *zap.Logger, opts *options.Options) *server {
|
||||
s := &server{
|
||||
endpoints: make([]func(r chi.Router), 0),
|
||||
log: log.Named("http"),
|
||||
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
s.demux = Demux(waiting, waitingRoutes(s.log.Named("waiting"), s.opts.HTTPServer))
|
||||
s.demux.Router(shutdown, shutdownRoutes())
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *server) LastError() error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func Test(o *options.Options) error {
|
||||
listener, err := net.Listen("tcp", o.HTTPServer.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = listener.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activate reconfigures server to use active routes
|
||||
func (s *server) Activate(mm ...func(chi.Router)) {
|
||||
s.demux.Router(active, activeRoutes(s.log, mm, s.opts))
|
||||
|
||||
s.log.Debug("entering active state")
|
||||
s.demux.State(active)
|
||||
}
|
||||
|
||||
// Shutdown reconfigures server to use shutdown routes
|
||||
func (s *server) Shutdown() {
|
||||
s.log.Debug("entering shutdown state")
|
||||
s.demux.State(shutdown)
|
||||
}
|
||||
|
||||
func (s server) Serve(ctx context.Context) {
|
||||
var (
|
||||
listener net.Listener
|
||||
)
|
||||
|
||||
s.log.Info(
|
||||
"starting HTTP server",
|
||||
|
||||
zap.String("path-prefix", s.opts.HTTPServer.BaseUrl),
|
||||
zap.String("address", s.opts.HTTPServer.Addr),
|
||||
)
|
||||
|
||||
listener, s.err = net.Listen("tcp", s.opts.HTTPServer.Addr)
|
||||
if s.err != nil {
|
||||
s.log.Error("cannot start server", zap.Error(s.err))
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
srv := http.Server{
|
||||
Handler: s.demux,
|
||||
|
||||
// use root context as server's base context and as a basis for
|
||||
// context for all requests
|
||||
// this enables us to send cancellation down to every request
|
||||
BaseContext: func(listener net.Listener) context.Context { return ctx },
|
||||
}
|
||||
s.err = srv.Serve(listener)
|
||||
}()
|
||||
<-ctx.Done()
|
||||
|
||||
if s.err == nil {
|
||||
s.err = ctx.Err()
|
||||
if s.err == context.Canceled {
|
||||
s.err = nil
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Info("HTTP server stopped", zap.Error(s.err))
|
||||
}
|
||||
181
server/pkg/api/server/waitfor.go
Normal file
181
server/pkg/api/server/waitfor.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WaitFor sets up a simple status page, delays execution and probes services
|
||||
func (s server) WaitFor(ctx context.Context) {
|
||||
var (
|
||||
opt = s.opts.WaitFor
|
||||
services = opt.GetServices()
|
||||
)
|
||||
|
||||
if len(services) == 0 && opt.Delay == 0 {
|
||||
// Nothing to do here..
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
log = s.log.Named("wait-for")
|
||||
depChan = make(chan struct{})
|
||||
wg sync.WaitGroup
|
||||
serviceAddr string
|
||||
serviceURL *url.URL
|
||||
err error
|
||||
)
|
||||
|
||||
// Setup a simple HTTP server that will inform the impatient users
|
||||
listener, err := net.Listen("tcp", s.opts.HTTPServer.Addr)
|
||||
if err != nil {
|
||||
s.log.Error("cannot start server", zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer listener.Close()
|
||||
go func() {
|
||||
router := chi.NewRouter()
|
||||
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusPreconditionFailed)
|
||||
w.Write([]byte("waiting for services..."))
|
||||
})
|
||||
_ = http.Serve(listener, router)
|
||||
}()
|
||||
|
||||
if opt.Delay > 0 {
|
||||
s.log.Info("delaying", zap.Duration("delay", opt.Delay))
|
||||
|
||||
// First delay execution
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Debug("canceled")
|
||||
return
|
||||
case <-time.After(opt.Delay):
|
||||
// all good...
|
||||
}
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("waiting for services", zap.Strings("services", services))
|
||||
// Probe services
|
||||
wg.Add(len(services))
|
||||
go func() {
|
||||
|
||||
for _, service := range services {
|
||||
slog := log.With(zap.String("service", service))
|
||||
|
||||
go func(ctx context.Context, service string) {
|
||||
defer wg.Done()
|
||||
|
||||
if serviceAddr, serviceURL, err = s.resolveService(service); err != nil {
|
||||
log.Error("could not resolve service", zap.Error(err))
|
||||
}
|
||||
|
||||
for {
|
||||
ctx, cancelFn := context.WithTimeout(ctx, opt.ServicesProbeTimeout)
|
||||
defer cancelFn()
|
||||
|
||||
if serviceURL == nil {
|
||||
if err = s.probeService(ctx, serviceAddr); err != nil {
|
||||
slog.Warn("service probe failed", zap.Error(err))
|
||||
time.Sleep(opt.ServicesProbeInterval)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if err = s.probeServiceURL(ctx, serviceURL); err != nil {
|
||||
slog.Warn("service URL probe failed", zap.Error(err))
|
||||
time.Sleep(opt.ServicesProbeInterval)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("service ready")
|
||||
return
|
||||
}
|
||||
}(ctx, service)
|
||||
}
|
||||
wg.Wait()
|
||||
close(depChan)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Debug("canceled")
|
||||
return
|
||||
case <-depChan: // services are ready
|
||||
log.Debug("all services ready")
|
||||
return
|
||||
case <-time.After(opt.ServicesTimeout):
|
||||
log.Debug("services not ready")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s server) resolveService(service string) (addr string, u *url.URL, err error) {
|
||||
addr = service
|
||||
|
||||
if strings.Contains(addr, "://") {
|
||||
// Is service an URL?
|
||||
u, err = url.Parse(addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
addr = u.Host
|
||||
|
||||
if u.Port() == "" {
|
||||
if u.Scheme == "https" {
|
||||
addr += ":443"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to port 80
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr += ":80"
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s server) probeService(ctx context.Context, addr string) (err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dialer := net.Dialer{}
|
||||
_, err = dialer.DialContext(ctx, "tcp", addr)
|
||||
return
|
||||
}
|
||||
|
||||
func (s server) probeServiceURL(ctx context.Context, u *url.URL) error {
|
||||
req, err := http.NewRequest("GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to assemble service request: %w", err)
|
||||
}
|
||||
|
||||
rsp, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return fmt.Errorf("service URL request failed: %w", err)
|
||||
}
|
||||
|
||||
defer rsp.Body.Close()
|
||||
if rsp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("service responded with unexpected status '%s'", rsp.Status)
|
||||
}
|
||||
41
server/pkg/apigw/ctx/ctx.go
Normal file
41
server/pkg/apigw/ctx/ctx.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package ctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/profiler"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
)
|
||||
|
||||
type (
|
||||
scopeCtxKey struct{}
|
||||
profilerCtxKey struct{}
|
||||
)
|
||||
|
||||
func ScopeToContext(ctx context.Context, s *types.Scp) context.Context {
|
||||
return context.WithValue(ctx, scopeCtxKey{}, s)
|
||||
}
|
||||
|
||||
func ScopeFromContext(ctx context.Context) (ss *types.Scp) {
|
||||
s := ctx.Value(scopeCtxKey{})
|
||||
|
||||
if s == nil {
|
||||
return &types.Scp{}
|
||||
}
|
||||
|
||||
return s.(*types.Scp)
|
||||
}
|
||||
|
||||
func ProfilerToContext(ctx context.Context, h interface{}) context.Context {
|
||||
return context.WithValue(ctx, profilerCtxKey{}, h)
|
||||
}
|
||||
|
||||
func ProfilerFromContext(ctx context.Context) (h *profiler.Hit) {
|
||||
hh := ctx.Value(profilerCtxKey{})
|
||||
|
||||
if hh == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return hh.(*profiler.Hit)
|
||||
}
|
||||
26
server/pkg/apigw/filter/filter.go
Normal file
26
server/pkg/apigw/filter/filter.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
)
|
||||
|
||||
const (
|
||||
PreFilterWeight = iota
|
||||
ProcesserWeight
|
||||
PostFilterWeight
|
||||
)
|
||||
|
||||
func FilterWeight(w int, t types.FilterKind) int {
|
||||
mul := PreFilterWeight
|
||||
|
||||
switch t {
|
||||
case types.PreFilter:
|
||||
mul = PreFilterWeight
|
||||
case types.Processer:
|
||||
mul = ProcesserWeight
|
||||
case types.PostFilter:
|
||||
mul = PostFilterWeight
|
||||
}
|
||||
|
||||
return mul*100 + w
|
||||
}
|
||||
309
server/pkg/apigw/filter/postfilter.go
Normal file
309
server/pkg/apigw/filter/postfilter.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
atypes "github.com/cortezaproject/corteza/server/automation/types"
|
||||
agctx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
errors "github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
)
|
||||
|
||||
type (
|
||||
typesRegistry interface {
|
||||
Type(ref string) expr.Type
|
||||
}
|
||||
redirection struct {
|
||||
types.FilterMeta
|
||||
|
||||
location *url.URL
|
||||
status int
|
||||
|
||||
cfg types.Config
|
||||
|
||||
params struct {
|
||||
HTTPStatus int `json:"status,string"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
}
|
||||
|
||||
response struct {
|
||||
types.FilterMeta
|
||||
|
||||
reg typesRegistry
|
||||
|
||||
cfg types.Config
|
||||
|
||||
params struct {
|
||||
Header http.Header `json:"header"`
|
||||
|
||||
Exp *atypes.Expr `json:"input"`
|
||||
|
||||
Evaluable expr.Evaluable
|
||||
}
|
||||
}
|
||||
|
||||
defaultJsonResponse struct {
|
||||
types.FilterMeta
|
||||
cfg types.Config
|
||||
}
|
||||
)
|
||||
|
||||
func NewRedirection(cfg types.Config) (e *redirection) {
|
||||
e = &redirection{}
|
||||
|
||||
e.Name = "redirection"
|
||||
e.Label = "Redirection"
|
||||
e.Kind = types.PostFilter
|
||||
e.cfg = cfg
|
||||
|
||||
e.Args = []*types.FilterMetaArg{
|
||||
{
|
||||
Type: "status",
|
||||
Label: "status",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
{
|
||||
Type: "text",
|
||||
Label: "location",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (h redirection) New(cfg types.Config) types.Handler {
|
||||
return NewRedirection(cfg)
|
||||
}
|
||||
|
||||
func (h redirection) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h redirection) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", h.Name, h.Label)
|
||||
}
|
||||
|
||||
func (h redirection) Meta() types.FilterMeta {
|
||||
return h.FilterMeta
|
||||
}
|
||||
|
||||
func (h redirection) Weight() int {
|
||||
return h.Wgt
|
||||
}
|
||||
|
||||
func (h *redirection) Merge(params []byte, cfg types.Config) (types.Handler, error) {
|
||||
err := json.NewDecoder(bytes.NewBuffer(params)).Decode(&h.params)
|
||||
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
|
||||
loc, err := url.ParseRequestURI(h.params.Location)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not validate parameters, invalid URL: %s", err)
|
||||
}
|
||||
|
||||
if !checkStatus("redirect", h.params.HTTPStatus) {
|
||||
return nil, fmt.Errorf("could not validate parameters, wrong status %d", h.params.HTTPStatus)
|
||||
}
|
||||
|
||||
h.location = loc
|
||||
h.status = h.params.HTTPStatus
|
||||
h.cfg = cfg
|
||||
|
||||
return h, err
|
||||
}
|
||||
|
||||
func (h redirection) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) error {
|
||||
http.Redirect(rw, r, h.location.String(), h.status)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewDefaultJsonResponse(cfg types.Config) (e *defaultJsonResponse) {
|
||||
e = &defaultJsonResponse{}
|
||||
|
||||
e.Name = "defaultJsonResponse"
|
||||
e.Label = "Default JSON response"
|
||||
e.Kind = types.PostFilter
|
||||
e.cfg = cfg
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (j defaultJsonResponse) New(cfg types.Config) types.Handler {
|
||||
return NewDefaultJsonResponse(cfg)
|
||||
}
|
||||
|
||||
func (j defaultJsonResponse) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (j defaultJsonResponse) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", j.Name, j.Label)
|
||||
}
|
||||
|
||||
func (j defaultJsonResponse) Meta() types.FilterMeta {
|
||||
return j.FilterMeta
|
||||
}
|
||||
|
||||
func (j *defaultJsonResponse) Merge(params []byte, cfg types.Config) (h types.Handler, err error) {
|
||||
j.cfg = cfg
|
||||
|
||||
return j, err
|
||||
}
|
||||
|
||||
func (j defaultJsonResponse) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) error {
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(http.StatusAccepted)
|
||||
|
||||
if _, err := rw.Write([]byte(`{}`)); err != nil {
|
||||
return errors.Internal("could not write to body: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkStatus(typ string, status int) bool {
|
||||
switch typ {
|
||||
case "redirect":
|
||||
return status >= 300 && status <= 399
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func NewResponse(cfg types.Config, reg typesRegistry) (e *response) {
|
||||
e = &response{}
|
||||
|
||||
e.Name = "response"
|
||||
e.Label = "Response"
|
||||
e.Kind = types.PostFilter
|
||||
|
||||
e.Args = []*types.FilterMetaArg{
|
||||
{
|
||||
Type: "input",
|
||||
Label: "input",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
{
|
||||
Type: "header",
|
||||
Label: "header",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
e.reg = reg
|
||||
e.cfg = cfg
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (j response) New(cfg types.Config) types.Handler {
|
||||
return NewResponse(cfg, j.reg)
|
||||
}
|
||||
|
||||
func (j response) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (j response) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", j.Name, j.Label)
|
||||
}
|
||||
|
||||
func (j response) Meta() types.FilterMeta {
|
||||
return j.FilterMeta
|
||||
}
|
||||
|
||||
func (j *response) Merge(params []byte, cfg types.Config) (h types.Handler, err error) {
|
||||
var (
|
||||
parser = expr.NewParser()
|
||||
)
|
||||
|
||||
err = json.NewDecoder(bytes.NewBuffer(params)).Decode(&j.params)
|
||||
|
||||
if err != nil {
|
||||
return j, err
|
||||
}
|
||||
|
||||
j.params.Evaluable, err = parser.Parse(j.params.Exp.Expr)
|
||||
|
||||
if err != nil {
|
||||
return j, fmt.Errorf("could not evaluate expression: %s", err)
|
||||
}
|
||||
|
||||
j.params.Exp.SetEval(j.params.Evaluable)
|
||||
j.cfg = cfg
|
||||
|
||||
return j, err
|
||||
}
|
||||
|
||||
func (j response) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) (err error) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
scope = agctx.ScopeFromContext(ctx)
|
||||
hasJsonHeader = false
|
||||
|
||||
evald interface{}
|
||||
)
|
||||
|
||||
in, err := expr.NewVars(scope.Dict())
|
||||
|
||||
if err != nil {
|
||||
return errors.Internal("could not validate request data: %v", err)
|
||||
}
|
||||
|
||||
// set type to the registered expression from
|
||||
// any of the already registered types
|
||||
j.params.Exp.SetType(func(name string) (e expr.Type, err error) {
|
||||
if name == "" {
|
||||
name = "Any"
|
||||
}
|
||||
|
||||
if typ := j.reg.Type(name); typ != nil {
|
||||
return typ, nil
|
||||
} else {
|
||||
return nil, errors.Internal("unknown or unregistered type %s", name)
|
||||
}
|
||||
})
|
||||
|
||||
evald, err = j.params.Exp.Eval(ctx, in)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for h, v := range j.params.Header {
|
||||
for _, vv := range v {
|
||||
rw.Header().Add(h, vv)
|
||||
|
||||
if strings.ToLower(h) == "content-type" {
|
||||
hasJsonHeader = vv == "application/json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasJsonHeader && reflect.ValueOf(evald).Kind() != reflect.String {
|
||||
err = (json.NewEncoder(rw)).Encode(expr.UntypedValue(evald))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(rw, "%v", evald)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
207
server/pkg/apigw/filter/postfilter_test.go
Normal file
207
server/pkg/apigw/filter/postfilter_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agctx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type (
|
||||
mockHandlerRegistry struct{}
|
||||
)
|
||||
|
||||
func Test_redirectionMerge(t *testing.T) {
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "url validation",
|
||||
expr: `{"status":"301", "location": "invalid url"}`,
|
||||
err: `could not validate parameters, invalid URL: parse "invalid url": invalid URI for request`,
|
||||
},
|
||||
{
|
||||
name: "invalid redirection status",
|
||||
expr: `{"status":"400", "location": "http://redire.ct/to"}`,
|
||||
err: "could not validate parameters, wrong status 400",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, testMerge(NewRedirection(types.Config{}), tc))
|
||||
}
|
||||
}
|
||||
|
||||
func Test_redirection(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
expr string
|
||||
err string
|
||||
loc string
|
||||
code int
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "simple redirection",
|
||||
expr: `{"status":"302", "location": "http://redire.ct/to"}`,
|
||||
loc: "http://redire.ct/to",
|
||||
code: 302,
|
||||
},
|
||||
{
|
||||
name: "permanent redirection",
|
||||
expr: `{"status":"301", "location": "http://redire.ct/to"}`,
|
||||
loc: "http://redire.ct/to",
|
||||
code: 301,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
r = httptest.NewRequest(http.MethodGet, "/foo", http.NoBody)
|
||||
rc = httptest.NewRecorder()
|
||||
|
||||
cfg = types.Config{}
|
||||
)
|
||||
|
||||
h := getHandler(NewRedirection(cfg))
|
||||
h, err := h.Merge([]byte(tc.expr), cfg)
|
||||
|
||||
req.NoError(err)
|
||||
|
||||
hn := h.Handler()
|
||||
err = hn(rc, r)
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
return
|
||||
}
|
||||
|
||||
req.NoError(err)
|
||||
req.Equal(tc.loc, rc.Header().Get("Location"))
|
||||
req.Equal(tc.code, rc.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_jsonResponse(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
expr string
|
||||
err string
|
||||
exp string
|
||||
scope interface{}
|
||||
}
|
||||
|
||||
aux struct {
|
||||
Name string `json:"name"`
|
||||
Surname string `json:"surname"`
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "String response as JSON",
|
||||
expr: `{"header":{"content-type":["application/json"]},"input":{"expr": "records", "type": "String"}}`,
|
||||
scope: expr.Must(expr.Any{}.Cast("foobar")),
|
||||
exp: `foobar`,
|
||||
},
|
||||
{
|
||||
name: "Array response as JSON",
|
||||
expr: `{"header":{"content-type":["application/json"]},"input":{"expr": "records", "type": "Array"}}`,
|
||||
scope: expr.Must(expr.Any{}.Cast([]float64{3.14, 42.690})),
|
||||
exp: `[3.14,42.69]`,
|
||||
},
|
||||
{
|
||||
name: "Array response as text",
|
||||
expr: `{"input":{"expr": "records", "type": "Array"}}`,
|
||||
scope: expr.Must(expr.Any{}.Cast([]float64{3.14, 42.690})),
|
||||
exp: `[3.14 42.69]`,
|
||||
},
|
||||
{
|
||||
name: "Any response as JSON",
|
||||
expr: `{"header":{"content-type":["application/json"]},"input": {"expr": "records", "type": "Any"}}`,
|
||||
scope: expr.Must(expr.Any{}.Cast(map[string]string{"foo": "bar", "baz": "bzz"})),
|
||||
exp: `{"baz":"bzz","foo":"bar"}`,
|
||||
},
|
||||
{
|
||||
name: "Any response as text",
|
||||
expr: `{"input": {"expr": "records", "type": "Any"}}`,
|
||||
scope: expr.Must(expr.Any{}.Cast(map[string]string{"foo": "bar", "baz": "bzz"})),
|
||||
exp: `map[baz:bzz foo:bar]`,
|
||||
},
|
||||
{
|
||||
name: "struct array response as JSON",
|
||||
expr: `{"input":{"expr": "toJSON(records)", "type": "String"}}`,
|
||||
scope: []aux{{"First", "Last"}, {"Foo", "bar"}},
|
||||
exp: `[{"name":"First","surname":"Last"},{"name":"Foo","surname":"bar"}]`,
|
||||
},
|
||||
{
|
||||
name: "struct array response as text",
|
||||
expr: `{"input":{"expr": "records", "type": "String"}}`,
|
||||
scope: []aux{{"First", "Last"}, {"Foo", "bar"}},
|
||||
exp: `[{First Last} {Foo bar}]`,
|
||||
},
|
||||
{
|
||||
name: "string csv response as text",
|
||||
expr: `{"header":{"content-type":["application/octet-stream"],"Content-Disposition":["attachment; filename=foo.txt"],"Content-Transfer-Encoding":["binary"]},"input":{"expr": "records", "type": "String"}}`,
|
||||
scope: "\"header 1\",\"header 2\"\nvalue 1,value 2\nvalue 3, value 4",
|
||||
exp: "\"header 1\",\"header 2\"\nvalue 1,value 2\nvalue 3, value 4",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
r = httptest.NewRequest(http.MethodGet, "/foo", http.NoBody)
|
||||
rc = httptest.NewRecorder()
|
||||
scope = &types.Scp{"records": tc.scope}
|
||||
|
||||
cfg = types.Config{}
|
||||
)
|
||||
|
||||
r = r.WithContext(agctx.ScopeToContext(context.Background(), scope))
|
||||
|
||||
h := getHandler(NewResponse(cfg, &mockHandlerRegistry{}))
|
||||
h, err := h.Merge([]byte(tc.expr), cfg)
|
||||
|
||||
req.NoError(err)
|
||||
|
||||
hn := h.Handler()
|
||||
err = hn(rc, r)
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
return
|
||||
}
|
||||
|
||||
req.NoError(err)
|
||||
req.Equal(tc.exp, strings.TrimSuffix(rc.Body.String(), "\n"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// hackity hack
|
||||
func getHandler(h types.Handler) types.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
func (r *mockHandlerRegistry) Type(ref string) expr.Type {
|
||||
return expr.Any{}
|
||||
}
|
||||
298
server/pkg/apigw/filter/prefilter.go
Normal file
298
server/pkg/apigw/filter/prefilter.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
agctx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
pe "github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
)
|
||||
|
||||
type (
|
||||
header struct {
|
||||
types.FilterMeta
|
||||
eval expr.Evaluable
|
||||
cfg types.Config
|
||||
|
||||
params struct {
|
||||
Expr string `json:"expr"`
|
||||
}
|
||||
}
|
||||
|
||||
queryParam struct {
|
||||
types.FilterMeta
|
||||
eval expr.Evaluable
|
||||
cfg types.Config
|
||||
|
||||
params struct {
|
||||
Expr string `json:"expr"`
|
||||
}
|
||||
}
|
||||
|
||||
origin struct {
|
||||
types.FilterMeta
|
||||
eval expr.Evaluable
|
||||
cfg types.Config
|
||||
|
||||
params struct {
|
||||
Expr string `json:"expr"`
|
||||
}
|
||||
}
|
||||
|
||||
profiler struct {
|
||||
cfg types.Config
|
||||
types.FilterMeta
|
||||
}
|
||||
)
|
||||
|
||||
func NewHeader(cfg types.Config) (v *header) {
|
||||
v = &header{}
|
||||
|
||||
v.Name = "header"
|
||||
v.Label = "Header"
|
||||
v.Kind = types.PreFilter
|
||||
|
||||
v.Args = []*types.FilterMetaArg{
|
||||
{
|
||||
Type: "expr",
|
||||
Label: "expr",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
v.cfg = cfg
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (h header) New(cfg types.Config) types.Handler {
|
||||
return NewHeader(cfg)
|
||||
}
|
||||
|
||||
func (h header) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h header) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", h.Name, h.Label)
|
||||
}
|
||||
|
||||
func (h header) Meta() types.FilterMeta {
|
||||
return h.FilterMeta
|
||||
}
|
||||
|
||||
func (h *header) Merge(params []byte, cfg types.Config) (types.Handler, error) {
|
||||
err := json.NewDecoder(bytes.NewBuffer(params)).Decode(&h.params)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parser := expr.NewParser()
|
||||
|
||||
h.cfg = cfg
|
||||
h.eval, err = parser.Parse(h.params.Expr)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not validate origin parameters: %s", err)
|
||||
}
|
||||
|
||||
return h, err
|
||||
}
|
||||
|
||||
func (h header) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) error {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
)
|
||||
|
||||
vv := map[string]interface{}{}
|
||||
headers := r.Header
|
||||
|
||||
for k, v := range headers {
|
||||
vv[k] = v[0]
|
||||
}
|
||||
|
||||
// get the request data and put it into vars
|
||||
out, err := expr.NewVars(vv)
|
||||
if err != nil {
|
||||
return pe.Internal("could not validate headers: %v", err)
|
||||
}
|
||||
|
||||
err = out.Set("headers", vv)
|
||||
if err != nil {
|
||||
return pe.Internal("could not set headers: %v", err)
|
||||
}
|
||||
|
||||
b, err := h.eval.Test(ctx, out)
|
||||
if err != nil {
|
||||
return pe.InvalidData("could not validate headers: %v", err)
|
||||
}
|
||||
|
||||
if !b {
|
||||
return pe.InvalidData("could not validate headers: %v", fmt.Errorf("validation failed"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewQueryParam(cfg types.Config) (v *queryParam) {
|
||||
v = &queryParam{}
|
||||
|
||||
v.Name = "queryParam"
|
||||
v.Label = "Query parameters"
|
||||
v.Kind = types.PreFilter
|
||||
|
||||
v.Args = []*types.FilterMetaArg{
|
||||
{
|
||||
Type: "expr",
|
||||
Label: "expr",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
v.cfg = cfg
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (qp queryParam) New(cfg types.Config) types.Handler {
|
||||
return NewQueryParam(cfg)
|
||||
}
|
||||
|
||||
func (qp queryParam) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (qp queryParam) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", qp.Name, qp.Label)
|
||||
}
|
||||
|
||||
func (qp queryParam) Meta() types.FilterMeta {
|
||||
return qp.FilterMeta
|
||||
}
|
||||
|
||||
func (qp *queryParam) Merge(params []byte, cfg types.Config) (types.Handler, error) {
|
||||
err := json.NewDecoder(bytes.NewBuffer(params)).Decode(&qp.params)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parser := expr.NewParser()
|
||||
|
||||
qp.cfg = cfg
|
||||
qp.eval, err = parser.Parse(qp.params.Expr)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not validate query parameters: %s", err)
|
||||
}
|
||||
|
||||
return qp, err
|
||||
}
|
||||
|
||||
func (qp *queryParam) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) error {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
)
|
||||
|
||||
vv := map[string]interface{}{}
|
||||
vals := r.URL.Query()
|
||||
|
||||
for k, v := range vals {
|
||||
vv[k] = v[0]
|
||||
}
|
||||
|
||||
// get the request data and put it into vars
|
||||
out, err := expr.NewVars(vv)
|
||||
if err != nil {
|
||||
return pe.Internal("could not validate query parameters: %v", err)
|
||||
}
|
||||
|
||||
err = out.Set("params", vv)
|
||||
if err != nil {
|
||||
return pe.Internal("could not set params: %v", err)
|
||||
}
|
||||
|
||||
b, err := qp.eval.Test(ctx, out)
|
||||
if err != nil {
|
||||
return pe.InvalidData("could not validate query parameters: %v", err)
|
||||
}
|
||||
|
||||
if !b {
|
||||
return pe.InvalidData("could not validate query parameters: %v", fmt.Errorf("validation failed"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewProfiler(cfg types.Config) (pp *profiler) {
|
||||
pp = &profiler{}
|
||||
|
||||
pp.cfg = cfg
|
||||
pp.Name = "profiler"
|
||||
pp.Label = "Profiler"
|
||||
pp.Kind = types.PreFilter
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (pr *profiler) New(cfg types.Config) types.Handler {
|
||||
return NewProfiler(cfg)
|
||||
}
|
||||
|
||||
func (pr *profiler) Enabled() bool {
|
||||
return pr.cfg.Profiler.Enabled
|
||||
}
|
||||
|
||||
func (pr *profiler) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", pr.Name, pr.Label)
|
||||
}
|
||||
|
||||
func (pr *profiler) Meta() types.FilterMeta {
|
||||
return pr.FilterMeta
|
||||
}
|
||||
|
||||
func (pr *profiler) Merge(params []byte, cfg types.Config) (types.Handler, error) {
|
||||
pr.cfg = cfg
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
func (pr *profiler) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) (err error) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
scope = agctx.ScopeFromContext(ctx)
|
||||
)
|
||||
|
||||
if pr.cfg.Profiler.Global {
|
||||
// profiler global overrides any profiling prefilter
|
||||
// the hit is registered on lower level
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
n = time.Now()
|
||||
hit = agctx.ProfilerFromContext(r.Context())
|
||||
)
|
||||
|
||||
if hit == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hit.Ts = &n
|
||||
hit.R = scope.Request()
|
||||
|
||||
r = r.WithContext(agctx.ProfilerToContext(r.Context(), hit))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
254
server/pkg/apigw/filter/prefilter_test.go
Normal file
254
server/pkg/apigw/filter/prefilter_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
agctx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
prf "github.com/cortezaproject/corteza/server/pkg/apigw/profiler"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
h "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
expr string
|
||||
err string
|
||||
url string
|
||||
o string
|
||||
headers http.Header
|
||||
}
|
||||
)
|
||||
|
||||
func Test_headerMerge(t *testing.T) {
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "non matching key",
|
||||
expr: `{"expr":"Foo1 == bar\""}`,
|
||||
headers: map[string][]string{"Foo": {"bar"}},
|
||||
err: "could not validate origin parameters: parsing error: Foo1 == bar\"\t:1:12 - 1:13 unexpected String while scanning operator",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, testMerge(NewHeader(types.Config{}), tc))
|
||||
}
|
||||
}
|
||||
|
||||
func Test_headerHandle(t *testing.T) {
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "matching simple",
|
||||
expr: `{"expr":"foo == \"bar\""}`,
|
||||
headers: map[string][]string{"foo": {"bar"}},
|
||||
},
|
||||
{
|
||||
name: "matching case",
|
||||
expr: `{"expr":"Foo == \"bar\""}`,
|
||||
headers: map[string][]string{"Foo": {"bar"}},
|
||||
},
|
||||
{
|
||||
name: "non matching value",
|
||||
expr: `{"expr":"Foo == \"bar1\""}`,
|
||||
headers: map[string][]string{"Foo": {"bar"}},
|
||||
err: `could not validate headers: validation failed`,
|
||||
},
|
||||
{
|
||||
name: "non matching key",
|
||||
expr: `{"expr":"Foo1 == \"bar\""}`,
|
||||
headers: map[string][]string{"Foo": {"bar"}},
|
||||
err: `could not validate headers: failed to select 'Foo1' on *expr.Vars: no such key 'Foo1'`,
|
||||
},
|
||||
{
|
||||
name: "regex matching key",
|
||||
expr: `{"expr":"match(Foo, \"^b\\\\wr\\\\s.*$\")"}`,
|
||||
headers: map[string][]string{"Foo": {"bar "}},
|
||||
},
|
||||
{
|
||||
name: "matching header with hyphen",
|
||||
expr: `{"expr":"headers[\"Content-type\"] == \"application/json\""}`,
|
||||
headers: map[string][]string{"Content-type": {"application/json"}},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
r := httptest.NewRequest(http.MethodGet, "/foo", http.NoBody)
|
||||
r.Header = tc.headers
|
||||
|
||||
t.Run(tc.name, testHandle(NewHeader(types.Config{}), r, tc))
|
||||
}
|
||||
}
|
||||
|
||||
func Test_queryParamMerge(t *testing.T) {
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "matching simple query parameter",
|
||||
expr: `{"expr":"foo == \"bar\""}`,
|
||||
url: "https://examp.le?foo=bar",
|
||||
},
|
||||
{
|
||||
name: "matching simple query parameter - invalid expression key",
|
||||
expr: `{"expr1":"foo == \"bar\""}`,
|
||||
url: "https://examp.le?foo=bar",
|
||||
err: "could not validate query parameters: parsing error: - 1:1 unexpected EOF while scanning extensions",
|
||||
},
|
||||
{
|
||||
name: "matching simple query parameter - missing value",
|
||||
expr: `{"expr":"foo == \"bar\""}`,
|
||||
url: "https://examp.le?foo=bar1",
|
||||
},
|
||||
{
|
||||
name: "matching simple query parameter - missing value",
|
||||
expr: `{"expr":"foo == \"bar-baz\""}`,
|
||||
url: "https://examp.le?foo=bar-baz",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, testMerge(NewQueryParam(types.Config{}), tc))
|
||||
}
|
||||
}
|
||||
|
||||
func Test_queryParamHandle(t *testing.T) {
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "matching simple query parameter",
|
||||
expr: `{"expr":"foo == \"bar\""}`,
|
||||
url: "https://examp.le?foo=bar",
|
||||
},
|
||||
{
|
||||
name: "matching simple query parameter - missing value",
|
||||
expr: `{"expr":"foo == \"bar\""}`,
|
||||
url: "https://examp.le?foo=bar1",
|
||||
err: `could not validate query parameters: validation failed`,
|
||||
},
|
||||
{
|
||||
name: "matching query parameter",
|
||||
expr: `{"expr":"foo == \"bar-baz\""}`,
|
||||
url: "https://examp.le?foo=bar-baz",
|
||||
},
|
||||
{
|
||||
name: "matching query parameter",
|
||||
expr: `{"expr":"params[\"foo-bar\"] == \"bar-baz\""}`,
|
||||
url: "https://examp.le?foo-bar=bar-baz",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
r := httptest.NewRequest(http.MethodGet, tc.url, http.NoBody)
|
||||
t.Run(tc.name, testHandle(NewQueryParam(types.Config{}), r, tc))
|
||||
}
|
||||
}
|
||||
|
||||
func Test_profilerHandle_profilerGlobal(t *testing.T) {
|
||||
type (
|
||||
tfp struct {
|
||||
name string
|
||||
cfg types.Config
|
||||
r *http.Request
|
||||
exp *h.Request
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
rr = httptest.NewRequest(http.MethodGet, "/foo", http.NoBody)
|
||||
tcc = []tfp{
|
||||
{
|
||||
name: "skip profiler hit on profiler global = true",
|
||||
cfg: types.Config{Profiler: struct {
|
||||
Enabled bool
|
||||
Global bool
|
||||
}{Global: true}},
|
||||
r: rr,
|
||||
exp: nil,
|
||||
},
|
||||
{
|
||||
name: "add profiler hit on profiler global = false",
|
||||
cfg: types.Config{Profiler: struct {
|
||||
Enabled bool
|
||||
Global bool
|
||||
}{Global: false}},
|
||||
r: rr,
|
||||
exp: createRequest(rr),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
ph = NewProfiler(tc.cfg)
|
||||
hfn = ph.Handler()
|
||||
|
||||
hr = createRequest(tc.r)
|
||||
hit = &prf.Hit{}
|
||||
)
|
||||
|
||||
req.Nil(hit.R)
|
||||
|
||||
tc.r = tc.r.WithContext(agctx.ScopeToContext(tc.r.Context(), &types.Scp{"request": hr}))
|
||||
tc.r = tc.r.WithContext(agctx.ProfilerToContext(tc.r.Context(), hit))
|
||||
|
||||
err := hfn(httptest.NewRecorder(), tc.r)
|
||||
req.NoError(err)
|
||||
|
||||
scoped := agctx.ProfilerFromContext(tc.r.Context())
|
||||
|
||||
req.Equal(tc.exp, scoped.R)
|
||||
}
|
||||
}
|
||||
|
||||
func createRequest(r *http.Request) (hr *h.Request) {
|
||||
hr, _ = h.NewRequest(r)
|
||||
return
|
||||
}
|
||||
|
||||
func testMerge(h types.Handler, tc tf) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
_, err := h.Merge([]byte(tc.expr), types.Config{})
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
} else {
|
||||
req.NoError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testHandle(h types.Handler, r *http.Request, tc tf) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
h, err := h.Merge([]byte(tc.expr), types.Config{})
|
||||
|
||||
req.NoError(err)
|
||||
|
||||
hfn := h.Handler()
|
||||
|
||||
err = hfn(httptest.NewRecorder(), r)
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
} else {
|
||||
req.NoError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
303
server/pkg/apigw/filter/processer.go
Normal file
303
server/pkg/apigw/filter/processer.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/automation/automation"
|
||||
atypes "github.com/cortezaproject/corteza/server/automation/types"
|
||||
agctx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
pe "github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/pkg/jsenv"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
workflow struct {
|
||||
types.FilterMeta
|
||||
d WfExecer
|
||||
cfg types.Config
|
||||
|
||||
params struct {
|
||||
Workflow uint64 `json:"workflow,string"`
|
||||
}
|
||||
}
|
||||
|
||||
WfExecer interface {
|
||||
Load(ctx context.Context) error
|
||||
Exec(ctx context.Context, workflowID uint64, p atypes.WorkflowExecParams) (*expr.Vars, uint64, atypes.Stacktrace, error)
|
||||
Search(ctx context.Context, filter atypes.WorkflowFilter) (atypes.WorkflowSet, atypes.WorkflowFilter, error)
|
||||
}
|
||||
|
||||
processerPayload struct {
|
||||
types.FilterMeta
|
||||
|
||||
vm jsenv.Vm
|
||||
fn *jsenv.Fn
|
||||
log *zap.Logger
|
||||
cfg types.Config
|
||||
|
||||
params struct {
|
||||
Func string `json:"jsfunc"`
|
||||
Encode bool `json:"encode"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
func NewWorkflow(cfg types.Config, wf WfExecer) (p *workflow) {
|
||||
p = &workflow{}
|
||||
|
||||
p.d = wf
|
||||
p.cfg = cfg
|
||||
|
||||
p.Name = "workflow"
|
||||
p.Label = "Workflow processer"
|
||||
p.Kind = types.Processer
|
||||
|
||||
p.Args = []*types.FilterMetaArg{
|
||||
{
|
||||
Type: "workflow",
|
||||
Label: "workflow",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (h workflow) New(cfg types.Config) types.Handler {
|
||||
return NewWorkflow(cfg, h.d)
|
||||
}
|
||||
|
||||
func (h workflow) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h workflow) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", h.Name, h.Label)
|
||||
}
|
||||
|
||||
func (h workflow) Meta() types.FilterMeta {
|
||||
return h.FilterMeta
|
||||
}
|
||||
|
||||
func (h *workflow) Merge(params []byte, cfg types.Config) (types.Handler, error) {
|
||||
var (
|
||||
t = struct {
|
||||
Workflow string `json:"workflow"`
|
||||
}{}
|
||||
|
||||
ctx = auth.SetIdentityToContext(context.Background(), auth.ServiceUser())
|
||||
)
|
||||
|
||||
h.cfg = cfg
|
||||
|
||||
err := json.NewDecoder(bytes.NewBuffer(params)).Decode(&t)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(t.Workflow)
|
||||
|
||||
if err == nil {
|
||||
h.params.Workflow = uint64(i)
|
||||
return h, h.d.Load(ctx)
|
||||
}
|
||||
|
||||
if wf, _, err := h.d.Search(ctx, atypes.WorkflowFilter{Query: t.Workflow}); err != nil {
|
||||
return h, err
|
||||
} else {
|
||||
h.params.Workflow = wf[0].ID
|
||||
}
|
||||
|
||||
// preload workflow cache
|
||||
return h, h.d.Load(ctx)
|
||||
}
|
||||
|
||||
func (h workflow) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) error {
|
||||
var (
|
||||
err error
|
||||
ctx = r.Context()
|
||||
scope = agctx.ScopeFromContext(ctx)
|
||||
)
|
||||
// cleanup scope for wf
|
||||
scp := filterScope(scope, "opts")
|
||||
|
||||
in, err := expr.NewVars(scp.Dict())
|
||||
|
||||
if err != nil {
|
||||
return pe.Internal("could not validate request data: %v", err)
|
||||
}
|
||||
|
||||
wp := atypes.WorkflowExecParams{
|
||||
Trace: true,
|
||||
// todo depending on settings per-route
|
||||
Async: false,
|
||||
// todo depending on settings per-route
|
||||
Wait: true,
|
||||
Input: in,
|
||||
}
|
||||
|
||||
out, _, _, err := h.d.Exec(ctx, h.params.Workflow, wp)
|
||||
|
||||
if err != nil {
|
||||
return pe.Internal("could not exec workflow: %v", err)
|
||||
}
|
||||
|
||||
// merge out with scope
|
||||
merged, err := in.Merge(out)
|
||||
|
||||
if err != nil {
|
||||
return pe.Internal("could not receive workflow results: %v", err)
|
||||
}
|
||||
|
||||
mm, err := expr.CastToVars(merged)
|
||||
|
||||
if err != nil {
|
||||
return pe.Internal("could not receive workflow results: %v", err)
|
||||
}
|
||||
|
||||
for k, v := range mm {
|
||||
scope.Set(k, v)
|
||||
}
|
||||
|
||||
scope = filterScope(scope, "eventType", "resourceType", "invoker")
|
||||
|
||||
// update scope for next items in pipeline
|
||||
_ = r.WithContext(agctx.ScopeToContext(ctx, scope))
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewPayload(cfg types.Config, l *zap.Logger) (p *processerPayload) {
|
||||
p = &processerPayload{}
|
||||
|
||||
p.vm = jsenv.New(jsenv.NewTransformer(jsenv.LoaderJS, jsenv.TargetES2016))
|
||||
p.log = l
|
||||
p.cfg = cfg
|
||||
|
||||
p.Name = "payload"
|
||||
p.Label = "Payload processer"
|
||||
p.Kind = types.Processer
|
||||
|
||||
p.Args = []*types.FilterMetaArg{
|
||||
{
|
||||
Type: "jsfunc",
|
||||
Label: "jsfunc",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
// register a request body reader
|
||||
p.vm.Register("readRequestBody", automation.ReadRequestBody)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (h processerPayload) New(cfg types.Config) types.Handler {
|
||||
return NewPayload(cfg, h.log)
|
||||
}
|
||||
|
||||
func (h processerPayload) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h processerPayload) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", h.Name, h.Label)
|
||||
}
|
||||
|
||||
func (h processerPayload) Meta() types.FilterMeta {
|
||||
return h.FilterMeta
|
||||
}
|
||||
|
||||
func (h *processerPayload) Merge(params []byte, cfg types.Config) (types.Handler, error) {
|
||||
h.cfg = cfg
|
||||
|
||||
err := json.NewDecoder(bytes.NewBuffer(params)).Decode(&h.params)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if h.params.Func == "" {
|
||||
return nil, fmt.Errorf("could not register function, body empty")
|
||||
}
|
||||
|
||||
if h.fn, err = h.vm.RegisterFunction(h.params.Func); err != nil {
|
||||
return nil, fmt.Errorf("could not register function, invalid body: %s", err)
|
||||
}
|
||||
|
||||
return h, err
|
||||
}
|
||||
|
||||
func (h processerPayload) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) (err error) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
scope = agctx.ScopeFromContext(ctx)
|
||||
)
|
||||
|
||||
// cleanup scope for js
|
||||
scp := filterScope(scope, "opts")
|
||||
|
||||
// check fn type
|
||||
if err != nil {
|
||||
return pe.InvalidData("could not register function: %v", err)
|
||||
}
|
||||
|
||||
// need to find a consistent approach to the workflow jsenv function
|
||||
// wf: input expr Var and the resulting variable in the jsenv is `input`
|
||||
// apigw: input types.Scope and the resulting variable in the jsenv is `input['some_var']`
|
||||
out, err := h.fn.Exec(h.vm.New(scp))
|
||||
|
||||
if err != nil {
|
||||
return pe.Internal("could not exec payload function: %v", err)
|
||||
}
|
||||
|
||||
// add to scope, so next steps can get the structure
|
||||
scope.Set("payload", out)
|
||||
|
||||
// check if string
|
||||
switch out.(type) {
|
||||
case string, []byte:
|
||||
// handling the newline, to keep the consistency with the json encoder
|
||||
// which automatically appends the newline
|
||||
_, err = rw.Write([]byte(fmt.Sprintf("%s\n", out)))
|
||||
default:
|
||||
err = json.NewEncoder(rw).Encode(out)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return pe.Internal("could not write to response body: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h processerPayload) VM() jsenv.Vm {
|
||||
return h.vm
|
||||
}
|
||||
|
||||
func filterScope(scope *types.Scp, kk ...string) (s *types.Scp) {
|
||||
s = scope.Filter(func(k string, v interface{}) bool {
|
||||
for _, v := range kk {
|
||||
if k == v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
244
server/pkg/apigw/filter/processer_test.go
Normal file
244
server/pkg/apigw/filter/processer_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
atypes "github.com/cortezaproject/corteza/server/automation/types"
|
||||
agctx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
h "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/cortezaproject/corteza/server/pkg/wfexec"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
wfServicer struct {
|
||||
load func(ctx context.Context) error
|
||||
exec func(ctx context.Context, workflowID uint64, p atypes.WorkflowExecParams) (*expr.Vars, uint64, atypes.Stacktrace, error)
|
||||
search func(ctx context.Context, filter atypes.WorkflowFilter) (atypes.WorkflowSet, atypes.WorkflowFilter, error)
|
||||
}
|
||||
)
|
||||
|
||||
func Test_processerWorkflow(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
err string
|
||||
params string
|
||||
wfs wfServicer
|
||||
exp []string
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "workflow processer",
|
||||
exp: []string{"opts", "request"},
|
||||
params: `{"workflow":"1"}`,
|
||||
err: `could not exec workflow: mocked error`,
|
||||
wfs: wfServicer{
|
||||
load: func(ctx context.Context) error {
|
||||
return nil
|
||||
},
|
||||
exec: func(ctx context.Context, workflowID uint64, p atypes.WorkflowExecParams) (*expr.Vars, uint64, atypes.Stacktrace, error) {
|
||||
return must(expr.NewVars(map[string]interface{}{"foo": "bar"})), 0, make([]*wfexec.Frame, 0), fmt.Errorf("mocked error")
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow processer",
|
||||
exp: []string{"foo", "request", "opts"},
|
||||
params: `{"workflow":"1"}`,
|
||||
wfs: wfServicer{
|
||||
load: func(ctx context.Context) error {
|
||||
return nil
|
||||
},
|
||||
exec: func(ctx context.Context, workflowID uint64, p atypes.WorkflowExecParams) (*expr.Vars, uint64, atypes.Stacktrace, error) {
|
||||
return must(expr.NewVars(map[string]interface{}{"foo": "bar"})), 0, make([]*wfexec.Frame, 0), nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
rc = httptest.NewRecorder()
|
||||
rq, _ = http.NewRequest("POST", "/foo", http.NoBody)
|
||||
ar, err = h.NewRequest(rq)
|
||||
pp = NewWorkflow(types.Config{}, tc.wfs)
|
||||
)
|
||||
|
||||
_, err = pp.Merge([]byte(tc.params), types.Config{})
|
||||
req.NoError(err)
|
||||
|
||||
scope := &types.Scp{
|
||||
"opts": options.Apigw(),
|
||||
"request": ar,
|
||||
}
|
||||
|
||||
rq = rq.WithContext(agctx.ScopeToContext(context.Background(), scope))
|
||||
|
||||
hn := pp.Handler()
|
||||
err = hn(rc, rq)
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
} else {
|
||||
req.NoError(err)
|
||||
}
|
||||
|
||||
req.ElementsMatch(tc.exp, agctx.ScopeFromContext(rq.Context()).Keys())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_processerPayload(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
err string
|
||||
errv string
|
||||
params string
|
||||
exp string
|
||||
rq *http.Request
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "payload processer parse request body",
|
||||
rq: &http.Request{
|
||||
Method: "POST",
|
||||
Body: ioutil.NopCloser(strings.NewReader(`[1,2,3]`)),
|
||||
},
|
||||
exp: "2\n",
|
||||
params: prepareFuncPayload(t, `
|
||||
const b = JSON.parse(readRequestBody(input.Get('request')));
|
||||
return b[1];
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "payload processer js map request body",
|
||||
rq: &http.Request{
|
||||
Method: "POST",
|
||||
Body: ioutil.NopCloser(strings.NewReader(`[{"name":"johnny", "surname":"mnemonic"},{"name":"johnny", "surname":"knoxville"}]`)),
|
||||
},
|
||||
exp: "{\"count\":2,\"results\":[{\"fullname\":\"Johnny Mnemonic\"},{\"fullname\":\"Johnny Knoxville\"}]}\n",
|
||||
params: prepareFuncPayload(t, `
|
||||
const readOnce = JSON.parse(readRequestBody(input.Get('request')));
|
||||
const readTwice = JSON.parse(readRequestBody(input.Get('request')));
|
||||
|
||||
return {
|
||||
"results":
|
||||
readTwice.map(function({ name, surname }) {
|
||||
return {
|
||||
"fullname": name[0].toUpperCase() + name.substring(1) + " " + surname[0].toUpperCase() + surname.substring(1)
|
||||
}
|
||||
}),
|
||||
"count": readTwice.length
|
||||
};
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "payload processer empty function",
|
||||
rq: &http.Request{
|
||||
Method: "POST",
|
||||
Body: ioutil.NopCloser(strings.NewReader(`[{"name":"johnny", "surname":"mnemonic"},{"name":"johnny", "surname":"knoxville"}]`)),
|
||||
},
|
||||
params: prepareFuncPayload(t, ``),
|
||||
errv: `could not register function, body empty`,
|
||||
},
|
||||
{
|
||||
name: "payload processer invalid body",
|
||||
rq: &http.Request{
|
||||
Method: "POST",
|
||||
Body: ioutil.NopCloser(strings.NewReader(`[{"name":"johnny", "surname":"mnemonic"},{"name":"johnny", "surname":"knoxville"}]`)),
|
||||
},
|
||||
params: prepareFuncPayload(t, `.foo`),
|
||||
errv: `could not register function, invalid body: could not transform payload: Unexpected "."`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
rc = httptest.NewRecorder()
|
||||
ar, err = h.NewRequest(tc.rq)
|
||||
|
||||
cfg = types.Config{}
|
||||
)
|
||||
|
||||
pp := NewPayload(cfg, zap.NewNop())
|
||||
_, err = pp.Merge([]byte(tc.params), cfg)
|
||||
|
||||
if tc.errv != "" {
|
||||
req.EqualError(err, tc.errv)
|
||||
return
|
||||
} else {
|
||||
req.NoError(err)
|
||||
}
|
||||
|
||||
scope := &types.Scp{
|
||||
"opts": options.Apigw(),
|
||||
"request": ar,
|
||||
}
|
||||
|
||||
tc.rq = tc.rq.WithContext(agctx.ScopeToContext(context.Background(), scope))
|
||||
|
||||
hn := pp.Handler()
|
||||
err = hn(rc, tc.rq)
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
} else {
|
||||
req.NoError(err)
|
||||
req.Equal(tc.exp, rc.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func prepareFuncPayload(t *testing.T, s string) string {
|
||||
aux, err := json.Marshal(map[string]string{"jsfunc": s})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return string(aux)
|
||||
}
|
||||
|
||||
func (f wfServicer) Load(ctx context.Context) error {
|
||||
return f.load(ctx)
|
||||
}
|
||||
|
||||
func (f wfServicer) Exec(ctx context.Context, workflowID uint64, p atypes.WorkflowExecParams) (*expr.Vars, uint64, atypes.Stacktrace, error) {
|
||||
return f.exec(ctx, workflowID, p)
|
||||
}
|
||||
|
||||
func (f wfServicer) Search(ctx context.Context, filter atypes.WorkflowFilter) (atypes.WorkflowSet, atypes.WorkflowFilter, error) {
|
||||
return f.search(ctx, filter)
|
||||
}
|
||||
|
||||
func must(v *expr.Vars, err error) *expr.Vars {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
211
server/pkg/apigw/filter/proxy/proxy.go
Normal file
211
server/pkg/apigw/filter/proxy/proxy.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
actx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
pe "github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
hopHeaders = []string{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"Te",
|
||||
"Trailers",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
proxy struct {
|
||||
types.FilterMeta
|
||||
|
||||
a ProxyAuthServicer
|
||||
s types.SecureStorager
|
||||
c *http.Client
|
||||
|
||||
log *zap.Logger
|
||||
cfg types.Config
|
||||
|
||||
params struct {
|
||||
Location string `json:"location"`
|
||||
Auth ProxyAuthParams `json:"auth"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
func New(cfg types.Config, l *zap.Logger, c *http.Client, s types.SecureStorager) (p *proxy) {
|
||||
p = &proxy{}
|
||||
|
||||
p.c = c
|
||||
p.s = s
|
||||
p.log = l
|
||||
p.cfg = cfg
|
||||
|
||||
p.Name = "proxy"
|
||||
p.Label = "Proxy processer"
|
||||
p.Kind = types.Processer
|
||||
|
||||
p.Args = []*types.FilterMetaArg{
|
||||
{
|
||||
Type: "text",
|
||||
Label: "location",
|
||||
Options: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (h proxy) New(cfg types.Config) types.Handler {
|
||||
return New(cfg, h.log, h.c, h.s)
|
||||
}
|
||||
|
||||
func (h proxy) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h proxy) String() string {
|
||||
return fmt.Sprintf("apigw filter %s (%s)", h.Name, h.Label)
|
||||
}
|
||||
|
||||
func (h proxy) Meta() types.FilterMeta {
|
||||
return h.FilterMeta
|
||||
}
|
||||
|
||||
func (h *proxy) Merge(params []byte, cfg types.Config) (types.Handler, error) {
|
||||
h.cfg = cfg
|
||||
|
||||
err := json.NewDecoder(bytes.NewBuffer(params)).Decode(&h.params)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get the auth mechanism
|
||||
h.a, err = NewProxyAuthServicer(h.c, h.params.Auth, h.s)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not load auth servicer for proxying: %s", err)
|
||||
}
|
||||
|
||||
return h, err
|
||||
}
|
||||
|
||||
func (h proxy) Handler() types.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) (err error) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
scope = actx.ScopeFromContext(ctx)
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, scope.Opts().ProxyOutboundTimeout)
|
||||
defer cancel()
|
||||
|
||||
log := h.log.With(zap.String("ref", h.Name))
|
||||
|
||||
outreq := r.Clone(ctx)
|
||||
|
||||
l, err := url.ParseRequestURI(h.params.Location)
|
||||
|
||||
if err != nil {
|
||||
return pe.InvalidData("could not parse destination location for proxying: (%v)", err)
|
||||
}
|
||||
|
||||
outreq.URL = l
|
||||
outreq.RequestURI = ""
|
||||
outreq.Method = r.Method
|
||||
outreq.Host = l.Hostname()
|
||||
|
||||
// use authservicer, set any additional headers
|
||||
err = h.a.Do(outreq)
|
||||
|
||||
if err != nil {
|
||||
return pe.External("could not authenticate to external auth: (%v)", err)
|
||||
}
|
||||
|
||||
// merge the old query params to the new request
|
||||
// do not overwrite old ones
|
||||
// do it after the authServicer, since we also may add them there
|
||||
mergeQueryParams(r, outreq)
|
||||
|
||||
if scope.Opts().ProxyEnableDebugLog {
|
||||
o, _ := httputil.DumpRequestOut(outreq, false)
|
||||
log.Debug("proxy outbound request", zap.Any("request", string(o)))
|
||||
}
|
||||
|
||||
// temporary metrics before the proper functionality
|
||||
startTime := time.Now()
|
||||
|
||||
// todo - disable / enable follow redirects, already
|
||||
// added to options
|
||||
resp, err := h.c.Do(outreq)
|
||||
|
||||
if err != nil {
|
||||
return pe.Internal("could not proxy request: (%v)", err)
|
||||
}
|
||||
|
||||
if scope.Opts().ProxyEnableDebugLog {
|
||||
o, _ := httputil.DumpResponse(resp, false)
|
||||
log.Debug("proxy outbound response", zap.Any("request", string(o)), zap.Duration("duration", time.Since(startTime)))
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return pe.Internal("could not read body on proxy request: (%v)", err)
|
||||
}
|
||||
|
||||
mergeHeaders(resp.Header, rw.Header())
|
||||
|
||||
// add to writer
|
||||
rw.Write(b)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func mergeHeaders(orig, dest http.Header) {
|
||||
OUTER:
|
||||
for name, values := range orig {
|
||||
// skip headers that need to be omitted
|
||||
// when proxying
|
||||
for _, v := range hopHeaders {
|
||||
if v == name {
|
||||
continue OUTER
|
||||
}
|
||||
}
|
||||
dest[name] = values
|
||||
}
|
||||
}
|
||||
|
||||
func mergeQueryParams(orig, dest *http.Request) {
|
||||
origValues := dest.URL.Query()
|
||||
|
||||
for k, qp := range orig.URL.Query() {
|
||||
// skip existing
|
||||
if dest.URL.Query().Get(k) != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range qp {
|
||||
origValues.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
dest.URL.RawQuery = origValues.Encode()
|
||||
}
|
||||
337
server/pkg/apigw/filter/proxy/proxy_auth.go
Normal file
337
server/pkg/apigw/filter/proxy/proxy_auth.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/http/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
proxyAuthTypeOauth2 proxyAuthType = "oauth2"
|
||||
proxyAuthTypeHeader proxyAuthType = "header"
|
||||
proxyAuthTypeQuery proxyAuthType = "query"
|
||||
proxyAuthTypeBasic proxyAuthType = "basic"
|
||||
proxyAuthTypeJWT proxyAuthType = "jwt"
|
||||
proxyAuthTypeNoop proxyAuthType = "noop"
|
||||
)
|
||||
|
||||
type (
|
||||
ProxyAuthServicer interface {
|
||||
Do(*http.Request) error
|
||||
}
|
||||
|
||||
proxyAuthServicerNoop struct{}
|
||||
|
||||
proxyAuthServicerHeader struct {
|
||||
params map[string]interface{}
|
||||
}
|
||||
|
||||
proxyAuthServicerQuery struct {
|
||||
params map[string]interface{}
|
||||
}
|
||||
|
||||
proxyAuthServicerBasic struct {
|
||||
servicer auth.ServicerBasic
|
||||
|
||||
user string
|
||||
pass string
|
||||
}
|
||||
|
||||
proxyAuthServicerOauth2 struct {
|
||||
c *http.Client
|
||||
servicer auth.ServicerOauth2
|
||||
|
||||
client string
|
||||
secret string
|
||||
scope []string
|
||||
tokenUrl string
|
||||
|
||||
Args []*proxyAuthMetaArg `json:"params"`
|
||||
}
|
||||
|
||||
proxyAuthServicerJWT struct {
|
||||
JWT string
|
||||
}
|
||||
|
||||
proxyAuthType string
|
||||
|
||||
ProxyAuthParams struct {
|
||||
Type proxyAuthType `json:"type"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
proxyAuthMetaArg struct {
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Example string `json:"example,omitempty"`
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
ProxyAuthDefinition struct {
|
||||
Type proxyAuthType `json:"type"`
|
||||
Params []*proxyAuthMetaArg `json:"params"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewProxyAuthServicer(c *http.Client, p ProxyAuthParams, s types.SecureStorager) (ProxyAuthServicer, error) {
|
||||
switch p.Type {
|
||||
case proxyAuthTypeHeader:
|
||||
return newProxyAuthHeader(p)
|
||||
case proxyAuthTypeQuery:
|
||||
return newProxyAuthQuery(p)
|
||||
case proxyAuthTypeBasic:
|
||||
return newProxyAuthBasic(p)
|
||||
case proxyAuthTypeOauth2:
|
||||
return newProxyAuthOauth2(p, c, s)
|
||||
case proxyAuthTypeJWT:
|
||||
return newProxyAuthJWT(p)
|
||||
default:
|
||||
return proxyAuthServicerNoop{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newProxyAuthHeader(p ProxyAuthParams) (s proxyAuthServicerHeader, err error) {
|
||||
s = proxyAuthServicerHeader{
|
||||
params: p.Params,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func newProxyAuthQuery(p ProxyAuthParams) (s proxyAuthServicerQuery, err error) {
|
||||
s = proxyAuthServicerQuery{
|
||||
params: p.Params,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func newProxyAuthBasic(p ProxyAuthParams) (s proxyAuthServicerBasic, err error) {
|
||||
var (
|
||||
ok bool
|
||||
user, pass string
|
||||
)
|
||||
|
||||
if user, ok = p.Params["username"].(string); !ok {
|
||||
err = fmt.Errorf("invalid param username")
|
||||
return
|
||||
}
|
||||
|
||||
if pass, ok = p.Params["password"].(string); !ok {
|
||||
err = fmt.Errorf("invalid param password")
|
||||
return
|
||||
}
|
||||
|
||||
servicer, _ := auth.NewBasic(auth.BasicParams{
|
||||
User: user,
|
||||
Pass: pass,
|
||||
})
|
||||
|
||||
s = proxyAuthServicerBasic{user: user, pass: pass, servicer: servicer}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func newProxyAuthOauth2(p ProxyAuthParams, c *http.Client, s types.SecureStorager) (ss proxyAuthServicerOauth2, err error) {
|
||||
var (
|
||||
u *url.URL
|
||||
ok bool
|
||||
client, secret, tokenUrl string
|
||||
scope []string = []string{}
|
||||
)
|
||||
|
||||
if client, ok = p.Params["client"].(string); !ok {
|
||||
err = fmt.Errorf("invalid param client")
|
||||
return
|
||||
}
|
||||
|
||||
if secret, ok = p.Params["secret"].(string); !ok {
|
||||
err = fmt.Errorf("invalid param secret")
|
||||
return
|
||||
}
|
||||
|
||||
if scopes, ok := p.Params["scope"].(string); ok {
|
||||
scope = strings.Fields(scopes)
|
||||
}
|
||||
|
||||
if u, err = url.Parse(p.Params["token_url"].(string)); err != nil {
|
||||
err = fmt.Errorf("invalid param token url: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
servicer, err := auth.NewOauth2(auth.Oauth2Params{
|
||||
Client: client,
|
||||
Secret: secret,
|
||||
Scope: scope,
|
||||
TokenUrl: u,
|
||||
}, c, s)
|
||||
|
||||
ss = proxyAuthServicerOauth2{
|
||||
c: c,
|
||||
client: client,
|
||||
servicer: servicer,
|
||||
secret: secret,
|
||||
scope: scope,
|
||||
tokenUrl: tokenUrl,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func newProxyAuthJWT(p ProxyAuthParams) (ss proxyAuthServicerJWT, err error) {
|
||||
var (
|
||||
ok bool
|
||||
jwt string
|
||||
)
|
||||
|
||||
if jwt, ok = p.Params["jwt"].(string); !ok {
|
||||
err = fmt.Errorf("invalid param jwt")
|
||||
return
|
||||
}
|
||||
|
||||
ss = proxyAuthServicerJWT{
|
||||
JWT: jwt,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerHeader) Do(r *http.Request) error {
|
||||
for k, v := range s.params {
|
||||
r.Header.Add(k, v.(string))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerQuery) Do(r *http.Request) error {
|
||||
if len(s.params) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
for k, v := range s.params {
|
||||
q.Set(k, v.(string))
|
||||
}
|
||||
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerBasic) Do(r *http.Request) error {
|
||||
r.Header.Set("Authorization", "Basic "+s.servicer.Do(r.Context()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerOauth2) Do(r *http.Request) error {
|
||||
t, err := s.servicer.Do(r.Context())
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.Header.Set("Authorization", "Bearer "+t.AccessToken)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerJWT) Do(r *http.Request) (err error) {
|
||||
// set up the request and get the jwt
|
||||
r.Header.Set("Authorization", "Bearer "+s.JWT)
|
||||
return
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerNoop) Do(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerOauth2) Def() *ProxyAuthDefinition {
|
||||
return &ProxyAuthDefinition{
|
||||
Type: proxyAuthTypeOauth2,
|
||||
Params: []*proxyAuthMetaArg{
|
||||
{
|
||||
Label: "client",
|
||||
Type: "text",
|
||||
Example: "",
|
||||
},
|
||||
{
|
||||
Label: "secret",
|
||||
Type: "text",
|
||||
Example: "",
|
||||
},
|
||||
{
|
||||
Label: "scope",
|
||||
Type: "text",
|
||||
Example: "Keep the scopes separated with spaces ie.: 'scope1 scope2'",
|
||||
},
|
||||
{
|
||||
Label: "token_url",
|
||||
Type: "text",
|
||||
Example: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerHeader) Def() *ProxyAuthDefinition {
|
||||
return &ProxyAuthDefinition{
|
||||
Type: proxyAuthTypeHeader,
|
||||
Params: []*proxyAuthMetaArg{
|
||||
{
|
||||
Label: "header",
|
||||
Type: "text",
|
||||
Example: "",
|
||||
},
|
||||
{
|
||||
Label: "key",
|
||||
Type: "text",
|
||||
Example: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerBasic) Def() *ProxyAuthDefinition {
|
||||
return &ProxyAuthDefinition{
|
||||
Type: proxyAuthTypeBasic,
|
||||
Params: []*proxyAuthMetaArg{
|
||||
{
|
||||
Label: "username",
|
||||
Type: "text",
|
||||
Example: "",
|
||||
},
|
||||
{
|
||||
Label: "password",
|
||||
Type: "text",
|
||||
Example: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s proxyAuthServicerJWT) Def() *ProxyAuthDefinition {
|
||||
return &ProxyAuthDefinition{
|
||||
Type: proxyAuthTypeJWT,
|
||||
Params: []*proxyAuthMetaArg{
|
||||
{
|
||||
Label: "jwt",
|
||||
Type: "text",
|
||||
Example: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ProxyAuthDef() []*ProxyAuthDefinition {
|
||||
return []*ProxyAuthDefinition{
|
||||
proxyAuthServicerOauth2{}.Def(),
|
||||
proxyAuthServicerHeader{}.Def(),
|
||||
proxyAuthServicerBasic{}.Def(),
|
||||
proxyAuthServicerJWT{}.Def(),
|
||||
}
|
||||
}
|
||||
124
server/pkg/apigw/filter/proxy/proxy_auth_test.go
Normal file
124
server/pkg/apigw/filter/proxy/proxy_auth_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_authDo(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
err string
|
||||
errv string
|
||||
params ProxyAuthParams
|
||||
exp http.Header
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "auth header match headers",
|
||||
params: ProxyAuthParams{
|
||||
Type: proxyAuthTypeHeader,
|
||||
Params: map[string]interface{}{
|
||||
"Client-Id": "123455",
|
||||
"Client_credentials": "pass1234",
|
||||
},
|
||||
},
|
||||
exp: http.Header{
|
||||
"Client-Id": []string{"123455"},
|
||||
"Client_credentials": []string{"pass1234"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "auth header match canonicalized headers",
|
||||
params: ProxyAuthParams{
|
||||
Type: proxyAuthTypeHeader,
|
||||
Params: map[string]interface{}{
|
||||
"camelCaseHeader": "123455",
|
||||
},
|
||||
},
|
||||
exp: http.Header{
|
||||
"Camelcaseheader": []string{"123455"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "auth basic match headers",
|
||||
params: ProxyAuthParams{
|
||||
Type: proxyAuthTypeBasic,
|
||||
Params: map[string]interface{}{
|
||||
"username": "user",
|
||||
"password": "pass1234",
|
||||
},
|
||||
},
|
||||
exp: http.Header{"Authorization": []string{"Basic dXNlcjpwYXNzMTIzNA=="}},
|
||||
},
|
||||
{
|
||||
name: "auth basic match headers fail user validation",
|
||||
params: ProxyAuthParams{
|
||||
Type: proxyAuthTypeBasic,
|
||||
Params: map[string]interface{}{"password": "pass1234"},
|
||||
},
|
||||
exp: http.Header{},
|
||||
errv: "invalid param username",
|
||||
},
|
||||
{
|
||||
name: "auth basic match headers fail pass validation",
|
||||
params: ProxyAuthParams{
|
||||
Type: proxyAuthTypeBasic,
|
||||
Params: map[string]interface{}{"username": "user"},
|
||||
},
|
||||
exp: http.Header{},
|
||||
errv: "invalid param password",
|
||||
},
|
||||
{
|
||||
name: "noop default fallback",
|
||||
params: ProxyAuthParams{},
|
||||
exp: http.Header{},
|
||||
},
|
||||
{
|
||||
name: "auth JWT token",
|
||||
params: ProxyAuthParams{
|
||||
Type: proxyAuthTypeJWT,
|
||||
Params: map[string]interface{}{"jwt": "1234"},
|
||||
},
|
||||
exp: http.Header{
|
||||
"Authorization": []string{"Bearer 1234"},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
c = http.DefaultClient
|
||||
)
|
||||
|
||||
c.Transport = types.MockRoundTripper(func(r *http.Request) (rs *http.Response, err error) { return })
|
||||
|
||||
rq, _ := http.NewRequest("POST", "/foo", http.NoBody)
|
||||
|
||||
authServicer, err := NewProxyAuthServicer(c, tc.params, struct{}{})
|
||||
|
||||
if tc.errv != "" {
|
||||
req.EqualError(err, tc.errv)
|
||||
return
|
||||
}
|
||||
|
||||
err = authServicer.Do(rq)
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
} else {
|
||||
req.Equal(tc.exp, rq.Header)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
201
server/pkg/apigw/filter/proxy/proxy_test.go
Normal file
201
server/pkg/apigw/filter/proxy/proxy_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agctx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Test_proxy(t *testing.T) {
|
||||
type (
|
||||
exp struct {
|
||||
Status int
|
||||
Header http.Header
|
||||
Body *bytes.Buffer
|
||||
}
|
||||
|
||||
tf struct {
|
||||
name string
|
||||
err string
|
||||
params string
|
||||
exp exp
|
||||
rq *http.Request
|
||||
fn func(*require.Assertions) types.MockRoundTripper
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "proxy processer with auth headers",
|
||||
fn: func(req *require.Assertions) types.MockRoundTripper {
|
||||
return func(r *http.Request) (rs *http.Response, err error) {
|
||||
rs = &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("default response")),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
params: `{"location": "/foo", "auth": {"type": "header", "params": {"access-token": "123", "client": "456"}}}`,
|
||||
exp: exp{
|
||||
Status: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}},
|
||||
Body: bytes.NewBufferString("default response"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proxy processer with auth query params",
|
||||
fn: func(req *require.Assertions) types.MockRoundTripper {
|
||||
return func(r *http.Request) (rs *http.Response, err error) {
|
||||
rs = &http.Response{}
|
||||
req.Equal("access-param=123%2B456", r.URL.RawQuery)
|
||||
return
|
||||
}
|
||||
},
|
||||
params: `{"location": "/foo", "auth": {"type": "query", "params": {"access-param": "123+456"}}}`,
|
||||
exp: exp{
|
||||
Status: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}},
|
||||
Body: bytes.NewBuffer(nil),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proxy processer with auth headers unauthorized",
|
||||
fn: func(req *require.Assertions) types.MockRoundTripper {
|
||||
return func(r *http.Request) (rs *http.Response, err error) {
|
||||
rs = &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Body: io.NopCloser(strings.NewReader("unauthorized response")),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
params: `{"location": "/foo", "auth": {"type": "header", "params": {"access-token": "123", "client": "456"}}}`,
|
||||
exp: exp{
|
||||
Status: http.StatusUnauthorized,
|
||||
Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}},
|
||||
Body: bytes.NewBufferString("unauthorized response"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proxy processer params parse error",
|
||||
params: `{"location": "invalid url", "auth": {"type": "header", "params": {}}}`,
|
||||
err: `could not parse destination location for proxying: (parse "invalid url": invalid URI for request)`,
|
||||
},
|
||||
{
|
||||
name: "proxy processer params request error",
|
||||
fn: func(req *require.Assertions) types.MockRoundTripper {
|
||||
return func(r *http.Request) (rs *http.Response, err error) {
|
||||
err = fmt.Errorf("error on client.Do")
|
||||
return
|
||||
}
|
||||
},
|
||||
params: `{"location": "https://example.com", "auth": {"type": "header", "params": {}}}`,
|
||||
err: `could not proxy request: (Post "https://example.com": error on client.Do)`,
|
||||
},
|
||||
{
|
||||
name: "proxy processer hop headers removed",
|
||||
fn: func(req *require.Assertions) types.MockRoundTripper {
|
||||
return func(r *http.Request) (rs *http.Response, err error) {
|
||||
rs = &http.Response{
|
||||
Header: http.Header{
|
||||
"Proxy-Authenticate": []string{`Basic realm="Access to the internal site"`},
|
||||
"Content-Type": []string{"application/json; charset=utf-8"},
|
||||
},
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("default response")),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
params: `{"location": "https://example.com", "auth": {"type": "header", "params": {}}}`,
|
||||
exp: exp{
|
||||
Status: http.StatusUnauthorized,
|
||||
Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
|
||||
Body: bytes.NewBufferString("default response"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proxy processer query parameters merged",
|
||||
fn: func(req *require.Assertions) types.MockRoundTripper {
|
||||
return func(r *http.Request) (rs *http.Response, err error) {
|
||||
rs = &http.Response{}
|
||||
req.Equal("access-param=123%2B456&addedCustomQueryParam=true", r.URL.RawQuery)
|
||||
return
|
||||
}
|
||||
},
|
||||
params: `{"location": "https://example.com", "auth": {"type": "query", "params": {"access-param": "123+456"}}}`,
|
||||
exp: exp{
|
||||
Status: http.StatusUnauthorized,
|
||||
Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}},
|
||||
Body: bytes.NewBuffer(nil),
|
||||
},
|
||||
rq: &http.Request{
|
||||
Header: http.Header{},
|
||||
URL: &url.URL{Path: "/foo", RawQuery: "addedCustomQueryParam=true"},
|
||||
Body: http.NoBody,
|
||||
Method: "POST",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
rc = httptest.NewRecorder()
|
||||
req = require.New(t)
|
||||
c = http.DefaultClient
|
||||
rq = tc.rq
|
||||
cfg = types.Config{}
|
||||
)
|
||||
|
||||
if tc.fn != nil {
|
||||
c.Transport = types.MockRoundTripper(tc.fn(req))
|
||||
}
|
||||
|
||||
if rq == nil {
|
||||
rq = httptest.NewRequest("POST", "/foo", strings.NewReader(`custom request body`))
|
||||
}
|
||||
|
||||
proxy := New(cfg, zap.NewNop(), c, struct{}{})
|
||||
_, err := proxy.Merge([]byte(tc.params), cfg)
|
||||
req.NoError(err)
|
||||
|
||||
scope := &types.Scp{
|
||||
"opts": options.Apigw(),
|
||||
}
|
||||
|
||||
ctx := agctx.ScopeToContext(context.Background(), scope)
|
||||
rq = rq.WithContext(ctx)
|
||||
|
||||
hn := proxy.Handler()
|
||||
err = hn(rc, rq)
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
} else {
|
||||
req.NoError(err)
|
||||
req.Equal(tc.exp.Header, rc.Header())
|
||||
req.Equal(tc.exp.Body, rc.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
43
server/pkg/apigw/helpers.go
Normal file
43
server/pkg/apigw/helpers.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package apigw
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/profiler"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
h "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func helperDefaultResponse(cfg types.Config, pr *profiler.Profiler, log *zap.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addToProfiler(cfg, pr, log, r, http.StatusNotFound)
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func helperMethodNotAllowed(cfg types.Config, pr *profiler.Profiler, log *zap.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addToProfiler(cfg, pr, log, r, http.StatusMethodNotAllowed)
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func addToProfiler(cfg types.Config, pr *profiler.Profiler, log *zap.Logger, r *http.Request, status int) {
|
||||
if !(cfg.Profiler.Enabled && cfg.Profiler.Global) {
|
||||
return
|
||||
}
|
||||
|
||||
// add to profiler
|
||||
ar, err := h.NewRequest(r)
|
||||
|
||||
if err != nil {
|
||||
log.Warn("could not create request wrapper, not adding to profiler")
|
||||
return
|
||||
}
|
||||
|
||||
h := pr.Hit(ar)
|
||||
h.Status = status
|
||||
|
||||
pr.Push(h)
|
||||
}
|
||||
38
server/pkg/apigw/pipeline/chain/default.go
Normal file
38
server/pkg/apigw/pipeline/chain/default.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type (
|
||||
middleware []func(http.Handler) http.Handler
|
||||
|
||||
ChainHandler interface {
|
||||
Handler() http.Handler
|
||||
Chain(middleware)
|
||||
}
|
||||
|
||||
chiHandlerChainDefault struct {
|
||||
chain middleware
|
||||
}
|
||||
)
|
||||
|
||||
func NewDefault() *chiHandlerChainDefault {
|
||||
return &chiHandlerChainDefault{}
|
||||
}
|
||||
|
||||
func (cc *chiHandlerChainDefault) Chain(m middleware) {
|
||||
cc.chain = m
|
||||
}
|
||||
|
||||
func (cc *chiHandlerChainDefault) Handler() http.Handler {
|
||||
return chiHandler(cc.chain)
|
||||
}
|
||||
|
||||
func chiHandler(cc middleware) http.Handler {
|
||||
return chi.
|
||||
Chain(cc...).
|
||||
HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {})
|
||||
}
|
||||
29
server/pkg/apigw/pipeline/chain/profiler.go
Normal file
29
server/pkg/apigw/pipeline/chain/profiler.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/profiler"
|
||||
)
|
||||
|
||||
type (
|
||||
chiHandlerChainProfiler struct {
|
||||
chain middleware
|
||||
}
|
||||
)
|
||||
|
||||
func NewProfiler() *chiHandlerChainProfiler {
|
||||
return &chiHandlerChainProfiler{}
|
||||
}
|
||||
|
||||
func (cc *chiHandlerChainProfiler) Chain(m middleware) {
|
||||
cc.chain = m
|
||||
}
|
||||
|
||||
func (cc *chiHandlerChainProfiler) Handler() http.Handler {
|
||||
// wrap handlers around the chain so we get some profiling info
|
||||
cc.chain = append([]func(http.Handler) http.Handler{profiler.StartHandler}, cc.chain...)
|
||||
cc.chain = append(cc.chain, profiler.FinishHandler)
|
||||
|
||||
return chiHandler(cc.chain)
|
||||
}
|
||||
157
server/pkg/apigw/pipeline/pipeline.go
Normal file
157
server/pkg/apigw/pipeline/pipeline.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
actx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/pipeline/chain"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
Worker struct {
|
||||
Weight int
|
||||
Async bool
|
||||
Name string
|
||||
Type types.FilterKind
|
||||
Handler func(rw http.ResponseWriter, r *http.Request) error
|
||||
}
|
||||
|
||||
workerSet []*Worker
|
||||
|
||||
Pl struct {
|
||||
workers workerSet
|
||||
ch chain.ChainHandler
|
||||
err types.ErrorHandlerFunc
|
||||
log *zap.Logger
|
||||
async bool
|
||||
}
|
||||
)
|
||||
|
||||
func NewPipeline(log *zap.Logger, ch chain.ChainHandler) *Pl {
|
||||
var (
|
||||
defaultErrorHandler = types.NewDefaultErrorHandler(log)
|
||||
)
|
||||
|
||||
return &Pl{
|
||||
ch: ch,
|
||||
log: log,
|
||||
err: defaultErrorHandler.Handler(),
|
||||
}
|
||||
}
|
||||
|
||||
func (pp *Pl) Async(a bool) {
|
||||
pp.async = a
|
||||
}
|
||||
|
||||
func (pp *Pl) Error() types.ErrorHandlerFunc {
|
||||
return pp.err
|
||||
}
|
||||
|
||||
// ErrorHandler adds error handler
|
||||
func (pp *Pl) ErrorHandler(ff types.ErrorHandlerFunc) {
|
||||
pp.err = ff
|
||||
}
|
||||
|
||||
// Add filter
|
||||
func (pp *Pl) Add(w *Worker) {
|
||||
pp.workers = append(pp.workers, w)
|
||||
sort.Sort(pp.workers)
|
||||
}
|
||||
|
||||
// Handler is the main operating entry point for requests
|
||||
// that handles filter groups
|
||||
func (pp *Pl) Handler() http.Handler {
|
||||
// use the chi implementation of chains
|
||||
pp.ch.Chain(pp.makeMiddleware(pp.workers...))
|
||||
return pp.ch.Handler()
|
||||
}
|
||||
|
||||
// makeMiddleware creates a list of handlers from workers
|
||||
// it is used in chaining
|
||||
func (pp *Pl) makeMiddleware(hh ...*Worker) (middleware []func(http.Handler) http.Handler) {
|
||||
for _, wrker := range hh {
|
||||
middleware = append(middleware, pp.makeHandler(*wrker))
|
||||
}
|
||||
|
||||
return middleware
|
||||
}
|
||||
|
||||
// makeHandler creates a handler from worker, it also
|
||||
// wraps the error handling
|
||||
func (pp *Pl) makeHandler(hh Worker) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
err error
|
||||
start = time.Now()
|
||||
log = pp.log.With(zap.String("filter", hh.Name))
|
||||
)
|
||||
|
||||
log.Debug("started processing", zap.Bool("async", hh.Async))
|
||||
|
||||
fn := func() (err error) {
|
||||
err = hh.Handler(rw, r)
|
||||
log.Debug("finished processing", zap.Duration("duration", time.Since(start)))
|
||||
return
|
||||
}
|
||||
|
||||
if hh.Async {
|
||||
go func() {
|
||||
var (
|
||||
newCtx = context.Background()
|
||||
|
||||
ident = auth.GetIdentityFromContext(r.Context())
|
||||
scope = actx.ScopeFromContext(r.Context())
|
||||
)
|
||||
|
||||
newCtx = actx.ScopeToContext(context.Background(), scope)
|
||||
newCtx = auth.SetIdentityToContext(newCtx, ident)
|
||||
|
||||
r = r.WithContext(newCtx)
|
||||
|
||||
// only log error, do not call error handler,
|
||||
// since we do not reply back the response (it was already sent)
|
||||
if err = fn(); err != nil {
|
||||
log.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
err = fn()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
pp.err(rw, r, err)
|
||||
return
|
||||
} else {
|
||||
next.ServeHTTP(rw, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a workerSet) Len() int { return len(a) }
|
||||
func (a workerSet) Less(i, j int) bool {
|
||||
return a[i].Weight < a[j].Weight
|
||||
}
|
||||
func (a workerSet) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
|
||||
func (a workerSet) Filter(f func(*Worker) (bool, error)) (out workerSet, err error) {
|
||||
var ok bool
|
||||
out = workerSet{}
|
||||
|
||||
for i := range a {
|
||||
if ok, err = f(a[i]); err != nil {
|
||||
return
|
||||
} else if ok {
|
||||
out = append(out, a[i])
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
126
server/pkg/apigw/pipeline/pipeline_test.go
Normal file
126
server/pkg/apigw/pipeline/pipeline_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/pipeline/chain"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
mockEmptyHandler = func(rw http.ResponseWriter, r *http.Request) (err error) { return }
|
||||
)
|
||||
|
||||
func NewPl() *Pl {
|
||||
return NewPipeline(zap.NewNop(), chain.NewDefault())
|
||||
}
|
||||
|
||||
func Test_pipelineAdd(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
p := NewPl()
|
||||
p.Add(&Worker{
|
||||
Handler: mockEmptyHandler,
|
||||
Weight: 0,
|
||||
Name: "mockWorker",
|
||||
})
|
||||
|
||||
req.Len(p.workers, 1)
|
||||
}
|
||||
|
||||
func Test_pipelineHandleMultiple(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
rr = httptest.NewRecorder()
|
||||
p = NewPl()
|
||||
|
||||
first = types.MockHandler{
|
||||
Handler_: func(rw http.ResponseWriter, r *http.Request) error {
|
||||
rw.Write([]byte(`first`))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
second = types.MockHandler{
|
||||
Handler_: func(rw http.ResponseWriter, r *http.Request) error {
|
||||
rw.Write([]byte(`second`))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
p.Add(&Worker{
|
||||
Handler: first.Handler(),
|
||||
Weight: 5,
|
||||
Name: "mockHandler",
|
||||
})
|
||||
|
||||
p.Add(&Worker{
|
||||
Handler: second.Handler(),
|
||||
Weight: 0,
|
||||
Name: "mockHandler",
|
||||
})
|
||||
|
||||
p.Handler().ServeHTTP(rr, &http.Request{})
|
||||
|
||||
req.Equal(`secondfirst`, rr.Body.String())
|
||||
}
|
||||
|
||||
func Test_pipelineExecErr(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
mh types.MockHandler
|
||||
w *Worker
|
||||
wgt int
|
||||
exp string
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "matching simple",
|
||||
mh: types.MockHandler{
|
||||
Handler_: func(rw http.ResponseWriter, r *http.Request) error {
|
||||
return fmt.Errorf("triggered")
|
||||
}},
|
||||
w: &Worker{},
|
||||
exp: `{"error":{"message":"triggered"}}` + "\n",
|
||||
},
|
||||
{
|
||||
name: "matching simple",
|
||||
mh: types.MockHandler{
|
||||
Handler_: func(rw http.ResponseWriter, r *http.Request) error {
|
||||
rw.Write([]byte(`foobar`))
|
||||
return nil
|
||||
}},
|
||||
w: &Worker{},
|
||||
exp: `foobar`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
var (
|
||||
p = NewPl()
|
||||
rr = httptest.NewRecorder()
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
tc.w.Handler = tc.mh.Handler()
|
||||
|
||||
p.Add(tc.w)
|
||||
p.Handler().ServeHTTP(rr, &http.Request{})
|
||||
|
||||
req.Equal(tc.exp, rr.Body.String())
|
||||
}
|
||||
|
||||
}
|
||||
16
server/pkg/apigw/profiler/handler.go
Normal file
16
server/pkg/apigw/profiler/handler.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package profiler
|
||||
|
||||
import "net/http"
|
||||
|
||||
func StartHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
// add some info to context
|
||||
next.ServeHTTP(rw, r)
|
||||
})
|
||||
}
|
||||
|
||||
func FinishHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(rw, r)
|
||||
})
|
||||
}
|
||||
57
server/pkg/apigw/profiler/hit.go
Normal file
57
server/pkg/apigw/profiler/hit.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package profiler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
h "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
)
|
||||
|
||||
type (
|
||||
Hit struct {
|
||||
ID string
|
||||
Status int
|
||||
Route uint64
|
||||
|
||||
R *h.Request
|
||||
|
||||
Ts *time.Time
|
||||
Tf *time.Time
|
||||
D *time.Duration
|
||||
}
|
||||
|
||||
Hits map[string][]*Hit
|
||||
)
|
||||
|
||||
func (h *Hit) generateID() {
|
||||
h.ID = base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%s_%d", h.R.URL.Path, h.Ts.UnixNano())))
|
||||
}
|
||||
|
||||
func (s Hits) Filter(fn func(k string, v *Hit) bool) Hits {
|
||||
ss := Hits{}
|
||||
|
||||
for k, v := range s {
|
||||
for _, vv := range v {
|
||||
if !fn(k, vv) {
|
||||
continue
|
||||
}
|
||||
|
||||
ss[k] = append(ss[k], vv)
|
||||
}
|
||||
}
|
||||
|
||||
return ss
|
||||
}
|
||||
|
||||
func (h Hits) Len() int {
|
||||
return h.Len()
|
||||
}
|
||||
|
||||
func (h Hits) Less(i, j int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h Hits) Swap(i, j int) {
|
||||
return
|
||||
}
|
||||
90
server/pkg/apigw/profiler/profiler.go
Normal file
90
server/pkg/apigw/profiler/profiler.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package profiler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
h "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
)
|
||||
|
||||
type (
|
||||
Profiler struct {
|
||||
mux sync.RWMutex
|
||||
l Hits
|
||||
}
|
||||
)
|
||||
|
||||
func New() *Profiler {
|
||||
return &Profiler{l: make(Hits)}
|
||||
}
|
||||
|
||||
func (p *Profiler) Hit(r *h.Request) (h *Hit) {
|
||||
var (
|
||||
n = time.Now()
|
||||
)
|
||||
|
||||
h = &Hit{"", http.StatusOK, 0, r, &n, nil, nil}
|
||||
h.generateID()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Profiler) Push(h *Hit) (id string) {
|
||||
p.mux.Lock()
|
||||
defer p.mux.Unlock()
|
||||
|
||||
if h.Tf == nil {
|
||||
n := time.Now()
|
||||
d := n.Sub(*h.Ts)
|
||||
|
||||
h.Tf = &n
|
||||
h.D = &d
|
||||
}
|
||||
|
||||
h.generateID()
|
||||
|
||||
id = p.id(h.R)
|
||||
p.l[id] = append(p.l[id], h)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Profiler) Hits(s Sort) Hits {
|
||||
p.mux.RLock()
|
||||
defer p.mux.RUnlock()
|
||||
|
||||
ll := p.l.Filter(func(k string, v *Hit) bool {
|
||||
var b bool = true
|
||||
|
||||
if s.Path != "" && v.R.URL.Path != s.Path {
|
||||
b = false
|
||||
}
|
||||
|
||||
if s.Hit != "" && v.ID != s.Hit {
|
||||
b = false
|
||||
}
|
||||
|
||||
return b
|
||||
})
|
||||
|
||||
return ll
|
||||
}
|
||||
|
||||
func (p *Profiler) Purge(f *PurgeFilter) {
|
||||
p.mux.Lock()
|
||||
defer p.mux.Unlock()
|
||||
|
||||
if f.RouteID == 0 {
|
||||
p.l = make(Hits, 0)
|
||||
return
|
||||
}
|
||||
|
||||
p.l = p.l.Filter(func(k string, v *Hit) bool {
|
||||
return v.Route != f.RouteID
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Profiler) id(r *h.Request) string {
|
||||
return r.URL.Path
|
||||
}
|
||||
163
server/pkg/apigw/profiler/profiler_test.go
Normal file
163
server/pkg/apigw/profiler/profiler_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package profiler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
h "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
day = time.Hour * 24
|
||||
)
|
||||
|
||||
func Test_ApigwProfiler_newHit(t *testing.T) {
|
||||
var (
|
||||
p = New()
|
||||
req = require.New(t)
|
||||
|
||||
rr, err = h.NewRequest(httptest.NewRequest("POST", "/foo", strings.NewReader(`foo`)))
|
||||
hit = p.Hit(rr)
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
req.Equal(hit.Status, http.StatusOK)
|
||||
req.NotNil(hit.Ts)
|
||||
req.NotNil(hit.ID)
|
||||
}
|
||||
|
||||
func Test_ApigwProfiler_push(t *testing.T) {
|
||||
var (
|
||||
p = New()
|
||||
req = require.New(t)
|
||||
rr, err = h.NewRequest(httptest.NewRequest("POST", "/foo", strings.NewReader(`foo`)))
|
||||
|
||||
hit = p.Hit(rr)
|
||||
id = p.Push(hit)
|
||||
)
|
||||
|
||||
_, found := p.l[id]
|
||||
|
||||
req.NoError(err)
|
||||
req.NotEmpty(id)
|
||||
req.Len(p.l, 1)
|
||||
req.True(found)
|
||||
}
|
||||
|
||||
func Test_ApigwProfiler_filterPath(t *testing.T) {
|
||||
var (
|
||||
pp = New()
|
||||
req = require.New(t)
|
||||
|
||||
now = time.Date(2022, time.March, 1, 1, 1, 1, 0, time.UTC)
|
||||
then = now.Add(-1 * day)
|
||||
later = now.Add(day)
|
||||
|
||||
hr, _ = h.NewRequest(httptest.NewRequest("POST", "/foo", strings.NewReader(`foo`)))
|
||||
hr2, _ = h.NewRequest(httptest.NewRequest("GET", "/bar", strings.NewReader(`foo`)))
|
||||
hr3, _ = h.NewRequest(httptest.NewRequest("GET", "/baz", strings.NewReader(`foo`)))
|
||||
)
|
||||
|
||||
pp.Push(&Hit{R: hr, Ts: &then})
|
||||
pp.Push(&Hit{R: hr, Ts: &then})
|
||||
pp.Push(&Hit{R: hr2, Ts: &now})
|
||||
pp.Push(&Hit{R: hr3, Ts: &later})
|
||||
|
||||
list := pp.Hits(Sort{
|
||||
Path: hr3.RequestURI,
|
||||
})
|
||||
|
||||
_, found := list[hr3.RequestURI]
|
||||
|
||||
req.True(found)
|
||||
req.Len(list[hr3.RequestURI], 1)
|
||||
}
|
||||
|
||||
func Test_ApigwProfiler_filterHit(t *testing.T) {
|
||||
var (
|
||||
pp = New()
|
||||
req = require.New(t)
|
||||
|
||||
now = time.Date(2022, time.March, 1, 1, 1, 1, 0, time.UTC)
|
||||
then = now.Add(-1 * day)
|
||||
later = now.Add(day)
|
||||
|
||||
hr, _ = h.NewRequest(httptest.NewRequest("POST", "/foo", strings.NewReader(`foo`)))
|
||||
hr2, _ = h.NewRequest(httptest.NewRequest("GET", "/bar", strings.NewReader(`foo`)))
|
||||
hr3, _ = h.NewRequest(httptest.NewRequest("GET", "/baz", strings.NewReader(`foo`)))
|
||||
)
|
||||
|
||||
pp.Push(&Hit{R: hr, Ts: &then})
|
||||
pp.Push(&Hit{R: hr, Ts: &now})
|
||||
pp.Push(&Hit{R: hr2, Ts: &now})
|
||||
pp.Push(&Hit{R: hr2, Ts: &later})
|
||||
pp.Push(&Hit{R: hr2, Ts: &later})
|
||||
|
||||
h := pp.Hit(hr3)
|
||||
h.Ts = &later
|
||||
|
||||
id := pp.Push(h)
|
||||
|
||||
list := pp.Hits(Sort{
|
||||
Hit: h.ID,
|
||||
})
|
||||
|
||||
_, found := list[id]
|
||||
|
||||
req.True(found)
|
||||
req.Len(list[id], 1)
|
||||
}
|
||||
|
||||
func Test_ApigwProfiler_purgeAll(t *testing.T) {
|
||||
var (
|
||||
pp = New()
|
||||
req = require.New(t)
|
||||
|
||||
now = time.Date(2022, time.March, 1, 1, 1, 1, 0, time.UTC)
|
||||
then = now.Add(-1 * day)
|
||||
later = now.Add(day)
|
||||
|
||||
hr, _ = h.NewRequest(httptest.NewRequest("POST", "/foo", strings.NewReader(`foo`)))
|
||||
hr2, _ = h.NewRequest(httptest.NewRequest("GET", "/bar", strings.NewReader(`foo`)))
|
||||
)
|
||||
|
||||
pp.Push(&Hit{R: hr, Ts: &then})
|
||||
pp.Push(&Hit{R: hr, Ts: &now})
|
||||
pp.Push(&Hit{R: hr2, Ts: &now})
|
||||
pp.Push(&Hit{R: hr2, Ts: &later})
|
||||
pp.Push(&Hit{R: hr2, Ts: &later})
|
||||
|
||||
req.Len(pp.l, 2)
|
||||
pp.Purge(&PurgeFilter{})
|
||||
req.Len(pp.l, 0)
|
||||
}
|
||||
|
||||
func Test_ApigwProfiler_purgeRoute(t *testing.T) {
|
||||
var (
|
||||
pp = New()
|
||||
req = require.New(t)
|
||||
|
||||
now = time.Date(2022, time.March, 1, 1, 1, 1, 0, time.UTC)
|
||||
then = now.Add(-1 * day)
|
||||
later = now.Add(day)
|
||||
|
||||
hr, _ = h.NewRequest(httptest.NewRequest("POST", "/foo", strings.NewReader(`foo`)))
|
||||
hr2, _ = h.NewRequest(httptest.NewRequest("GET", "/bar", strings.NewReader(`foo`)))
|
||||
)
|
||||
|
||||
pp.Push(&Hit{Route: 1, R: hr, Ts: &then})
|
||||
pp.Push(&Hit{Route: 1, R: hr, Ts: &now})
|
||||
pp.Push(&Hit{Route: 2, R: hr2, Ts: &now})
|
||||
pp.Push(&Hit{Route: 2, R: hr2, Ts: &later})
|
||||
pp.Push(&Hit{Route: 2, R: hr2, Ts: &later})
|
||||
|
||||
req.Len(pp.l, 2)
|
||||
|
||||
pp.Purge(&PurgeFilter{RouteID: 2})
|
||||
|
||||
req.Len(pp.l, 1)
|
||||
}
|
||||
7
server/pkg/apigw/profiler/purge.go
Normal file
7
server/pkg/apigw/profiler/purge.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package profiler
|
||||
|
||||
type (
|
||||
PurgeFilter struct {
|
||||
RouteID uint64
|
||||
}
|
||||
)
|
||||
8
server/pkg/apigw/profiler/sort.go
Normal file
8
server/pkg/apigw/profiler/sort.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package profiler
|
||||
|
||||
type (
|
||||
Sort struct {
|
||||
Hit string
|
||||
Path string
|
||||
}
|
||||
)
|
||||
83
server/pkg/apigw/registry/registry.go
Normal file
83
server/pkg/apigw/registry/registry.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/automation/service"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/filter/proxy"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
)
|
||||
|
||||
type (
|
||||
Registry struct {
|
||||
cfg types.Config
|
||||
h map[string]types.Handler
|
||||
}
|
||||
|
||||
secureStorageTodo struct{}
|
||||
)
|
||||
|
||||
func NewRegistry(cfg types.Config) *Registry {
|
||||
return &Registry{
|
||||
h: map[string]types.Handler{},
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Add(n string, h types.Handler) {
|
||||
r.h[n] = h
|
||||
}
|
||||
|
||||
func (r *Registry) Merge(h types.Handler, b []byte, cfg types.Config) (hh types.Handler, err error) {
|
||||
hh, err = h.Merge(b, cfg)
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Registry) Get(identifier string) (types.Handler, error) {
|
||||
var (
|
||||
ok bool
|
||||
f types.Handler
|
||||
)
|
||||
|
||||
if f, ok = r.h[identifier]; !ok {
|
||||
return nil, fmt.Errorf("could not get element from registry: %s", identifier)
|
||||
}
|
||||
|
||||
return f.New(r.cfg), nil
|
||||
}
|
||||
|
||||
func (r *Registry) All() (list types.FilterMetaList) {
|
||||
for _, handler := range r.h {
|
||||
if !handler.Enabled() {
|
||||
continue
|
||||
}
|
||||
|
||||
meta := handler.Meta()
|
||||
list = append(list, &meta)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Registry) Preload() {
|
||||
// prefilters
|
||||
r.Add("queryParam", filter.NewQueryParam(r.cfg))
|
||||
r.Add("header", filter.NewHeader(r.cfg))
|
||||
r.Add("profiler", filter.NewProfiler(r.cfg))
|
||||
|
||||
// processers
|
||||
r.Add("workflow", filter.NewWorkflow(r.cfg, NewWorkflow()))
|
||||
r.Add("proxy", proxy.New(r.cfg, service.DefaultLogger, http.DefaultClient, secureStorageTodo{}))
|
||||
r.Add("payload", filter.NewPayload(r.cfg, service.DefaultLogger))
|
||||
|
||||
// postfilters
|
||||
r.Add("redirection", filter.NewRedirection(r.cfg))
|
||||
r.Add("response", filter.NewResponse(r.cfg, service.Registry()))
|
||||
r.Add("defaultJsonResponse", filter.NewDefaultJsonResponse(r.cfg))
|
||||
}
|
||||
|
||||
func NewWorkflow() (wf filter.WfExecer) {
|
||||
return service.DefaultWorkflow
|
||||
}
|
||||
101
server/pkg/apigw/registry/registry_test.go
Normal file
101
server/pkg/apigw/registry/registry_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_registryAddGet(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
r = NewRegistry(types.Config{})
|
||||
)
|
||||
|
||||
r.Add("mockHandler", types.MockHandler{})
|
||||
|
||||
h, err := r.Get("mockHandler")
|
||||
|
||||
req.NoError(err)
|
||||
req.Len(r.h, 1)
|
||||
req.IsType(types.MockHandler{}, h)
|
||||
}
|
||||
|
||||
func Test_registryAddGetErr(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
r = NewRegistry(types.Config{})
|
||||
)
|
||||
|
||||
r.Add("mockHandler", types.MockHandler{})
|
||||
|
||||
h, err := r.Get("foo")
|
||||
|
||||
req.EqualError(err, "could not get element from registry: foo")
|
||||
req.Len(r.h, 1)
|
||||
req.Nil(h)
|
||||
}
|
||||
|
||||
func Test_registryMerge(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
err string
|
||||
params string
|
||||
exp string
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "set params",
|
||||
params: `{"foo":"bar"}`,
|
||||
exp: "bar",
|
||||
},
|
||||
{
|
||||
name: "set invalid params",
|
||||
params: `{"foo1":"bar"}`,
|
||||
exp: "",
|
||||
},
|
||||
{
|
||||
name: "set invalid params err",
|
||||
params: `{"foo1":"bar"`,
|
||||
exp: "",
|
||||
err: "unexpected EOF",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
var (
|
||||
req = require.New(t)
|
||||
r = NewRegistry(types.Config{})
|
||||
)
|
||||
|
||||
m, err := r.Merge(types.MockHandler{}, []byte(tc.params), types.Config{})
|
||||
|
||||
if tc.err != "" {
|
||||
req.EqualError(err, tc.err)
|
||||
} else {
|
||||
req.Equal(m.(types.MockHandler).Foo, tc.exp)
|
||||
req.NoError(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func Test_registryAll(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
r = NewRegistry(types.Config{})
|
||||
)
|
||||
|
||||
r.Add("mockHandler", types.MockHandler{})
|
||||
|
||||
list := r.All()
|
||||
|
||||
req.Len(list, 1)
|
||||
req.NotEmpty(list[0].Name)
|
||||
}
|
||||
94
server/pkg/apigw/route.go
Normal file
94
server/pkg/apigw/route.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package apigw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
actx "github.com/cortezaproject/corteza/server/pkg/apigw/ctx"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/profiler"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
h "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
route struct {
|
||||
ID uint64
|
||||
endpoint string
|
||||
method string
|
||||
meta routeMeta
|
||||
|
||||
cfg types.Config
|
||||
log *zap.Logger
|
||||
pr *profiler.Profiler
|
||||
|
||||
handler http.Handler
|
||||
errHandler types.ErrorHandlerFunc
|
||||
}
|
||||
|
||||
routeMeta struct {
|
||||
debug bool
|
||||
async bool
|
||||
}
|
||||
)
|
||||
|
||||
func (r route) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
var (
|
||||
ctx = auth.SetIdentityToContext(req.Context(), auth.ServiceUser())
|
||||
start = time.Now()
|
||||
scope = types.Scp{}
|
||||
hit = &profiler.Hit{}
|
||||
)
|
||||
|
||||
r.log.Debug("started serving route")
|
||||
|
||||
ar, err := h.NewRequest(req)
|
||||
|
||||
if err != nil {
|
||||
r.log.Error("could not get initial request", zap.Error(err))
|
||||
}
|
||||
|
||||
scope.Set("opts", r.cfg)
|
||||
scope.Set("request", ar)
|
||||
|
||||
// use profiler, override any profiling prefilter
|
||||
if r.cfg.Profiler.Enabled && r.cfg.Profiler.Global {
|
||||
// add request to profiler
|
||||
hit = r.pr.Hit(ar)
|
||||
hit.Route = r.ID
|
||||
}
|
||||
|
||||
req = req.WithContext(actx.ScopeToContext(ctx, &scope))
|
||||
req = req.WithContext(actx.ProfilerToContext(req.Context(), hit))
|
||||
|
||||
r.handler.ServeHTTP(w, req)
|
||||
|
||||
r.log.Debug("finished serving route",
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
)
|
||||
|
||||
if !r.cfg.Profiler.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if r.cfg.Profiler.Global {
|
||||
r.pr.Push(hit)
|
||||
return
|
||||
}
|
||||
|
||||
if hit = actx.ProfilerFromContext(req.Context()); hit != nil && hit.R != nil {
|
||||
// updated hit from a possible prefilter
|
||||
// we need to push route ID even if the profiler is disabled
|
||||
hit.Route = r.ID
|
||||
hit.Status = http.StatusOK
|
||||
|
||||
r.pr.Push(hit)
|
||||
r.log.Debug("pushing request to profiler")
|
||||
}
|
||||
}
|
||||
|
||||
func (r route) String() string {
|
||||
return fmt.Sprintf("%s %s", r.method, r.endpoint)
|
||||
}
|
||||
107
server/pkg/apigw/route_test.go
Normal file
107
server/pkg/apigw/route_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package apigw
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/pipeline"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/pipeline/chain"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Test_pl(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
method string
|
||||
endpoint string
|
||||
expError string
|
||||
expStatus int
|
||||
handler *types.MockHandler
|
||||
errHandler *types.MockErrorHandler
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "successful handler",
|
||||
handler: &types.MockHandler{
|
||||
Handler_: func(rw http.ResponseWriter, r *http.Request) error {
|
||||
rw.WriteHeader(http.StatusTemporaryRedirect)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
method: "POST",
|
||||
expStatus: http.StatusTemporaryRedirect,
|
||||
expError: "",
|
||||
},
|
||||
{
|
||||
name: "unsuccessful handle custom error response",
|
||||
handler: &types.MockHandler{
|
||||
Handler_: func(rw http.ResponseWriter, r *http.Request) error {
|
||||
rw.WriteHeader(http.StatusTemporaryRedirect)
|
||||
return errors.New("test error")
|
||||
},
|
||||
},
|
||||
errHandler: &types.MockErrorHandler{
|
||||
Handler_: func(rw http.ResponseWriter, r *http.Request, err error) {
|
||||
rw.Write([]byte("custom error response: " + err.Error()))
|
||||
},
|
||||
},
|
||||
method: "POST",
|
||||
expStatus: http.StatusTemporaryRedirect,
|
||||
expError: "custom error response: test error",
|
||||
},
|
||||
{
|
||||
name: "unsuccessful handle default error response",
|
||||
handler: &types.MockHandler{
|
||||
Handler_: func(rw http.ResponseWriter, r *http.Request) error {
|
||||
rw.WriteHeader(http.StatusTemporaryRedirect)
|
||||
return errors.New("test error")
|
||||
},
|
||||
},
|
||||
method: "POST",
|
||||
expStatus: http.StatusTemporaryRedirect,
|
||||
expError: "{\"error\":{\"message\":\"test error\"}}\n",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
rr = httptest.NewRecorder()
|
||||
pipe = pipeline.NewPipeline(zap.NewNop(), chain.NewDefault())
|
||||
)
|
||||
|
||||
r := httptest.NewRequest("POST", "/foo", http.NoBody)
|
||||
|
||||
pipe.Add(&pipeline.Worker{
|
||||
Handler: tc.handler.Handler(),
|
||||
})
|
||||
|
||||
if tc.errHandler != nil {
|
||||
pipe.ErrorHandler(tc.errHandler.Handler())
|
||||
}
|
||||
|
||||
route := &route{
|
||||
method: tc.method,
|
||||
endpoint: tc.endpoint,
|
||||
log: zap.NewNop(),
|
||||
handler: pipe.Handler(),
|
||||
errHandler: pipe.Error(),
|
||||
}
|
||||
|
||||
route.ServeHTTP(rr, r)
|
||||
|
||||
req.Equal(tc.expStatus, rr.Result().StatusCode)
|
||||
req.Equal(tc.expError, rr.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
466
server/pkg/apigw/service.go
Normal file
466
server/pkg/apigw/service.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package apigw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/filter/proxy"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/pipeline"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/pipeline/chain"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/profiler"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/registry"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
f "github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
st "github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
storer interface {
|
||||
SearchApigwRoutes(ctx context.Context, f st.ApigwRouteFilter) (st.ApigwRouteSet, st.ApigwRouteFilter, error)
|
||||
SearchApigwFilters(ctx context.Context, f st.ApigwFilterFilter) (st.ApigwFilterSet, st.ApigwFilterFilter, error)
|
||||
}
|
||||
|
||||
apigw struct {
|
||||
log *zap.Logger
|
||||
reg *registry.Registry
|
||||
routes []*route
|
||||
mx *chi.Mux
|
||||
pr *profiler.Profiler
|
||||
storer storer
|
||||
|
||||
cfg types.Config
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// global service
|
||||
apiGw *apigw
|
||||
)
|
||||
|
||||
func Service() *apigw {
|
||||
return apiGw
|
||||
}
|
||||
|
||||
// Setup handles the singleton service
|
||||
func Setup(cfg types.Config, log *zap.Logger, storer storer) {
|
||||
if apiGw != nil {
|
||||
return
|
||||
}
|
||||
|
||||
apiGw = New(cfg, log, storer)
|
||||
}
|
||||
|
||||
func New(cfg types.Config, logger *zap.Logger, storer storer) *apigw {
|
||||
var (
|
||||
pr = profiler.New()
|
||||
)
|
||||
|
||||
reg := registry.NewRegistry(cfg)
|
||||
reg.Preload()
|
||||
|
||||
return &apigw{
|
||||
log: logger.Named("http.apigw"),
|
||||
storer: storer,
|
||||
reg: reg,
|
||||
pr: pr,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP forwards the given HTTP request to the underlying chi mux which
|
||||
// then handles the heavy lifting
|
||||
//
|
||||
// When reloading routes, make sure to replace the original mux
|
||||
func (s *apigw) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if s.mx == nil {
|
||||
http.Error(w, "Integration Gateway not initialized", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(s.routes) == 0 {
|
||||
helperDefaultResponse(s.cfg, s.pr, s.log)(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove route context for chi
|
||||
//
|
||||
// Without this, chi can not properly handle requests
|
||||
// in API gateway's sub-router
|
||||
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, nil))
|
||||
|
||||
// Handle api-gw request
|
||||
s.mx.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Reload reloads all routes and their filters
|
||||
//
|
||||
// The procedure constructs a new chi mux
|
||||
func (s *apigw) Reload(ctx context.Context) (err error) {
|
||||
routes, err := s.loadRoutes(ctx)
|
||||
|
||||
if err != nil {
|
||||
s.log.Error("could not reload Integration Gateway routes", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
s.Init(ctx, routes...)
|
||||
|
||||
// Rebuild the mux
|
||||
s.mx = chi.NewMux()
|
||||
|
||||
for _, r := range s.routes {
|
||||
// Register route handler on endpoint & method
|
||||
s.mx.Method(r.method, r.endpoint, r)
|
||||
}
|
||||
|
||||
// handling missed hits
|
||||
// profiler gets the missed hit info also
|
||||
{
|
||||
var (
|
||||
defaultMethodResponse = helperMethodNotAllowed(s.cfg, s.pr, s.log)
|
||||
defaultResponse = helperDefaultResponse(s.cfg, s.pr, s.log)
|
||||
)
|
||||
|
||||
s.mx.NotFound(defaultResponse)
|
||||
s.mx.MethodNotAllowed(defaultMethodResponse)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadEndpoint reload a route and its filters
|
||||
//
|
||||
// The procedure use existing chi mux
|
||||
func (s *apigw) ReloadEndpoint(ctx context.Context, method, endpoint string) (err error) {
|
||||
var (
|
||||
routes []*route
|
||||
)
|
||||
|
||||
routes, err = s.loadRoute(ctx, method, endpoint)
|
||||
|
||||
if err != nil {
|
||||
s.log.Error("could not reload Integration Gateway routes", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// rr := append(s.routes, routes...)
|
||||
s.PrepRoutes(ctx, routes...)
|
||||
|
||||
if s.mx == nil {
|
||||
// Rebuild the mux
|
||||
s.mx = chi.NewMux()
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
// Register route handler on endpoint & method
|
||||
s.mx.Method(r.method, r.endpoint, r)
|
||||
}
|
||||
|
||||
// Make sure to append newly registered routes
|
||||
s.AppendRoutes(routes...)
|
||||
|
||||
// handling missed hits
|
||||
// profiler gets the missed hit info also
|
||||
{
|
||||
var (
|
||||
defaultMethodResponse = helperMethodNotAllowed(s.cfg, s.pr, s.log)
|
||||
defaultResponse = helperDefaultResponse(s.cfg, s.pr, s.log)
|
||||
)
|
||||
|
||||
s.mx.NotFound(defaultResponse)
|
||||
s.mx.MethodNotAllowed(defaultMethodResponse)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Init all routes
|
||||
func (s *apigw) Init(ctx context.Context, routes ...*route) {
|
||||
s.PrepRoutes(ctx, routes...)
|
||||
s.routes = routes
|
||||
}
|
||||
|
||||
func (s *apigw) PrepRoutes(ctx context.Context, routes ...*route) {
|
||||
var (
|
||||
err error
|
||||
defaultPostFilter types.Handler
|
||||
)
|
||||
|
||||
s.loadInfo()
|
||||
s.log.Debug("preparing routes", zap.Int("count", len(routes)))
|
||||
|
||||
defaultPostFilter, err = s.reg.Get("defaultJsonResponse")
|
||||
|
||||
if err != nil {
|
||||
s.log.Error("could not register default filter", zap.Error(err))
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
var (
|
||||
log = s.log.With(zap.String("route", r.String()))
|
||||
pipe = pipeline.NewPipeline(log, chain.NewDefault())
|
||||
|
||||
regFilters []*st.ApigwFilter
|
||||
)
|
||||
|
||||
// pipeline needs to know how to handle
|
||||
// async processers
|
||||
pipe.Async(r.meta.async)
|
||||
|
||||
r.cfg = s.cfg
|
||||
r.log = log
|
||||
r.pr = s.pr
|
||||
|
||||
regFilters, err = s.loadFilters(ctx, r.ID)
|
||||
if err != nil {
|
||||
log.Error("could not load filters for route", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rf := range regFilters {
|
||||
flog := log.With(zap.String("ref", rf.Ref))
|
||||
|
||||
// make sure there is only one postfilter
|
||||
// on async routes
|
||||
if r.meta.async && rf.Kind == string(types.PostFilter) {
|
||||
flog.Debug("not registering filter for async route")
|
||||
continue
|
||||
}
|
||||
|
||||
var ff *pipeline.Worker
|
||||
ff, err = s.registerFilter(rf, r)
|
||||
if err != nil {
|
||||
flog.Error("could not register filter", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
pipe.Add(ff)
|
||||
|
||||
flog.Debug("registered filter")
|
||||
}
|
||||
|
||||
// add default postfilter on async
|
||||
// routes if not present
|
||||
if r.meta.async {
|
||||
log.Info("registering default postfilter", zap.Error(err))
|
||||
|
||||
pipe.Add(&pipeline.Worker{
|
||||
Handler: defaultPostFilter.Handler(),
|
||||
Name: defaultPostFilter.String(),
|
||||
Type: types.PostFilter,
|
||||
Weight: math.MaxInt8,
|
||||
})
|
||||
}
|
||||
|
||||
r.handler = pipe.Handler()
|
||||
r.errHandler = pipe.Error()
|
||||
|
||||
log.Debug("successfully registered route")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *apigw) AppendRoutes(routes ...*route) {
|
||||
var (
|
||||
rMap = make(map[string]*route)
|
||||
uniq = func(r *route) string {
|
||||
if routes == nil {
|
||||
return ""
|
||||
}
|
||||
return r.method + r.endpoint
|
||||
}
|
||||
)
|
||||
|
||||
for _, r := range routes {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
rMap[uniq(r)] = r
|
||||
}
|
||||
|
||||
// update existing routes
|
||||
for i, r := range s.routes {
|
||||
if val, ok := rMap[uniq(r)]; ok && val != nil {
|
||||
s.routes[i] = val
|
||||
rMap[uniq(r)] = nil
|
||||
}
|
||||
}
|
||||
|
||||
// add new routes
|
||||
for _, r := range rMap {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
s.routes = append(s.routes, r)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *apigw) NotFound(_ context.Context, method, endpoint string) {
|
||||
if s.mx == nil || len(method) == 0 || len(endpoint) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
defaultResponse = helperDefaultResponse(s.cfg, s.pr, s.log)
|
||||
)
|
||||
|
||||
// Attach 404 handler
|
||||
s.mx.Method(method, endpoint, defaultResponse)
|
||||
}
|
||||
|
||||
func (s *apigw) registerFilter(f *st.ApigwFilter, r *route) (ff *pipeline.Worker, err error) {
|
||||
handler, err := s.reg.Get(f.Ref)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
enc, err := json.Marshal(f.Params)
|
||||
|
||||
if err != nil {
|
||||
err = fmt.Errorf("could not load params for filter: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
handler, err = s.reg.Merge(handler, enc, s.cfg)
|
||||
|
||||
if err != nil {
|
||||
err = fmt.Errorf("could not merge params to handler: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
ff = &pipeline.Worker{
|
||||
Async: r.meta.async && f.Kind == string(types.Processer),
|
||||
Handler: handler.Handler(),
|
||||
Name: handler.String(),
|
||||
Type: types.FilterKind(f.Kind),
|
||||
Weight: filter.FilterWeight(int(f.Weight), types.FilterKind(f.Kind)),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *apigw) Funcs(kind string) (list types.FilterMetaList) {
|
||||
list = s.reg.All()
|
||||
|
||||
if kind != "" {
|
||||
list, _ = list.Filter(func(fm *types.FilterMeta) (bool, error) {
|
||||
return string(fm.Kind) == kind, nil
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *apigw) ProxyAuthDef() (list []*proxy.ProxyAuthDefinition) {
|
||||
list = proxy.ProxyAuthDef()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *apigw) UpdateSettings(ctx context.Context, cfg types.Config) {
|
||||
s.cfg = cfg
|
||||
|
||||
s.reg = registry.NewRegistry(cfg)
|
||||
s.reg.Preload()
|
||||
|
||||
s.Reload(ctx)
|
||||
}
|
||||
|
||||
func (s *apigw) loadRoutes(ctx context.Context) (rr []*route, err error) {
|
||||
var (
|
||||
routes st.ApigwRouteSet
|
||||
agwf = st.ApigwRouteFilter{
|
||||
Deleted: f.StateExcluded,
|
||||
Disabled: f.StateExcluded,
|
||||
}
|
||||
)
|
||||
|
||||
routes, _, err = s.storer.SearchApigwRoutes(ctx, agwf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
route := &route{
|
||||
ID: r.ID,
|
||||
endpoint: r.Endpoint,
|
||||
method: r.Method,
|
||||
meta: routeMeta{
|
||||
debug: r.Meta.Debug,
|
||||
async: r.Meta.Async,
|
||||
},
|
||||
}
|
||||
|
||||
rr = append(rr, route)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *apigw) loadRoute(ctx context.Context, method, endpoint string) (rr []*route, err error) {
|
||||
var (
|
||||
routes st.ApigwRouteSet
|
||||
agwf = st.ApigwRouteFilter{
|
||||
Endpoint: endpoint,
|
||||
Method: method,
|
||||
Deleted: f.StateExcluded,
|
||||
Disabled: f.StateExcluded,
|
||||
}
|
||||
)
|
||||
|
||||
routes, _, err = s.storer.SearchApigwRoutes(ctx, agwf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
rr = append(rr, &route{
|
||||
ID: r.ID,
|
||||
endpoint: r.Endpoint,
|
||||
method: r.Method,
|
||||
meta: routeMeta{
|
||||
debug: r.Meta.Debug,
|
||||
async: r.Meta.Async,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *apigw) loadFilters(ctx context.Context, route uint64) (ff []*st.ApigwFilter, err error) {
|
||||
ff, _, err = s.storer.SearchApigwFilters(ctx, st.ApigwFilterFilter{
|
||||
RouteID: route,
|
||||
Deleted: f.StateExcluded,
|
||||
Disabled: f.StateExcluded,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *apigw) loadInfo() {
|
||||
s.log.Info("loading Integration Gateway")
|
||||
|
||||
if s.cfg.Profiler.Enabled {
|
||||
if !s.cfg.Profiler.Global {
|
||||
s.log.Warn("profiler enabled only for routes with a profiler prefilter, use global setting to enable for all (APIGW_PROFILER_GLOBAL)")
|
||||
}
|
||||
} else {
|
||||
if s.cfg.Profiler.Global {
|
||||
s.log.Warn("profiler global is enabled, but profiler disabled, no routes will be profiled",
|
||||
zap.Bool("APIGW_PROFILER_ENABLED", s.cfg.Profiler.Enabled),
|
||||
zap.Bool("APIGW_PROFILER_GLOBAL", s.cfg.Profiler.Global))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *apigw) Profiler() *profiler.Profiler {
|
||||
return s.pr
|
||||
}
|
||||
249
server/pkg/apigw/service_test.go
Normal file
249
server/pkg/apigw/service_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package apigw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/registry"
|
||||
"github.com/cortezaproject/corteza/server/pkg/apigw/types"
|
||||
st "github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
// overriding types.MockHandler with only
|
||||
// the merge function
|
||||
mockExistingHandler struct {
|
||||
*types.MockHandler
|
||||
merge func(params []byte) (types.Handler, error)
|
||||
}
|
||||
)
|
||||
|
||||
func Test_serviceLoadRoutes(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
mockStorer := &types.MockStorer{
|
||||
R: func(c context.Context, arf st.ApigwRouteFilter) (s st.ApigwRouteSet, f st.ApigwRouteFilter, err error) {
|
||||
s = st.ApigwRouteSet{
|
||||
{ID: 1, Endpoint: "/endpoint", Method: "GET", Enabled: true, Group: 0},
|
||||
{ID: 2, Endpoint: "/endpoint2", Method: "POST", Enabled: true, Group: 0},
|
||||
}
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
service := &apigw{
|
||||
storer: mockStorer,
|
||||
}
|
||||
|
||||
r, err := service.loadRoutes(ctx)
|
||||
|
||||
req.NoError(err)
|
||||
req.Len(r, 2)
|
||||
}
|
||||
|
||||
func Test_serviceLoadFunctions(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
mockStorer := &types.MockStorer{
|
||||
F: func(c context.Context, aff st.ApigwFilterFilter) (s st.ApigwFilterSet, f st.ApigwFilterFilter, err error) {
|
||||
s = st.ApigwFilterSet{
|
||||
{ID: 1, Route: 1},
|
||||
{ID: 2, Route: 2},
|
||||
}
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
service := &apigw{
|
||||
storer: mockStorer,
|
||||
}
|
||||
|
||||
r, err := service.loadFilters(ctx, 2)
|
||||
|
||||
req.NoError(err)
|
||||
req.Len(r, 2)
|
||||
}
|
||||
|
||||
func Test_serviceInit(t *testing.T) {
|
||||
type (
|
||||
tf struct {
|
||||
name string
|
||||
expLen int
|
||||
st types.MockStorer
|
||||
reg map[string]types.Handler
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
tcc = []tf{
|
||||
{
|
||||
name: "could not register 1 function for route",
|
||||
st: types.MockStorer{
|
||||
R: func(c context.Context, arf st.ApigwRouteFilter) (s st.ApigwRouteSet, f st.ApigwRouteFilter, err error) {
|
||||
s = st.ApigwRouteSet{
|
||||
{ID: 1, Endpoint: "/endpoint", Method: "GET", Enabled: true, Group: 0},
|
||||
}
|
||||
return
|
||||
},
|
||||
F: func(c context.Context, aff st.ApigwFilterFilter) (s st.ApigwFilterSet, f st.ApigwFilterFilter, err error) {
|
||||
s = st.ApigwFilterSet{
|
||||
{ID: 1, Route: 1, Ref: "testExistingFilter"},
|
||||
{ID: 2, Route: 1, Ref: "testNotExistingFunction"},
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
reg: map[string]types.Handler{"testExistingFilter": &types.MockHandler{}},
|
||||
expLen: 1,
|
||||
},
|
||||
{
|
||||
name: "successful register of 2 functions for route",
|
||||
st: types.MockStorer{
|
||||
R: func(c context.Context, arf st.ApigwRouteFilter) (s st.ApigwRouteSet, f st.ApigwRouteFilter, err error) {
|
||||
s = st.ApigwRouteSet{
|
||||
{ID: 1, Endpoint: "/endpoint", Method: "GET", Enabled: true, Group: 0},
|
||||
}
|
||||
return
|
||||
},
|
||||
F: func(c context.Context, aff st.ApigwFilterFilter) (s st.ApigwFilterSet, f st.ApigwFilterFilter, err error) {
|
||||
s = st.ApigwFilterSet{
|
||||
{ID: 1, Route: 1, Ref: "testExistingFilter"},
|
||||
{ID: 2, Route: 1, Ref: "testExistingFilter"},
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
reg: map[string]types.Handler{"testExistingFilter": &types.MockHandler{}},
|
||||
expLen: 2,
|
||||
},
|
||||
{
|
||||
name: "could not merge params for function",
|
||||
st: types.MockStorer{
|
||||
R: func(c context.Context, arf st.ApigwRouteFilter) (s st.ApigwRouteSet, f st.ApigwRouteFilter, err error) {
|
||||
s = st.ApigwRouteSet{
|
||||
{ID: 1, Endpoint: "/endpoint", Method: "GET", Enabled: true, Group: 0},
|
||||
}
|
||||
return
|
||||
},
|
||||
F: func(c context.Context, aff st.ApigwFilterFilter) (s st.ApigwFilterSet, f st.ApigwFilterFilter, err error) {
|
||||
s = st.ApigwFilterSet{
|
||||
{ID: 1, Route: 1, Ref: "testExistingFilter", Params: st.ApigwFilterParams{}},
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
reg: map[string]types.Handler{"testExistingFilter": &mockExistingHandler{
|
||||
MockHandler: &types.MockHandler{},
|
||||
merge: func(params []byte) (types.Handler, error) {
|
||||
return nil, fmt.Errorf("testttt")
|
||||
},
|
||||
}},
|
||||
expLen: 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ctx = context.Background()
|
||||
)
|
||||
|
||||
reg := registry.NewRegistry(types.Config{})
|
||||
|
||||
for hn, h := range tc.reg {
|
||||
reg.Add(hn, h)
|
||||
}
|
||||
|
||||
service := &apigw{
|
||||
log: zap.NewNop(),
|
||||
storer: tc.st,
|
||||
reg: reg,
|
||||
}
|
||||
|
||||
rr, err := service.loadRoutes(ctx)
|
||||
req.NoError(err)
|
||||
|
||||
service.Init(ctx, rr...)
|
||||
|
||||
req.NotEmpty(service.routes)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (h mockExistingHandler) Merge(params []byte, cfg types.Config) (types.Handler, error) {
|
||||
return h.merge(params)
|
||||
}
|
||||
|
||||
func Test_serviceAppendRoutes(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
routes = func(rr ...*route) []*route {
|
||||
return rr
|
||||
}
|
||||
|
||||
r1 = &route{
|
||||
method: "GET",
|
||||
endpoint: "/test",
|
||||
}
|
||||
r2 = &route{
|
||||
method: "POST",
|
||||
endpoint: "/test",
|
||||
}
|
||||
r3 = &route{
|
||||
method: "PUT",
|
||||
endpoint: "/test",
|
||||
}
|
||||
r4 = &route{
|
||||
method: "DELETE",
|
||||
endpoint: "/test",
|
||||
}
|
||||
r5 = &route{
|
||||
endpoint: "GET",
|
||||
method: "/test2",
|
||||
}
|
||||
|
||||
tests = []struct {
|
||||
name string
|
||||
ag apigw
|
||||
routes []*route
|
||||
expected []*route
|
||||
}{
|
||||
{
|
||||
name: "add new",
|
||||
ag: apigw{
|
||||
routes: routes(r1, r2, r3),
|
||||
},
|
||||
routes: routes(r4),
|
||||
expected: routes(r1, r2, r3, r4),
|
||||
},
|
||||
{
|
||||
name: "no duplicates",
|
||||
ag: apigw{
|
||||
routes: routes(r1, r2, r3, r4, r5),
|
||||
},
|
||||
routes: routes(r1, r5),
|
||||
expected: routes(r1, r2, r3, r4, r5),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.ag.AppendRoutes(tt.routes...)
|
||||
req.Equal(tt.expected, tt.ag.routes)
|
||||
})
|
||||
}
|
||||
}
|
||||
19
server/pkg/apigw/types/config.go
Normal file
19
server/pkg/apigw/types/config.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
Enabled bool
|
||||
|
||||
Profiler struct {
|
||||
Enabled bool
|
||||
Global bool
|
||||
}
|
||||
|
||||
Proxy struct {
|
||||
FollowRedirects bool
|
||||
OutboundTimeout time.Duration
|
||||
}
|
||||
}
|
||||
)
|
||||
27
server/pkg/apigw/types/error.go
Normal file
27
server/pkg/apigw/types/error.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
DefaultErrorHandler struct {
|
||||
log *zap.Logger
|
||||
}
|
||||
)
|
||||
|
||||
func NewDefaultErrorHandler(log *zap.Logger) DefaultErrorHandler {
|
||||
return DefaultErrorHandler{
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (h DefaultErrorHandler) Handler() ErrorHandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request, err error) {
|
||||
errors.ProperlyServeHTTP(rw, r, err, true)
|
||||
h.log.Error(err.Error())
|
||||
}
|
||||
}
|
||||
43
server/pkg/apigw/types/filter.go
Normal file
43
server/pkg/apigw/types/filter.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package types
|
||||
|
||||
const (
|
||||
PreFilter FilterKind = "prefilter"
|
||||
PostFilter FilterKind = "postfilter"
|
||||
Processer FilterKind = "processer"
|
||||
)
|
||||
|
||||
type (
|
||||
FilterKind string
|
||||
|
||||
FilterMeta struct {
|
||||
Wgt int `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Kind FilterKind `json:"kind"`
|
||||
Args []*FilterMetaArg `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
FilterMetaList []*FilterMeta
|
||||
|
||||
FilterMetaArg struct {
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Example string `json:"example"`
|
||||
Options map[string]interface{} `json:"options"`
|
||||
}
|
||||
)
|
||||
|
||||
func (fm FilterMetaList) Filter(f func(*FilterMeta) (bool, error)) (out FilterMetaList, err error) {
|
||||
var ok bool
|
||||
out = FilterMetaList{}
|
||||
|
||||
for i := range fm {
|
||||
if ok, err = f(fm[i]); err != nil {
|
||||
return
|
||||
} else if ok {
|
||||
out = append(out, fm[i])
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
29
server/pkg/apigw/types/handler.go
Normal file
29
server/pkg/apigw/types/handler.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type (
|
||||
HTTPHandler interface {
|
||||
Handler() HandlerFunc
|
||||
}
|
||||
|
||||
HTTPErrorHandler interface {
|
||||
Handler() ErrorHandlerFunc
|
||||
}
|
||||
|
||||
Handler interface {
|
||||
HTTPHandler
|
||||
fmt.Stringer
|
||||
|
||||
New(Config) Handler
|
||||
Merge([]byte, Config) (Handler, error)
|
||||
Meta() FilterMeta
|
||||
Enabled() bool
|
||||
}
|
||||
|
||||
HandlerFunc func(rw http.ResponseWriter, r *http.Request) error
|
||||
ErrorHandlerFunc func(rw http.ResponseWriter, r *http.Request, err error)
|
||||
)
|
||||
122
server/pkg/apigw/types/scope.go
Normal file
122
server/pkg/apigw/types/scope.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
h "github.com/cortezaproject/corteza/server/pkg/http"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
)
|
||||
|
||||
type (
|
||||
Scp map[string]interface{}
|
||||
)
|
||||
|
||||
func (s Scp) Keys() (kk []string) {
|
||||
for i := range s {
|
||||
kk = append(kk, i)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s Scp) Request() *h.Request {
|
||||
if _, ok := s["request"]; ok {
|
||||
return s["request"].(*h.Request)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Scp) Writer() http.ResponseWriter {
|
||||
if _, ok := s["writer"]; ok {
|
||||
return s["writer"].(http.ResponseWriter)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Opts returns gateway options from the scope
|
||||
//
|
||||
// Routes store a Config (built from settings), older callers may store the
|
||||
// options struct itself. Never returns nil; callers dereference the result
|
||||
// directly.
|
||||
func (s Scp) Opts() *options.ApigwOpt {
|
||||
if ss, ok := s["opts"]; ok {
|
||||
switch sss := ss.(type) {
|
||||
case *options.ApigwOpt:
|
||||
return withOptDefaults(sss)
|
||||
case options.ApigwOpt:
|
||||
return withOptDefaults(&sss)
|
||||
case *Config:
|
||||
return withOptDefaults(sss.toOpt())
|
||||
case Config:
|
||||
return withOptDefaults(sss.toOpt())
|
||||
}
|
||||
}
|
||||
|
||||
return withOptDefaults(&options.ApigwOpt{})
|
||||
}
|
||||
|
||||
// toOpt maps the gateway config onto the options struct
|
||||
//
|
||||
// ProxyEnableDebugLog has no counterpart in settings and stays off.
|
||||
func (c Config) toOpt() *options.ApigwOpt {
|
||||
return &options.ApigwOpt{
|
||||
Enabled: c.Enabled,
|
||||
ProfilerEnabled: c.Profiler.Enabled,
|
||||
ProfilerGlobal: c.Profiler.Global,
|
||||
ProxyFollowRedirects: c.Proxy.FollowRedirects,
|
||||
ProxyOutboundTimeout: c.Proxy.OutboundTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// withOptDefaults fills in values that would otherwise break the caller
|
||||
//
|
||||
// An unset outbound timeout yields an already-expired context, so fall back
|
||||
// to the same default the options carry.
|
||||
func withOptDefaults(o *options.ApigwOpt) *options.ApigwOpt {
|
||||
if o.ProxyOutboundTimeout <= 0 {
|
||||
o.ProxyOutboundTimeout = options.Apigw().ProxyOutboundTimeout
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
func (s Scp) Set(k string, v interface{}) {
|
||||
s[k] = v
|
||||
}
|
||||
|
||||
func (s Scp) Get(k string) (v interface{}, err error) {
|
||||
var ok bool
|
||||
|
||||
if v, ok = s[k]; !ok {
|
||||
err = fmt.Errorf("could not get key on index: %s", k)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Scp) Dict() map[string]interface{} {
|
||||
return *s
|
||||
}
|
||||
|
||||
func (s *Scp) Filter(fn func(k string, v interface{}) bool) *Scp {
|
||||
ss := Scp{}
|
||||
|
||||
for k, v := range *s {
|
||||
if !fn(k, v) {
|
||||
continue
|
||||
}
|
||||
|
||||
ss[k] = v
|
||||
}
|
||||
|
||||
return &ss
|
||||
}
|
||||
|
||||
func (s Scp) Has(k string) (has bool) {
|
||||
_, has = s[k]
|
||||
return
|
||||
}
|
||||
104
server/pkg/apigw/types/scope_test.go
Normal file
104
server/pkg/apigw/types/scope_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScp_Opts(t *testing.T) {
|
||||
var (
|
||||
// what routes actually put in the scope
|
||||
cfg = func() (c Config) {
|
||||
c.Enabled = true
|
||||
c.Profiler.Enabled = true
|
||||
c.Profiler.Global = true
|
||||
c.Proxy.FollowRedirects = true
|
||||
c.Proxy.OutboundTimeout = time.Second * 5
|
||||
return
|
||||
}()
|
||||
|
||||
noTimeout = func() (c Config) {
|
||||
c.Enabled = true
|
||||
return
|
||||
}()
|
||||
|
||||
defaultTimeout = options.Apigw().ProxyOutboundTimeout
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(s Scp)
|
||||
want *options.ApigwOpt
|
||||
}{
|
||||
{
|
||||
name: "config, as stored by the route",
|
||||
set: func(s Scp) { s.Set("opts", cfg) },
|
||||
want: &options.ApigwOpt{
|
||||
Enabled: true,
|
||||
ProfilerEnabled: true,
|
||||
ProfilerGlobal: true,
|
||||
ProxyFollowRedirects: true,
|
||||
ProxyOutboundTimeout: time.Second * 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "config pointer",
|
||||
set: func(s Scp) { s.Set("opts", &cfg) },
|
||||
want: &options.ApigwOpt{
|
||||
Enabled: true,
|
||||
ProfilerEnabled: true,
|
||||
ProfilerGlobal: true,
|
||||
ProxyFollowRedirects: true,
|
||||
ProxyOutboundTimeout: time.Second * 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
// an unset timeout yields an already expired context
|
||||
name: "config without an outbound timeout",
|
||||
set: func(s Scp) { s.Set("opts", noTimeout) },
|
||||
want: &options.ApigwOpt{Enabled: true, ProxyOutboundTimeout: defaultTimeout},
|
||||
},
|
||||
{
|
||||
name: "options value",
|
||||
set: func(s Scp) { s.Set("opts", options.ApigwOpt{Debug: true, ProxyOutboundTimeout: time.Second}) },
|
||||
want: &options.ApigwOpt{Debug: true, ProxyOutboundTimeout: time.Second},
|
||||
},
|
||||
{
|
||||
name: "options pointer keeps the debug log flag",
|
||||
set: func(s Scp) {
|
||||
s.Set("opts", &options.ApigwOpt{ProxyEnableDebugLog: true, ProxyOutboundTimeout: time.Second})
|
||||
},
|
||||
want: &options.ApigwOpt{ProxyEnableDebugLog: true, ProxyOutboundTimeout: time.Second},
|
||||
},
|
||||
{
|
||||
name: "nothing in scope",
|
||||
set: func(s Scp) {},
|
||||
want: &options.ApigwOpt{ProxyOutboundTimeout: defaultTimeout},
|
||||
},
|
||||
{
|
||||
name: "unexpected type in scope",
|
||||
set: func(s Scp) { s.Set("opts", "nonsense") },
|
||||
want: &options.ApigwOpt{ProxyOutboundTimeout: defaultTimeout},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
s = Scp{}
|
||||
)
|
||||
|
||||
tt.set(s)
|
||||
|
||||
opts := s.Opts()
|
||||
|
||||
// callers dereference this without checking
|
||||
req.NotNil(opts)
|
||||
req.Equal(tt.want, opts)
|
||||
})
|
||||
}
|
||||
}
|
||||
15
server/pkg/apigw/types/settings.go
Normal file
15
server/pkg/apigw/types/settings.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package types
|
||||
|
||||
type (
|
||||
Settings struct {
|
||||
Enabled bool
|
||||
ProfilerGlobal bool
|
||||
ProfilerEnabled bool
|
||||
}
|
||||
|
||||
AddSettings func(s Setter)
|
||||
|
||||
Setter interface {
|
||||
UpdateSettings(st ...AddSettings)
|
||||
}
|
||||
)
|
||||
5
server/pkg/apigw/types/store.go
Normal file
5
server/pkg/apigw/types/store.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package types
|
||||
|
||||
type (
|
||||
SecureStorager interface{}
|
||||
)
|
||||
76
server/pkg/apigw/types/test.go
Normal file
76
server/pkg/apigw/types/test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
st "github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
type (
|
||||
MockExecer struct {
|
||||
Exec_ func(context.Context, *Scp) (err error)
|
||||
Type_ func() FilterKind
|
||||
}
|
||||
|
||||
MockErrorHandler struct {
|
||||
Handler_ ErrorHandlerFunc
|
||||
}
|
||||
|
||||
MockHandler struct {
|
||||
Foo string `json:"foo"`
|
||||
Handler_ HandlerFunc
|
||||
}
|
||||
|
||||
MockStorer struct {
|
||||
F func(context.Context, st.ApigwFilterFilter) (st.ApigwFilterSet, st.ApigwFilterFilter, error)
|
||||
R func(context.Context, st.ApigwRouteFilter) (st.ApigwRouteSet, st.ApigwRouteFilter, error)
|
||||
}
|
||||
|
||||
MockRoundTripper func(*http.Request) (*http.Response, error)
|
||||
)
|
||||
|
||||
func (h MockHandler) New(cfg Config) Handler {
|
||||
return MockHandler{}
|
||||
}
|
||||
|
||||
func (h MockHandler) Enabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h MockHandler) String() string {
|
||||
return "MockHandler"
|
||||
}
|
||||
|
||||
func (h MockHandler) Handler() HandlerFunc {
|
||||
return h.Handler_
|
||||
}
|
||||
|
||||
func (h MockHandler) Merge(params []byte, cfg Config) (Handler, error) {
|
||||
err := json.NewDecoder(bytes.NewBuffer(params)).Decode(&h)
|
||||
return h, err
|
||||
}
|
||||
|
||||
func (h MockHandler) Meta() FilterMeta {
|
||||
return FilterMeta{
|
||||
Name: "return mocked filter metadata",
|
||||
}
|
||||
}
|
||||
|
||||
func (td MockStorer) SearchApigwRoutes(ctx context.Context, f st.ApigwRouteFilter) (s st.ApigwRouteSet, ff st.ApigwRouteFilter, err error) {
|
||||
return td.R(ctx, f)
|
||||
}
|
||||
|
||||
func (td MockStorer) SearchApigwFilters(ctx context.Context, f st.ApigwFilterFilter) (s st.ApigwFilterSet, ff st.ApigwFilterFilter, err error) {
|
||||
return td.F(ctx, f)
|
||||
}
|
||||
|
||||
func (h MockErrorHandler) Handler() ErrorHandlerFunc {
|
||||
return h.Handler_
|
||||
}
|
||||
|
||||
func (mrt MockRoundTripper) RoundTrip(rq *http.Request) (r *http.Response, err error) {
|
||||
return mrt(rq)
|
||||
}
|
||||
40
server/pkg/auth/errors.go
Normal file
40
server/pkg/auth/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
)
|
||||
|
||||
func errUnauthorized() error {
|
||||
return errors.New(
|
||||
errors.KindUnauthorized,
|
||||
|
||||
"unauthorized",
|
||||
|
||||
errors.Meta("type", "unauthorized"),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "internal"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "auth.errors.unauthorized"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
errors.StackTrimAtFn("http.HandlerFunc.ServeHTTP"),
|
||||
)
|
||||
}
|
||||
|
||||
func errUnauthorizedScope() error {
|
||||
return errors.New(
|
||||
errors.KindUnauthorized,
|
||||
|
||||
"unauthorized scope",
|
||||
|
||||
errors.Meta("type", "unauthorizedScope"),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, "internal"),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "auth.errors.unauthorizedScope"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
errors.StackTrimAtFn("http.HandlerFunc.ServeHTTP"),
|
||||
)
|
||||
}
|
||||
31
server/pkg/auth/extra.go
Normal file
31
server/pkg/auth/extra.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type (
|
||||
ExtraReqInfo struct {
|
||||
RemoteAddr string
|
||||
UserAgent string
|
||||
}
|
||||
)
|
||||
|
||||
func ExtraReqInfoMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ExtraReqInfo{}, ExtraReqInfo{
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
UserAgent: r.UserAgent(),
|
||||
})))
|
||||
})
|
||||
}
|
||||
|
||||
func GetExtraReqInfoFromContext(ctx context.Context) ExtraReqInfo {
|
||||
eti := ctx.Value(ExtraReqInfo{})
|
||||
if eti != nil {
|
||||
return eti.(ExtraReqInfo)
|
||||
} else {
|
||||
return ExtraReqInfo{}
|
||||
}
|
||||
}
|
||||
60
server/pkg/auth/identity.go
Normal file
60
server/pkg/auth/identity.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
identity struct {
|
||||
id uint64
|
||||
memberOf []uint64
|
||||
}
|
||||
)
|
||||
|
||||
// Anonymous constructs and returns new anonymous identity with system anonymous roles
|
||||
func Anonymous() *identity {
|
||||
return &identity{
|
||||
memberOf: AnonymousRoles().IDs(),
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated constructs and returns new authenticated identity with assigned roles + system authenticated roles
|
||||
func Authenticated(id uint64, rr ...uint64) *identity {
|
||||
return &identity{
|
||||
id: id,
|
||||
memberOf: append(rr, AuthenticatedRoles().IDs()...),
|
||||
}
|
||||
}
|
||||
|
||||
func (i identity) Identity() uint64 {
|
||||
return i.id
|
||||
}
|
||||
|
||||
func (i identity) Roles() []uint64 {
|
||||
return i.memberOf
|
||||
}
|
||||
|
||||
func (i identity) Valid() bool {
|
||||
return i.id > 0
|
||||
}
|
||||
|
||||
func (i identity) String() string {
|
||||
return fmt.Sprintf("%d", i.Identity())
|
||||
}
|
||||
|
||||
func ExtractFromSubClaim(sub string) (userID uint64, rr []uint64) {
|
||||
parts := strings.Split(sub, " ")
|
||||
rr = make([]uint64, len(parts)-1)
|
||||
for p := range parts {
|
||||
id, _ := strconv.ParseUint(parts[p], 10, 64)
|
||||
if p == 0 {
|
||||
userID = id
|
||||
} else {
|
||||
rr[p-1] = id
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
32
server/pkg/auth/identity_context.go
Normal file
32
server/pkg/auth/identity_context.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type (
|
||||
identityCtxKey struct{}
|
||||
)
|
||||
|
||||
func SetIdentityToContext(ctx context.Context, identity Identifiable) context.Context {
|
||||
return context.WithValue(ctx, identityCtxKey{}, identity)
|
||||
}
|
||||
|
||||
// GetIdentityFromContext always returns identity, either valid or anonymous
|
||||
//
|
||||
// For anonymous user, it auto appends all anonymous defined on the system
|
||||
func GetIdentityFromContext(ctx context.Context) Identifiable {
|
||||
if i := GetIdentityFromContextWithKey(ctx, identityCtxKey{}); i != nil && i.Valid() {
|
||||
return i
|
||||
} else {
|
||||
return Anonymous()
|
||||
}
|
||||
}
|
||||
|
||||
func GetIdentityFromContextWithKey(ctx context.Context, key interface{}) Identifiable {
|
||||
if i, ok := ctx.Value(key).(Identifiable); ok {
|
||||
return i
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
26
server/pkg/auth/interfaces.go
Normal file
26
server/pkg/auth/interfaces.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package auth
|
||||
|
||||
type (
|
||||
Identifiable interface {
|
||||
Identity() uint64
|
||||
Roles() []uint64
|
||||
Valid() bool
|
||||
String() string
|
||||
}
|
||||
|
||||
//TokenGenerator interface {
|
||||
// Encode(i Identifiable, clientID uint64, scope ...string) (token []byte, err error)
|
||||
// Generate(ctx context.Context, i Identifiable, clientID uint64, scope ...string) (token []byte, err error)
|
||||
//}
|
||||
|
||||
//TokenHandler interface {
|
||||
// TokenGenerator
|
||||
// HttpVerifier() func(http.Handler) http.Handler
|
||||
// HttpAuthenticator() func(http.Handler) http.Handler
|
||||
//}
|
||||
|
||||
Signer interface {
|
||||
Sign(userID uint64, pp ...interface{}) string
|
||||
Verify(signature string, userID uint64, pp ...interface{}) bool
|
||||
}
|
||||
)
|
||||
41
server/pkg/auth/role-security.go
Normal file
41
server/pkg/auth/role-security.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/slice"
|
||||
)
|
||||
|
||||
// ApplyRoleSecurity takes role security params (set of permitted, prohibited and forced roles)
|
||||
// and applies these rules to the set of given roles
|
||||
//
|
||||
// Filtered set of roles is returned
|
||||
//
|
||||
// String slices are used intentionally, because of the data source used
|
||||
func ApplyRoleSecurity(permitted, prohibited, forced []uint64, rr ...uint64) (out []uint64) {
|
||||
var (
|
||||
mPermitted = slice.ToUint64BoolMap(permitted)
|
||||
mProhibited = slice.ToUint64BoolMap(prohibited)
|
||||
mForced = slice.ToUint64BoolMap(forced)
|
||||
)
|
||||
|
||||
// iterate over user's roles and just append them (obeying allow&deny rules)
|
||||
// to list of mForced roles
|
||||
for _, r := range rr {
|
||||
if (len(mPermitted) == 0 || mPermitted[r]) && !mProhibited[r] {
|
||||
mForced[r] = true
|
||||
}
|
||||
}
|
||||
|
||||
out = make([]uint64, 0, len(mForced))
|
||||
for forcedRoleID := range mForced {
|
||||
out = append(out, forcedRoleID)
|
||||
}
|
||||
|
||||
// for stable output
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i] < out[j]
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
56
server/pkg/auth/role-security_test.go
Normal file
56
server/pkg/auth/role-security_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyRoleSecurity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permitted []uint64
|
||||
prohibited []uint64
|
||||
forced []uint64
|
||||
roles []uint64
|
||||
wantOut []uint64
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
},
|
||||
{
|
||||
"nil",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
[]uint64{},
|
||||
},
|
||||
{
|
||||
"one",
|
||||
[]uint64{1},
|
||||
[]uint64{2},
|
||||
[]uint64{3},
|
||||
[]uint64{1, 2},
|
||||
[]uint64{1, 3},
|
||||
},
|
||||
{
|
||||
"forced only",
|
||||
[]uint64{},
|
||||
[]uint64{},
|
||||
[]uint64{3, 2, 1},
|
||||
[]uint64{2},
|
||||
[]uint64{1, 2, 3},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.wantOut, ApplyRoleSecurity(tt.permitted, tt.prohibited, tt.forced, tt.roles...))
|
||||
})
|
||||
}
|
||||
}
|
||||
45
server/pkg/auth/scope.go
Normal file
45
server/pkg/auth/scope.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
)
|
||||
|
||||
const (
|
||||
scopeDelimiter = " "
|
||||
)
|
||||
|
||||
// CheckJwtScope verifies if required scope is in claim
|
||||
// We're using interface{} and casting it if needed to simplify usage of the fn by directly
|
||||
// using it with map[string]interface{} claims type
|
||||
func CheckJwtScope(token jwt.Token, required ...string) bool {
|
||||
if token == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
scopeClaimRaw, has := token.Get("scope")
|
||||
if !has {
|
||||
return false
|
||||
}
|
||||
|
||||
scopeClaim, ok := scopeClaimRaw.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return CheckScope(scopeClaim, required...)
|
||||
}
|
||||
|
||||
func CheckScope(scope string, required ...string) bool {
|
||||
scope = scopeDelimiter + strings.TrimSpace(scope) + scopeDelimiter
|
||||
|
||||
for _, req := range required {
|
||||
req = scopeDelimiter + strings.TrimSpace(req) + scopeDelimiter
|
||||
if strings.Contains(scope, req) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
48
server/pkg/auth/signer.go
Normal file
48
server/pkg/auth/signer.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
hmacSigner struct {
|
||||
secret []byte
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
hmacSumStringLength = 40
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultSigner Signer
|
||||
)
|
||||
|
||||
func SetupSigner(secret string) (err error) {
|
||||
DefaultSigner = HmacSigner(secret)
|
||||
return
|
||||
}
|
||||
|
||||
func HmacSigner(secret string) *hmacSigner {
|
||||
return &hmacSigner{
|
||||
secret: []byte(secret),
|
||||
}
|
||||
}
|
||||
|
||||
func (s hmacSigner) Sign(userID uint64, pp ...interface{}) string {
|
||||
h := hmac.New(sha1.New, s.secret)
|
||||
fmt.Fprintf(h, "%d ", userID)
|
||||
|
||||
for _, part := range pp {
|
||||
fmt.Fprintf(h, "%v ", part)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func (s hmacSigner) Verify(signature string, userID uint64, pp ...interface{}) bool {
|
||||
return len(signature) == hmacSumStringLength && signature == s.Sign(userID, pp...)
|
||||
}
|
||||
90
server/pkg/auth/system.go
Normal file
90
server/pkg/auth/system.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProvisionUserHandle = "corteza-provisioner"
|
||||
ServiceUserHandle = "corteza-service"
|
||||
FederationUserHandle = "corteza-federation"
|
||||
|
||||
BypassRoleHandle = "super-admin"
|
||||
AuthenticatedRoleHandle = "authenticated"
|
||||
AnonymousRoleHandle = "anonymous"
|
||||
)
|
||||
|
||||
var (
|
||||
provisionUser *types.User
|
||||
serviceUser *types.User
|
||||
federationUser *types.User
|
||||
|
||||
authenticatedRoles []*types.Role
|
||||
anonymousRoles []*types.Role
|
||||
bypassRoles []*types.Role
|
||||
)
|
||||
|
||||
// SetSystemUsers takes list of users and sets/updates appropriate
|
||||
// provision, service & fed users
|
||||
//
|
||||
// These are then accessed via special *User() fn.
|
||||
func SetSystemUsers(uu types.UserSet, rr types.RoleSet) {
|
||||
bpr := rr.FindByHandle(BypassRoleHandle)
|
||||
|
||||
for _, u := range uu {
|
||||
switch u.Handle {
|
||||
case ProvisionUserHandle:
|
||||
provisionUser = u.Clone()
|
||||
provisionUser.SetRoles(bpr.ID)
|
||||
case ServiceUserHandle:
|
||||
serviceUser = u.Clone()
|
||||
serviceUser.SetRoles(bpr.ID)
|
||||
case FederationUserHandle:
|
||||
federationUser = u.Clone()
|
||||
federationUser.SetRoles(bpr.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SetSystemRoles(rr types.RoleSet) {
|
||||
for _, r := range rr {
|
||||
switch r.Handle {
|
||||
case BypassRoleHandle:
|
||||
bypassRoles = append(bypassRoles, r.Clone())
|
||||
case AuthenticatedRoleHandle:
|
||||
authenticatedRoles = append(authenticatedRoles, r.Clone())
|
||||
case AnonymousRoleHandle:
|
||||
anonymousRoles = append(anonymousRoles, r.Clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BypassRoles returns all bypass Roles
|
||||
func BypassRoles() types.RoleSet {
|
||||
return bypassRoles
|
||||
}
|
||||
|
||||
// AuthenticatedRoles returns all authenticated Roles
|
||||
func AuthenticatedRoles() types.RoleSet {
|
||||
return authenticatedRoles
|
||||
}
|
||||
|
||||
// AnonymousRoles returns all anonymous Roles
|
||||
func AnonymousRoles() types.RoleSet {
|
||||
return anonymousRoles
|
||||
}
|
||||
|
||||
// ProvisionUser returns clone of system provision user
|
||||
func ProvisionUser() *types.User {
|
||||
return provisionUser.Clone()
|
||||
}
|
||||
|
||||
// ServiceUser returns clone of system service user
|
||||
func ServiceUser() *types.User {
|
||||
return serviceUser.Clone()
|
||||
}
|
||||
|
||||
// FederationUser returns clone of system federation user
|
||||
func FederationUser() *types.User {
|
||||
return federationUser.Clone()
|
||||
}
|
||||
394
server/pkg/auth/token_issuer.go
Normal file
394
server/pkg/auth/token_issuer.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/payload"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/go-oauth2/oauth2/v4/generates"
|
||||
"github.com/go-oauth2/oauth2/v4/models"
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
"github.com/lestrrat-go/jwx/jwk"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type (
|
||||
tokenIssuer struct {
|
||||
defaultRequest *TokenRequest
|
||||
|
||||
// store issued tokens
|
||||
store tokenIssuerStore
|
||||
|
||||
// lookup for issued tokens
|
||||
lookup tokenIssuerLookup
|
||||
|
||||
// generator for issued tokens
|
||||
generator tokenIssuerGenerator
|
||||
|
||||
// signer for issued tokens
|
||||
signer tokenIssuerSigner
|
||||
}
|
||||
|
||||
// IssuerOptFn modify toeknIssuer
|
||||
IssuerOptFn func(*tokenIssuer) error
|
||||
|
||||
TokenRequest struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
Expiration time.Duration
|
||||
Audience string
|
||||
Issuer string
|
||||
IssuedAt time.Time
|
||||
ClientID uint64
|
||||
UserID uint64
|
||||
Roles []uint64
|
||||
Scope []string
|
||||
}
|
||||
|
||||
// IssueOptFn functions modify TokenRequest
|
||||
IssueOptFn func(*TokenRequest) error
|
||||
|
||||
tokenIssuerStore func(context.Context, TokenRequest) error
|
||||
tokenIssuerLookup func(context.Context, string) error
|
||||
tokenIssuerGenerator func(context.Context, TokenRequest) (string, string, error)
|
||||
tokenIssuerSigner func(token jwt.Token) ([]byte, error)
|
||||
)
|
||||
|
||||
var (
|
||||
TokenIssuer *tokenIssuer
|
||||
|
||||
// wrapper around time.Now() that will aid service testing
|
||||
now = func() *time.Time {
|
||||
c := time.Now().Truncate(time.Second)
|
||||
return &c
|
||||
}
|
||||
)
|
||||
|
||||
// NewTokenIssuer initializes and returns new instance of JWT manager
|
||||
func NewTokenIssuer(opt ...IssuerOptFn) (issuer *tokenIssuer, err error) {
|
||||
issuer = &tokenIssuer{
|
||||
defaultRequest: &TokenRequest{Issuer: "cortezaproject.org"},
|
||||
|
||||
store: func(ctx context.Context, request TokenRequest) error {
|
||||
// elegantly handle unconfigured store
|
||||
return fmt.Errorf("token issuer store not configured")
|
||||
},
|
||||
|
||||
lookup: func(context.Context, string) error {
|
||||
// elegantly handle unconfigured lookup
|
||||
return fmt.Errorf("token issuer lookup not configured")
|
||||
},
|
||||
|
||||
signer: func(token jwt.Token) ([]byte, error) {
|
||||
// elegantly handle unconfigured signer
|
||||
return nil, fmt.Errorf("token issuer signer not configured")
|
||||
},
|
||||
|
||||
generator: DefaultAccessTokenGenerator,
|
||||
}
|
||||
|
||||
return issuer, applyToIssuer(issuer, opt...)
|
||||
}
|
||||
|
||||
// Issue issues new access token, stores it and returns signed JWT.
|
||||
func (i *tokenIssuer) Issue(ctx context.Context, opt ...IssueOptFn) (_ []byte, err error) {
|
||||
var req = i.newTokenRequest()
|
||||
if err = req.apply(opt...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.AccessToken+req.RefreshToken) > 0 {
|
||||
panic("can not issue new token with preset access and refresh tokens, " +
|
||||
"this is most likely an implementation mistake")
|
||||
}
|
||||
|
||||
if req.AccessToken, req.RefreshToken, err = i.generator(ctx, *req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = i.store(ctx, *req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return i.sign(req)
|
||||
}
|
||||
|
||||
func (i *tokenIssuer) Sign(opt ...IssueOptFn) (_ []byte, err error) {
|
||||
var req = i.newTokenRequest()
|
||||
if err = req.apply(opt...); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return i.sign(req)
|
||||
}
|
||||
|
||||
func (i *tokenIssuer) sign(req *TokenRequest) ([]byte, error) {
|
||||
if token, err := makeToken(req); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return i.signer(token)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns new token request and copies relevant values from the default token request on the issuer
|
||||
func (i *tokenIssuer) newTokenRequest() *TokenRequest {
|
||||
return &TokenRequest{
|
||||
Issuer: i.defaultRequest.Issuer,
|
||||
ClientID: i.defaultRequest.ClientID,
|
||||
Expiration: i.defaultRequest.Expiration,
|
||||
IssuedAt: *now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate performs token validation by checking existence of access-token in the store
|
||||
func (i *tokenIssuer) Validate(ctx context.Context, token jwt.Token) (err error) {
|
||||
if err = i.lookup(ctx, token.JwtID()); err != nil {
|
||||
return errUnauthorized()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeToken(req *TokenRequest) (_ jwt.Token, err error) {
|
||||
var (
|
||||
roles = make([]string, len(req.Roles))
|
||||
token = jwt.New()
|
||||
|
||||
toString = func(i uint64) string {
|
||||
return strconv.FormatUint(i, 10)
|
||||
}
|
||||
)
|
||||
|
||||
if len(req.Scope) == 0 {
|
||||
// for backward compatibility we default
|
||||
// unset scope to profile & api
|
||||
req.Scope = []string{"profile", "api"}
|
||||
}
|
||||
|
||||
if req.IssuedAt.IsZero() {
|
||||
req.IssuedAt = *now()
|
||||
}
|
||||
|
||||
for i, r := range req.Roles {
|
||||
roles[i] = toString(r)
|
||||
}
|
||||
|
||||
// The key part: store access token as JWT ID claim.
|
||||
// Claim will be extracted when JWT is validated and checked
|
||||
if err = token.Set(jwt.JwtIDKey, req.AccessToken); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set(jwt.SubjectKey, toString(req.UserID)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set(jwt.ExpirationKey, now().Add(req.Expiration).Unix()); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Audience != "" {
|
||||
if err = token.Set(jwt.AudienceKey, req.Audience); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = token.Set(jwt.IssuerKey, req.Issuer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set(jwt.IssuedAtKey, req.IssuedAt.Unix()); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set("clientID", toString(req.ClientID)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set("scope", strings.Join(req.Scope, " ")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set("roles", roles); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// IdentityFromToken decodes sub & roles claims into identity
|
||||
func IdentityFromToken(token jwt.Token) *identity {
|
||||
if token == nil {
|
||||
return Anonymous()
|
||||
}
|
||||
|
||||
var (
|
||||
roles, _ = token.Get("roles")
|
||||
)
|
||||
|
||||
return Authenticated(
|
||||
cast.ToUint64(token.Subject()),
|
||||
payload.ParseUint64s(cast.ToStringSlice(roles))...,
|
||||
)
|
||||
}
|
||||
|
||||
// DefaultAccessTokenGenerator uses token generator from oauth2 lib
|
||||
func DefaultAccessTokenGenerator(ctx context.Context, req TokenRequest) (string, string, error) {
|
||||
return generates.NewAccessGenerate().Token(
|
||||
ctx,
|
||||
&oauth2.GenerateBasic{
|
||||
Client: &models.Client{
|
||||
ID: strconv.FormatUint(req.ClientID, 10),
|
||||
},
|
||||
UserID: strconv.FormatUint(req.UserID, 10),
|
||||
CreateAt: *now(),
|
||||
TokenInfo: nil,
|
||||
Request: nil,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// WithSecretSigner configures token issuer with
|
||||
func WithSecretSigner(secret string) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
if len(secret) == 0 {
|
||||
return fmt.Errorf("JWK missing")
|
||||
}
|
||||
|
||||
var key jwk.Key
|
||||
if key, err = jwk.New([]byte(secret)); err != nil {
|
||||
return fmt.Errorf("could not parse JWK: %w", err)
|
||||
}
|
||||
|
||||
tm.signer = func(token jwt.Token) ([]byte, error) {
|
||||
return jwt.Sign(token, jwa.HS512, key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultClientID configures ID of the default auth client
|
||||
func WithDefaultClientID(ID uint64) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.defaultRequest.ClientID = ID
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultExpiration configures default token expiration time
|
||||
func WithDefaultExpiration(exp time.Duration) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.defaultRequest.Expiration = exp
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultIssuer configures default issuer claim
|
||||
func WithDefaultIssuer(iss string) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.defaultRequest.Issuer = iss
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithStore configures store function
|
||||
func WithStore(fn tokenIssuerStore) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.store = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithLookup configures lookup function
|
||||
func WithLookup(fn tokenIssuerLookup) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.lookup = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithGenerator configures generator function
|
||||
func WithGenerator(fn tokenIssuerGenerator) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.generator = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WithSigner configures signer function
|
||||
func WithSigner(fn tokenIssuerSigner) IssuerOptFn {
|
||||
return func(tm *tokenIssuer) (err error) {
|
||||
tm.signer = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (req *TokenRequest) apply(opt ...IssueOptFn) (err error) {
|
||||
for _, fn := range opt {
|
||||
if err = fn(req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func WithExpiration(e time.Duration) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.Expiration = e
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithIdentity(i Identifiable) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.UserID = i.Identity()
|
||||
t.Roles = i.Roles()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithAccessToken(access string) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.AccessToken = access
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithScope(ss ...string) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.Scope = ss
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithAudience(aud string) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.Audience = aud
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func WithClientID(id uint64) IssueOptFn {
|
||||
return func(t *TokenRequest) (err error) {
|
||||
t.ClientID = id
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func applyToIssuer(tm *tokenIssuer, opt ...IssuerOptFn) (err error) {
|
||||
for _, fn := range opt {
|
||||
if err = fn(tm); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
44
server/pkg/auth/token_issuer_test.go
Normal file
44
server/pkg/auth/token_issuer_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIdentityDecoding(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ii = []Identifiable{
|
||||
&identity{id: 1, memberOf: []uint64{}},
|
||||
&identity{id: 2, memberOf: []uint64{2, 3, 4}},
|
||||
}
|
||||
|
||||
tm, err = NewTokenIssuer(WithSecretSigner("test"))
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
|
||||
for _, i := range ii {
|
||||
t.Run(i.String(), func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
token jwt.Token
|
||||
signed []byte
|
||||
)
|
||||
|
||||
signed, err = tm.Sign(WithIdentity(i))
|
||||
req.NoError(err)
|
||||
|
||||
token, err = jwt.Parse(signed)
|
||||
req.NoError(err)
|
||||
|
||||
ift := IdentityFromToken(token)
|
||||
|
||||
req.Equal(i.Identity(), ift.Identity())
|
||||
req.Equal(i.Roles(), ift.Roles())
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
101
server/pkg/auth/token_middleware.go
Normal file
101
server/pkg/auth/token_middleware.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/go-chi/jwtauth"
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
"github.com/lestrrat-go/jwx/jwk"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
)
|
||||
|
||||
var (
|
||||
HttpTokenVerifier func(http.Handler) http.Handler
|
||||
)
|
||||
|
||||
// verifier returns a jwt verification middleware
|
||||
//
|
||||
// Tasks
|
||||
// 1. picks token from header, query or cookie
|
||||
// 2. extracts identity (if any) and adds it into request context
|
||||
//
|
||||
// In there is no token
|
||||
func verifier(ja *jwtauth.JWTAuth) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := jwtauth.VerifyRequest(ja, r, jwtauth.TokenFromHeader, jwtauth.TokenFromQuery, jwtauth.TokenFromCookie)
|
||||
ctx := r.Context()
|
||||
|
||||
if token != nil && err == nil {
|
||||
if err = TokenIssuer.Validate(ctx, token); err != nil {
|
||||
errors.ProperlyServeHTTP(w, r, err, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx = jwtauth.NewContext(ctx, token, err)
|
||||
ctx = SetIdentityToContext(ctx, IdentityFromToken(token))
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TokenVerifierMiddlewareWithSecretSigner returns HTTP handler with simple jwa.HS512 + secret verifier
|
||||
//
|
||||
// This should be 1:1 with token issuer!
|
||||
func TokenVerifierMiddlewareWithSecretSigner(secret string) (_ func(http.Handler) http.Handler, err error) {
|
||||
if len(secret) == 0 {
|
||||
return nil, fmt.Errorf("JWK missing")
|
||||
}
|
||||
|
||||
var key jwk.Key
|
||||
if key, err = jwk.New([]byte(secret)); err != nil {
|
||||
return nil, fmt.Errorf("could not parse JWK: %w", err)
|
||||
}
|
||||
|
||||
return verifier(jwtauth.New(jwa.HS512.String(), key, nil)), nil
|
||||
}
|
||||
|
||||
// HttpTokenValidator checks if there is a token with identity and matching scope claim
|
||||
//
|
||||
// Empty scope defaults to "api"!
|
||||
func HttpTokenValidator(scope ...string) func(http.Handler) http.Handler {
|
||||
if len(scope) == 0 {
|
||||
// ensure that scope is not empty
|
||||
scope = []string{"api"}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := verifyToken(r.Context(), scope...)
|
||||
if err != nil && !errors.Is(err, jwtauth.ErrNoTokenFound) {
|
||||
errors.ProperlyServeHTTP(w, r, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pulls token from context and validates scope & access-token
|
||||
func verifyToken(ctx context.Context, scope ...string) (err error) {
|
||||
var token jwt.Token
|
||||
if token, _, err = jwtauth.FromContext(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return errUnauthorized()
|
||||
}
|
||||
|
||||
if len(scope) > 0 && !CheckJwtScope(token, scope...) {
|
||||
return errUnauthorizedScope()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
13
server/pkg/cast2/boolean.go
Normal file
13
server/pkg/cast2/boolean.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package cast2
|
||||
|
||||
import "github.com/spf13/cast"
|
||||
|
||||
func Bool(in any, out *bool) error {
|
||||
aux, err := cast.ToBoolE(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*out = aux
|
||||
return nil
|
||||
}
|
||||
20
server/pkg/cast2/generic.go
Normal file
20
server/pkg/cast2/generic.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package cast2
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func ID(in any, out *id.ID) error {
|
||||
aux, err := cast.ToStringE(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*out, err = id.ByteID([]byte(aux))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
33
server/pkg/cast2/integer.go
Normal file
33
server/pkg/cast2/integer.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package cast2
|
||||
|
||||
import "github.com/spf13/cast"
|
||||
|
||||
func Uint64(in any, out *uint64) error {
|
||||
aux, err := cast.ToUint64E(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*out = aux
|
||||
return nil
|
||||
}
|
||||
|
||||
func Uint(in any, out *uint) error {
|
||||
aux, err := cast.ToUintE(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*out = aux
|
||||
return nil
|
||||
}
|
||||
|
||||
func Int(in any, out *int) error {
|
||||
aux, err := cast.ToIntE(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*out = aux
|
||||
return nil
|
||||
}
|
||||
23
server/pkg/cast2/integer_test.go
Normal file
23
server/pkg/cast2/integer_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package cast2
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUint64(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
target uint64 = 42
|
||||
)
|
||||
|
||||
req.NoError(Uint64(nil, &target))
|
||||
req.Equal(uint64(0), target)
|
||||
|
||||
req.NoError(Uint64("42", &target))
|
||||
req.Equal(uint64(42), target)
|
||||
|
||||
target = 5
|
||||
req.Error(Uint64("-42", &target))
|
||||
req.Equal(uint64(5), target)
|
||||
}
|
||||
32
server/pkg/cast2/map.go
Normal file
32
server/pkg/cast2/map.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cast2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/modern-go/reflect2"
|
||||
)
|
||||
|
||||
// Meta casts a value to a map[string]any
|
||||
func Meta(in any, out *map[string]any) (err error) {
|
||||
if reflect2.IsNil(in) {
|
||||
*out = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
switch aux := in.(type) {
|
||||
case []byte:
|
||||
err = json.Unmarshal(aux, out)
|
||||
case string:
|
||||
err = json.Unmarshal([]byte(aux), out)
|
||||
case map[string]any:
|
||||
*out = aux
|
||||
default:
|
||||
err = fmt.Errorf("unsupported type: %T", in)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
return fmt.Errorf("can not cast to Meta: %w", err)
|
||||
}
|
||||
30
server/pkg/cast2/map_test.go
Normal file
30
server/pkg/cast2/map_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package cast2
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMap(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
{
|
||||
target := make(map[string]any)
|
||||
req.NoError(Meta([]byte(`{"a":"b"}`), &target))
|
||||
req.Equal(map[string]any{"a": "b"}, target)
|
||||
}
|
||||
|
||||
{
|
||||
target := make(map[string]any)
|
||||
req.NoError(Meta(`{"a":"b"}`, &target))
|
||||
req.Equal(map[string]any{"a": "b"}, target)
|
||||
}
|
||||
|
||||
{
|
||||
target := make(map[string]any)
|
||||
req.NoError(Meta(map[string]any{"a": "b"}, &target))
|
||||
req.Equal(map[string]any{"a": "b"}, target)
|
||||
}
|
||||
}
|
||||
12
server/pkg/cast2/slice.go
Normal file
12
server/pkg/cast2/slice.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package cast2
|
||||
|
||||
// Anys converts any kinds of values to a []any slice
|
||||
func Anys[C any](in ...C) (out []any) {
|
||||
out = make([]any, len(in))
|
||||
|
||||
for i := range in {
|
||||
out[i] = in[i]
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
13
server/pkg/cast2/string.go
Normal file
13
server/pkg/cast2/string.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package cast2
|
||||
|
||||
import "github.com/spf13/cast"
|
||||
|
||||
func String(in any, out *string) error {
|
||||
aux, err := cast.ToStringE(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*out = aux
|
||||
return nil
|
||||
}
|
||||
40
server/pkg/cast2/time.go
Normal file
40
server/pkg/cast2/time.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package cast2
|
||||
|
||||
import (
|
||||
"github.com/modern-go/reflect2"
|
||||
"github.com/spf13/cast"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Time(in any, out *time.Time) error {
|
||||
if reflect2.IsNil(in) {
|
||||
if out != nil {
|
||||
*out = time.Time{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
aux, err := cast.ToTimeE(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*out = aux
|
||||
return nil
|
||||
}
|
||||
|
||||
func TimePtr(in any, out **time.Time) error {
|
||||
if reflect2.IsNil(in) {
|
||||
*out = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
aux, err := cast.ToTimeE(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*out = &aux
|
||||
return nil
|
||||
}
|
||||
32
server/pkg/cast2/time_test.go
Normal file
32
server/pkg/cast2/time_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cast2
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTime(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
|
||||
target1 time.Time
|
||||
target2 = &time.Time{}
|
||||
|
||||
in = "2006-01-02T15:04:05" // abuse format as value
|
||||
)
|
||||
|
||||
req.NoError(Time(in, &target1))
|
||||
req.Equal(in, target1.Format(in))
|
||||
req.NotZero(target1)
|
||||
|
||||
req.NoError(TimePtr(in, &target2))
|
||||
req.NotNil(target2)
|
||||
req.Equal(in, target2.Format(in))
|
||||
|
||||
req.NoError(Time(nil, &target1))
|
||||
req.Zero(target1)
|
||||
|
||||
req.NoError(TimePtr(nil, &target2))
|
||||
req.Nil(target2)
|
||||
}
|
||||
95
server/pkg/cli/commands.go
Normal file
95
server/pkg/cli/commands.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
rootCommand = &cobra.Command{
|
||||
Use: "corteza-server",
|
||||
Aliases: []string{"corteza", "server"},
|
||||
TraverseChildren: true,
|
||||
}
|
||||
|
||||
serveApiCommand = &cobra.Command{
|
||||
Use: "serve-api",
|
||||
Aliases: []string{"serve"},
|
||||
|
||||
Short: "Start HTTP server with REST API",
|
||||
}
|
||||
|
||||
upgradeCommand = &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrade tasks",
|
||||
}
|
||||
|
||||
provisionCommand = &cobra.Command{
|
||||
Use: "provision",
|
||||
Short: "Provision tasks",
|
||||
}
|
||||
|
||||
versionCommand = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Version",
|
||||
}
|
||||
|
||||
// List of commands that do not
|
||||
// run initialization (db connection, etc...)
|
||||
light = map[string]bool{
|
||||
"help": true,
|
||||
versionCommand.Use: true,
|
||||
}
|
||||
)
|
||||
|
||||
// RootCommand creates root command with a simple persistent-pre-run callback
|
||||
//
|
||||
// Callback is called when not executed with help subcommand
|
||||
func RootCommand(ppRunEfn func() error) *cobra.Command {
|
||||
rootCommand.PersistentPreRunE = func(cmd *cobra.Command, args []string) (err error) {
|
||||
if light[cmd.Name()] {
|
||||
// Do not run hooks on parts on help command
|
||||
return nil
|
||||
}
|
||||
|
||||
if ppRunEfn != nil {
|
||||
return ppRunEfn()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return rootCommand
|
||||
}
|
||||
|
||||
func ServeCommand(runEfn func() error) *cobra.Command {
|
||||
serveApiCommand.RunE = func(cmd *cobra.Command, args []string) (err error) {
|
||||
return runEfn()
|
||||
}
|
||||
|
||||
return serveApiCommand
|
||||
}
|
||||
|
||||
func UpgradeCommand(dbfn func() error) *cobra.Command {
|
||||
upgradeCommand.RunE = func(cmd *cobra.Command, args []string) (err error) {
|
||||
return dbfn()
|
||||
}
|
||||
|
||||
return upgradeCommand
|
||||
}
|
||||
|
||||
func ProvisionCommand(cffn func() error) *cobra.Command {
|
||||
provisionCommand.RunE = func(cmd *cobra.Command, args []string) (err error) {
|
||||
return cffn()
|
||||
}
|
||||
|
||||
return provisionCommand
|
||||
}
|
||||
|
||||
func VersionCommand() *cobra.Command {
|
||||
versionCommand.Run = func(cmd *cobra.Command, args []string) {
|
||||
cmd.Println(version.Version)
|
||||
}
|
||||
|
||||
return versionCommand
|
||||
}
|
||||
16
server/pkg/cli/context.go
Normal file
16
server/pkg/cli/context.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
ctxwrap "github.com/SentimensRG/ctx"
|
||||
"github.com/SentimensRG/ctx/sigctx"
|
||||
)
|
||||
|
||||
// Context is small wrapper that returns sig-term bound context
|
||||
//
|
||||
// This can be used as (proper) background context that properly terminates
|
||||
// all subroutines.
|
||||
func Context() context.Context {
|
||||
return ctxwrap.AsContext(sigctx.New())
|
||||
}
|
||||
57
server/pkg/cli/env.go
Normal file
57
server/pkg/cli/env.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// LoadEnv loads env-variables (KEY=VAL) from provided files and paths (by searching for .env file in the given path)
|
||||
//
|
||||
// Please note that loaded values
|
||||
// DO NOT OVERRIDE the existing
|
||||
// environmental variables
|
||||
func LoadEnv(pp ...string) error {
|
||||
// preparse the input and try to figure out if .env should be appended
|
||||
var checked = make([]string, 0, len(pp))
|
||||
for _, p := range pp {
|
||||
if s, err := os.Stat(p); err != nil {
|
||||
return err
|
||||
} else if s.IsDir() {
|
||||
chk := path.Join(p, ".env")
|
||||
if _, err = os.Stat(chk); err == nil {
|
||||
// make sure only .env files
|
||||
checked = append(checked, chk)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
checked = append(checked, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(checked) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return godotenv.Load(checked...)
|
||||
}
|
||||
|
||||
// EnvCommand outputs all loaded env variables
|
||||
func EnvCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "env",
|
||||
Long: "Outputs list (sorted by key) of of all environmental variables. Can be used for diagnosis and debugging",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
kv := os.Environ()
|
||||
sort.Strings(kv)
|
||||
for _, l := range kv {
|
||||
cmd.Println(l)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
28
server/pkg/cli/helpers.go
Normal file
28
server/pkg/cli/helpers.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func HandleError(err error) {
|
||||
if err = unwrapGeneric(err); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func unwrapGeneric(err error) error {
|
||||
for {
|
||||
g, ok := err.(interface{ IsGeneric() bool })
|
||||
if ok && g != nil && g.IsGeneric() {
|
||||
err = errors.Unwrap(err)
|
||||
continue
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
297
server/pkg/codegen/actions.go
Normal file
297
server/pkg/codegen/actions.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/handle"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type (
|
||||
// definitions are in multiple files and each definition
|
||||
// should produce one output
|
||||
actionsDef struct {
|
||||
Component string
|
||||
Source string
|
||||
outputDir string
|
||||
|
||||
// List of imports
|
||||
// Used only by generated file and not pre-generated-user-file
|
||||
Import []string `yaml:"import"`
|
||||
|
||||
Service string `yaml:"service"`
|
||||
Resource string `yaml:"resource"`
|
||||
|
||||
// Default severity for actions
|
||||
DefaultActionSeverity string `yaml:"defaultActionSeverity"`
|
||||
|
||||
// Default severity for errors
|
||||
DefaultErrorSeverity string `yaml:"defaultErrorSeverity"`
|
||||
|
||||
// If at least one of the errors has HTTP status defined,
|
||||
// add support for http errors
|
||||
SupportHttpErrors bool
|
||||
|
||||
Props []*propsDef
|
||||
Actions []*actionDef
|
||||
Errors []*errorDef
|
||||
}
|
||||
|
||||
// List of event/log properties that can/will be captured
|
||||
// and injected into log or message string
|
||||
propsDef struct {
|
||||
Name string
|
||||
Type string
|
||||
Fields []string
|
||||
Builtin bool
|
||||
}
|
||||
|
||||
actionDef struct {
|
||||
// Action name
|
||||
Action string `yaml:"action"`
|
||||
|
||||
// String to log when action is successful
|
||||
Log string `yaml:"log"`
|
||||
|
||||
// String to log when error was yield
|
||||
//ErrorLog string `yaml:"errorLog"`
|
||||
|
||||
// Action severity
|
||||
Severity string `yaml:"severity"`
|
||||
}
|
||||
|
||||
// Event definition
|
||||
errorDef struct {
|
||||
// Error key
|
||||
// message can contain {variables} from meta data
|
||||
Error string `yaml:"error"`
|
||||
|
||||
// Error key
|
||||
// message can contain {variables} from meta data
|
||||
Message string `yaml:"message"`
|
||||
|
||||
// Formatted and readable audit log message
|
||||
// message can contain {variables} from meta data
|
||||
Log string `yaml:"log"`
|
||||
|
||||
// Longer message or error description that can help resolving the error
|
||||
Details string `yaml:"details"`
|
||||
|
||||
// Relative link to content in the documentation
|
||||
Documentation string `yaml:"documentation"`
|
||||
|
||||
// Reference to "safe" error
|
||||
// safe error should hide any information that might cause
|
||||
// personal data leakage or expose system internals
|
||||
MaskedWith string `yaml:"maskedWith"`
|
||||
|
||||
// Error severity
|
||||
Severity string `yaml:"severity"`
|
||||
|
||||
// HTTP Status code for this error
|
||||
HttpStatus string `yaml:"httpStatus"`
|
||||
}
|
||||
)
|
||||
|
||||
// Processes multiple action definitions
|
||||
func procActions(mm ...string) (dd []*actionsDef, err error) {
|
||||
var (
|
||||
f io.ReadCloser
|
||||
d *actionsDef
|
||||
)
|
||||
|
||||
dd = make([]*actionsDef, 0)
|
||||
for _, m := range mm {
|
||||
err = func() error {
|
||||
|
||||
if f, err = os.Open(m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
d = &actionsDef{Component: strings.SplitN(m, string(filepath.Separator), 2)[0]}
|
||||
|
||||
if err := yaml.NewDecoder(f).Decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = actionNormalize(d); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.Source = m
|
||||
d.outputDir = path.Dir(m)
|
||||
|
||||
dd = append(dd, d)
|
||||
return nil
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not process %s: %w", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
return dd, nil
|
||||
}
|
||||
|
||||
func actionNormalize(d *actionsDef) error {
|
||||
// Prepend generic error
|
||||
d.Errors = append([]*errorDef{{
|
||||
Error: "generic",
|
||||
Message: "failed to complete request due to internal error",
|
||||
Log: "{err}",
|
||||
Severity: "error",
|
||||
}}, d.Errors...)
|
||||
|
||||
// index known meta fields and sanitize types (no type => string type)
|
||||
knownProps := map[string]bool{
|
||||
"err": true,
|
||||
}
|
||||
|
||||
for _, m := range d.Props {
|
||||
if m.Type == "" {
|
||||
m.Type = "string"
|
||||
}
|
||||
|
||||
// very optimistic check if referenced type is builtin or not
|
||||
m.Builtin = !strings.Contains(m.Type, ".")
|
||||
|
||||
knownProps[m.Name] = true
|
||||
|
||||
for _, f := range m.Fields {
|
||||
knownProps[fmt.Sprintf("%s.%s", m.Name, f)] = true
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range d.Actions {
|
||||
if a.Severity == "" {
|
||||
a.Severity = d.DefaultActionSeverity
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range d.Errors {
|
||||
if e.Severity == "" {
|
||||
e.Severity = d.DefaultErrorSeverity
|
||||
}
|
||||
|
||||
if e.HttpStatus != "" {
|
||||
d.SupportHttpErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
checkHandle := func(s string) error {
|
||||
if !handle.IsValid(s) {
|
||||
return fmt.Errorf("handle empty")
|
||||
|
||||
}
|
||||
|
||||
if !handle.IsValid(s) {
|
||||
return fmt.Errorf("invalid handle format: %q", s)
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
placeholderMatcher := regexp.MustCompile(`\{\{(.+?)\}\}`)
|
||||
checkPlaceholders := func(def string, kind, s string) error {
|
||||
for _, match := range placeholderMatcher.FindAllStringSubmatch(s, 1) {
|
||||
placeholder := match[1]
|
||||
if !knownProps[placeholder] {
|
||||
return fmt.Errorf("unknown placeholder %q used in %s for %s", placeholder, def, kind)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, a := range d.Actions {
|
||||
checkHandle(a.Action)
|
||||
if a.Log == "" {
|
||||
// If no log is defined, use action handle
|
||||
a.Log = a.Action
|
||||
}
|
||||
|
||||
if err := checkPlaceholders(a.Action, "log", a.Log); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range d.Errors {
|
||||
if err := checkHandle(e.Error); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkPlaceholders(e.Error, "message", e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkPlaceholders(e.Error, "log", e.Log); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a actionsDef) Package() string {
|
||||
return path.Base(path.Dir(a.Source))
|
||||
}
|
||||
|
||||
func (a actionDef) SeverityConstName() string {
|
||||
return severityConstName(a.Severity)
|
||||
}
|
||||
|
||||
func (e errorDef) SeverityConstName() string {
|
||||
return severityConstName(e.Severity)
|
||||
}
|
||||
|
||||
func severityConstName(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "emergency":
|
||||
return "actionlog.Emergency"
|
||||
case "alert":
|
||||
return "actionlog.Alert"
|
||||
case "crit", "critical":
|
||||
return "actionlog.Critical"
|
||||
case "warn", "warning":
|
||||
return "actionlog.Warning"
|
||||
case "notice":
|
||||
return "actionlog.Notice"
|
||||
case "info", "informational":
|
||||
return "actionlog.Info"
|
||||
case "debug":
|
||||
return "actionlog.Debug"
|
||||
default:
|
||||
return "actionlog.Err"
|
||||
}
|
||||
}
|
||||
|
||||
func genActions(tpl *template.Template, dd ...*actionsDef) (err error) {
|
||||
var (
|
||||
// Will only be generated if file does not exist previously
|
||||
tplActionsGen = tpl.Lookup("actions.gen.go.tpl")
|
||||
|
||||
dst string
|
||||
)
|
||||
|
||||
for _, d := range dd {
|
||||
// Generic code, actions for every resource goes to a separated file
|
||||
dst = path.Join(d.outputDir, path.Base(d.Source)[:strings.LastIndex(path.Base(d.Source), ".")]+".gen.go")
|
||||
err = goTemplate(dst, tplActionsGen, d)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
235
server/pkg/codegen/afunc.go
Normal file
235
server/pkg/codegen/afunc.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
. "github.com/cortezaproject/corteza/server/pkg/y7s"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type (
|
||||
// definitions are in one file
|
||||
aFuncDefs struct {
|
||||
Package string
|
||||
Name string
|
||||
Source string
|
||||
Prefix string
|
||||
outputDir string
|
||||
|
||||
// List of imports
|
||||
// Used only by generated file and not pre-generated-user-file
|
||||
Imports []string
|
||||
|
||||
Functions aFunctionSet
|
||||
}
|
||||
|
||||
aFunctionSet []*aFuncDef
|
||||
|
||||
aFuncDef struct {
|
||||
Name string
|
||||
Kind string
|
||||
Labels map[string]string
|
||||
Meta *aFuncMetaDef
|
||||
Params aFuncParamSet
|
||||
Results aFuncResultSet
|
||||
}
|
||||
|
||||
aFuncParamSet []*aFuncParamDef
|
||||
aFuncResultSet []*aFuncResultDef
|
||||
|
||||
aFuncParamDef struct {
|
||||
Name string
|
||||
Required bool
|
||||
IsArray bool `yaml:"isArray"`
|
||||
Types []*aFuncParamTypeVarDef
|
||||
Meta *aFuncParamMetaDef
|
||||
}
|
||||
|
||||
aFuncParamTypeVarDef struct {
|
||||
WorkflowType string `yaml:"wf"`
|
||||
GoType string `yaml:"go"`
|
||||
Suffix string
|
||||
}
|
||||
|
||||
aFuncResultDef struct {
|
||||
Name string
|
||||
IsArray bool `yaml:"isArray"`
|
||||
WorkflowType string `yaml:"wf"`
|
||||
GoType string `yaml:"go"`
|
||||
Meta *aFuncParamMetaDef
|
||||
}
|
||||
|
||||
aFuncMetaDef struct {
|
||||
Short string
|
||||
Description string
|
||||
Visual map[string]interface{}
|
||||
}
|
||||
|
||||
aFuncParamMetaDef struct {
|
||||
Label string
|
||||
Description string
|
||||
Visual map[string]interface{}
|
||||
}
|
||||
)
|
||||
|
||||
func procAutomationFunctions(mm ...string) (dd []*aFuncDefs, err error) {
|
||||
for _, m := range mm {
|
||||
f, err := os.Open(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s read failed: %w", m, err)
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
var (
|
||||
d = &aFuncDefs{
|
||||
Package: "automation",
|
||||
Source: m,
|
||||
Name: path.Base(m),
|
||||
outputDir: path.Dir(m),
|
||||
}
|
||||
)
|
||||
|
||||
d.Name = d.Name[:len(d.Name)-13]
|
||||
|
||||
if err := yaml.NewDecoder(f).Decode(d); err != nil {
|
||||
return nil, fmt.Errorf("could not decode %s: %w", m, err)
|
||||
}
|
||||
|
||||
dd = append(dd, d)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (set *aFunctionSet) UnmarshalYAML(n *yaml.Node) error {
|
||||
return Each(n, func(k *yaml.Node, v *yaml.Node) (err error) {
|
||||
def := &aFuncDef{Name: k.Value}
|
||||
|
||||
if err = v.Decode(&def); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if def.Kind == "" {
|
||||
def.Kind = "function"
|
||||
}
|
||||
|
||||
*set = append(*set, def)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (set *aFuncParamSet) UnmarshalYAML(n *yaml.Node) error {
|
||||
return Each(n, func(k *yaml.Node, v *yaml.Node) (err error) {
|
||||
def := aFuncParamDef{}
|
||||
if k != nil {
|
||||
def.Name = k.Value
|
||||
}
|
||||
|
||||
*set = append(*set, &def)
|
||||
return v.Decode(&def)
|
||||
})
|
||||
}
|
||||
|
||||
func (set *aFuncResultSet) UnmarshalYAML(n *yaml.Node) error {
|
||||
return Each(n, func(k *yaml.Node, v *yaml.Node) (err error) {
|
||||
def := aFuncResultDef{}
|
||||
if k != nil {
|
||||
def.Name = k.Value
|
||||
}
|
||||
|
||||
*set = append(*set, &def)
|
||||
return v.Decode(&def)
|
||||
})
|
||||
}
|
||||
|
||||
func expandAutomationFunctionTypes(ff []*aFuncDefs, tt []*exprTypesDef) {
|
||||
// index of all known types
|
||||
ti := make(map[string]*exprTypeDef)
|
||||
|
||||
for _, t := range tt {
|
||||
for typ, d := range t.Types {
|
||||
ti[typ] = d
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range ff {
|
||||
for _, fn := range f.Functions {
|
||||
for _, p := range fn.Params {
|
||||
for _, t := range p.Types {
|
||||
if ti[t.WorkflowType] == nil {
|
||||
fmt.Printf("%s/%s(): unknown type %q used for param %q\n", f.Prefix, fn.Name, t.WorkflowType, p.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if t.GoType == "" {
|
||||
t.GoType = ti[t.WorkflowType].As
|
||||
if "[]TypedValue" == t.GoType {
|
||||
t.GoType = "[]expr.TypedValue"
|
||||
}
|
||||
}
|
||||
|
||||
if t.Suffix == "" && len(p.Types) > 1 {
|
||||
t.Suffix = t.WorkflowType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range fn.Results {
|
||||
if ti[r.WorkflowType] == nil {
|
||||
fmt.Printf("%s/%s(): unknown type %q used for result %q\n", f.Prefix, fn.Name, r.WorkflowType, r.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if r.GoType == "" {
|
||||
r.GoType = ti[r.WorkflowType].As
|
||||
if "[]TypedValue" == r.GoType {
|
||||
r.GoType = "[]expr.TypedValue"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func genAutomationFunctions(tpl *template.Template, dd ...*aFuncDefs) (err error) {
|
||||
var (
|
||||
// Will only be generated if file does not exist previously
|
||||
tplAFuncGen = tpl.Lookup("afunc.gen.go.tpl")
|
||||
|
||||
dst string
|
||||
)
|
||||
|
||||
for _, d := range dd {
|
||||
// Generic code, actions for every resource goes to a separated file
|
||||
dst = path.Join(d.outputDir, path.Base(d.Source)[:strings.LastIndex(path.Base(d.Source), ".")]+".gen.go")
|
||||
json.NewEncoder(os.Stdout).SetIndent("", " ")
|
||||
err = goTemplate(dst, tplAFuncGen, d)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// genAutomationFunctionDocs look for afunc.gen.adoc.tpl and generates afunc.gen.adoc from it
|
||||
func genAutomationFunctionDocs(tpl *template.Template, docsPath string, dd ...*aFuncDefs) (err error) {
|
||||
var (
|
||||
typeGenAdoc = tpl.Lookup("afunc.gen.adoc.tpl")
|
||||
|
||||
dst string
|
||||
)
|
||||
|
||||
dst = path.Join(docsPath, "afunc.gen.adoc")
|
||||
return plainTemplate(dst, typeGenAdoc, map[string]interface{}{
|
||||
"Definitions": dd,
|
||||
})
|
||||
}
|
||||
318
server/pkg/codegen/assets/actions.gen.go.tpl
Normal file
318
server/pkg/codegen/assets/actions.gen.go.tpl
Normal file
@@ -0,0 +1,318 @@
|
||||
package {{ .Package }}
|
||||
|
||||
// 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:
|
||||
// {{ .Source }}
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
{{- range .Import }}
|
||||
{{ normalizeImport . }}
|
||||
{{- end }}
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
)
|
||||
|
||||
type (
|
||||
{{ $.Service }}ActionProps struct {
|
||||
{{- range $prop := $.Props }}
|
||||
{{ $prop.Name }} {{ $prop.Type }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
{{ if $.Actions }}
|
||||
{{ $.Service }}Action struct {
|
||||
timestamp time.Time
|
||||
resource string
|
||||
action string
|
||||
log string
|
||||
severity actionlog.Severity
|
||||
|
||||
// prefix for error when action fails
|
||||
errorMessage string
|
||||
|
||||
props *{{ $.Service }}ActionProps
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
{{ $.Service }}LogMetaKey struct {}
|
||||
{{ $.Service }}PropsMetaKey struct {}
|
||||
)
|
||||
|
||||
var (
|
||||
// just a placeholder to cover template cases w/o fmt package use
|
||||
_ = fmt.Println
|
||||
)
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Props methods
|
||||
|
||||
{{- range $prop := $.Props }}
|
||||
// {{ camelCase "set" $prop.Name }} updates {{ $.Service }}ActionProps's {{ $prop.Name }}
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (p *{{ $.Service }}ActionProps) {{ camelCase "set" $prop.Name }}({{ $prop.Name }} {{ $prop.Type }}) *{{ $.Service }}ActionProps {
|
||||
p.{{ $prop.Name }} = {{ $prop.Name }}
|
||||
return p
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
|
||||
// Serialize converts {{ $.Service }}ActionProps to actionlog.Meta
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (p {{ $.Service }}ActionProps) Serialize() actionlog.Meta {
|
||||
var (
|
||||
m = make(actionlog.Meta)
|
||||
)
|
||||
|
||||
{{ range $prop := $.Props }}
|
||||
{{- if $prop.Builtin }}
|
||||
m.Set("{{ $prop.Name }}", p.{{ $prop.Name }}, true)
|
||||
{{- else }}
|
||||
if p.{{ $prop.Name }} != nil {
|
||||
{{- range $f := $prop.Fields }}
|
||||
m.Set("{{ $prop.Name }}.{{ $f }}", p.{{ $prop.Name }}.{{ camelCase " " $f }}, true)
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// tr translates string and replaces meta value placeholder with values
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (p {{ $.Service }}ActionProps) Format(in string, err error) string {
|
||||
var (
|
||||
pairs = []string{"{{"{{"}}err}}"}
|
||||
|
||||
{{- if $.Props }}
|
||||
// first non-empty string
|
||||
fns = func(ii ... interface{}) string {
|
||||
for _, i:= range ii {
|
||||
if s :=fmt.Sprintf("%v", i); len(s) > 0 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
{{- end }}
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
pairs = append(pairs, err.Error())
|
||||
} else {
|
||||
pairs = append(pairs, "nil")
|
||||
}
|
||||
|
||||
{{- range $prop := $.Props }}
|
||||
{{- if $prop.Builtin }}
|
||||
pairs = append(pairs, "{{"{{"}}{{ $prop.Name }}}}", fns(p.{{ $prop.Name }}))
|
||||
{{- else }}
|
||||
|
||||
if p.{{ $prop.Name }} != nil {
|
||||
// replacement for "{{"{{"}}{{ $prop.Name }}}}" (in order how fields are defined)
|
||||
pairs = append(
|
||||
pairs,
|
||||
"{{"{{"}}{{ $prop.Name }}}}",
|
||||
fns(
|
||||
{{- range $f := $prop.Fields }}
|
||||
p.{{ $prop.Name }}.{{ camelCase " " $f }},
|
||||
{{- end }}
|
||||
),
|
||||
)
|
||||
|
||||
{{- range $f := $prop.Fields }}
|
||||
pairs = append(pairs, "{{"{{"}}{{ $prop.Name }}.{{ $f }}}}", fns(p.{{ $prop.Name }}.{{ camelCase " " $f }}))
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
return strings.NewReplacer(pairs...).Replace(in)
|
||||
}
|
||||
|
||||
{{ if $.Actions }}
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action methods
|
||||
|
||||
// String returns loggable description as string
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (a *{{ $.Service }}Action) String() string {
|
||||
var props = &{{ $.Service }}ActionProps{}
|
||||
|
||||
if a.props != nil {
|
||||
props = a.props
|
||||
}
|
||||
|
||||
return props.Format(a.log, nil)
|
||||
}
|
||||
|
||||
func (e *{{ $.Service }}Action) ToAction() *actionlog.Action {
|
||||
return &actionlog.Action{
|
||||
Resource: e.resource,
|
||||
Action: e.action,
|
||||
Severity: e.severity,
|
||||
Description: e.String(),
|
||||
Meta: e.props.Serialize(),
|
||||
}
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
{{ if $.Actions }}
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Action constructors
|
||||
|
||||
{{ range $a := $.Actions }}
|
||||
// {{ camelCase "" $.Service "Action" $a.Action }} returns "{{ $.Resource }}.{{ $a.Action }}" action
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func {{ camelCase "" $.Service "Action" $a.Action }}(props ... *{{ $.Service }}ActionProps) *{{ $.Service }}Action {
|
||||
a := &{{ $.Service }}Action{
|
||||
timestamp: time.Now(),
|
||||
resource: "{{ $.Resource }}",
|
||||
action: "{{ $a.Action }}",
|
||||
log: "{{ $a.Log }}",
|
||||
severity: {{ $a.SeverityConstName }},
|
||||
}
|
||||
|
||||
if len(props) > 0 {
|
||||
a.props = props[0]
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if $.Errors }}
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
// Error constructors
|
||||
|
||||
{{ range $e := $.Errors }}
|
||||
// {{ camelCase "" $.Service "Err" $e.Error }} returns "{{ $.Resource }}.{{ $e.Error }}" as *errors.Error
|
||||
//
|
||||
{{- if $e.MaskedWith }}
|
||||
// Note: This error will be wrapped with safe ({{ $.Resource }}.{{ $e.MaskedWith }}) error!
|
||||
{{- end }}
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func {{ camelCase "" $.Service "Err" $e.Error }}(mm ... *{{ $.Service }}ActionProps) *errors.Error {
|
||||
var p = &{{ $.Service }}ActionProps{}
|
||||
if len(mm) > 0 {
|
||||
p = mm[0]
|
||||
}
|
||||
|
||||
var e = errors.New(
|
||||
errors.KindInternal,
|
||||
|
||||
{{ if $e.Message }}p.Format({{ printf "%q" $e.Message }}, nil){{ else }}{{ printf "%q" $e.Error }}{{ end }},
|
||||
|
||||
errors.Meta("type", {{ printf "%q" $e.Error }}),
|
||||
errors.Meta("resource", {{ printf "%q" $.Resource }}),
|
||||
|
||||
{{ if $e.Documentation }}
|
||||
// link to documentation; formatting applies in case we need some special link formatting
|
||||
errors.Meta("documentation", p.Format({{ printf "%q" $e.Documentation }}, nil)),
|
||||
{{ end -}}
|
||||
|
||||
{{ if $e.Details }}
|
||||
// details, used in detailed eror reporting
|
||||
errors.Meta("details", p.Format({{ printf "%q" $e.Details }}, nil)),
|
||||
{{ end -}}
|
||||
|
||||
|
||||
{{- if $e.Log }}
|
||||
// action log entry; no formatting, it will be applied inside recordAction fn.
|
||||
errors.Meta({{ $.Service }}LogMetaKey{}, {{ printf "%q" $e.Log }}),
|
||||
{{ end -}}
|
||||
errors.Meta({{ $.Service }}PropsMetaKey{}, p),
|
||||
|
||||
// translation namespace & key
|
||||
errors.Meta(locale.ErrorMetaNamespace{}, {{ printf "%q" $.Component }}),
|
||||
errors.Meta(locale.ErrorMetaKey{}, "{{ kebabCase $.Service }}.errors.{{ $e.Error }}"),
|
||||
|
||||
errors.StackSkip(1),
|
||||
)
|
||||
|
||||
if len(mm) > 0 {
|
||||
}
|
||||
|
||||
{{ if $e.MaskedWith }}
|
||||
// Wrap with safe error
|
||||
e = {{ camelCase "" $.Service "Err" $e.MaskedWith }}().Wrap(e)
|
||||
{{ end }}
|
||||
|
||||
return e
|
||||
}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
// *********************************************************************************************************************
|
||||
// *********************************************************************************************************************
|
||||
|
||||
// recordAction is a service helper function wraps function that can return error
|
||||
//
|
||||
// It will wrap unrecognized/internal errors with generic errors.
|
||||
//
|
||||
// This function is auto-generated.
|
||||
//
|
||||
func (svc {{ $.Service }}) recordAction(ctx context.Context, props *{{ $.Service }}ActionProps, actionFn func(... *{{ $.Service }}ActionProps) *{{ $.Service }}Action, err error) error {
|
||||
if svc.actionlog == nil || actionFn == nil {
|
||||
// action log disabled or no action fn passed, return error as-is
|
||||
return err
|
||||
} else if err == nil {
|
||||
// action completed w/o error, record it
|
||||
svc.actionlog.Record(ctx, actionFn(props).ToAction())
|
||||
return nil
|
||||
}
|
||||
|
||||
a := actionFn(props).ToAction()
|
||||
|
||||
|
||||
// Extracting error information and recording it as action
|
||||
a.Error = err.Error()
|
||||
|
||||
switch c := err.(type) {
|
||||
case *errors.Error:
|
||||
m := c.Meta()
|
||||
|
||||
a.Error = err.Error()
|
||||
a.Severity = actionlog.Severity(m.AsInt("severity"))
|
||||
a.Description = props.Format(m.AsString({{ $.Service }}LogMetaKey{}), err)
|
||||
|
||||
if p, has := m[{{ $.Service }}PropsMetaKey{}]; has {
|
||||
a.Meta = p.(*{{ $.Service }}ActionProps).Serialize()
|
||||
}
|
||||
|
||||
svc.actionlog.Record(ctx, a)
|
||||
default:
|
||||
svc.actionlog.Record(ctx, a)
|
||||
}
|
||||
|
||||
|
||||
// Original error is passed on
|
||||
return err
|
||||
}
|
||||
63
server/pkg/codegen/assets/afunc.gen.adoc.tpl
Normal file
63
server/pkg/codegen/assets/afunc.gen.adoc.tpl
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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:
|
||||
{{- range .Definitions }}
|
||||
// - {{ .Source }}
|
||||
{{- end }}
|
||||
|
||||
{{ range $d := .Definitions }}
|
||||
= `{{ $d.Name }}`
|
||||
|
||||
[cols="2m,4a,3a"]
|
||||
|===
|
||||
| Name | Description | I/O
|
||||
|
||||
{{- range $f := .Functions }}
|
||||
|
||||
{{- if eq $f.Kind "function" }}
|
||||
|
||||
| [#fnc-{{ toLower $d.Name }}-{{ toLower $f.Name }}]#<<fnc-{{ toLower $d.Name }}-{{ toLower $f.Name }},{{ if $f.Meta.Short }}{{ $f.Meta.Short }}{{ else }}{{ $f.Name }}{{ end }}>>#
|
||||
| {{ if $f.Meta.Description }}{{ $f.Meta.Description }}{{ end }}
|
||||
|
|
||||
{{- if gt (len $f.Params) 0}}
|
||||
.Parameters:
|
||||
{{- range $p := $f.Params}}
|
||||
* {{ if $p.Required }}#*# {{ end }}`{{ if $p.Meta }}
|
||||
{{- if $p.Meta.Label }}
|
||||
{{- $p.Meta.Label }}
|
||||
{{- else }}
|
||||
{{- $p.Name }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
{{- $p.Name }}
|
||||
{{- end }}`
|
||||
({{- range $pti, $pt := $p.Types }}
|
||||
`{{- $pt.WorkflowType }}`,
|
||||
{{- end }})
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if gt (len $f.Results) 0}}
|
||||
|
||||
.Results:
|
||||
{{- range $r := $f.Results}}
|
||||
* {{ if $r.Meta }}
|
||||
{{- if $r.Meta.Label }}
|
||||
{{- $r.Meta.Label }}
|
||||
{{- else }}
|
||||
{{- $r.Name }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
{{- $r.Name }}
|
||||
{{- end }} (`{{ $r.WorkflowType }}`)
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
|===
|
||||
{{- end }}
|
||||
269
server/pkg/codegen/assets/afunc.gen.go.tpl
Normal file
269
server/pkg/codegen/assets/afunc.gen.go.tpl
Normal file
@@ -0,0 +1,269 @@
|
||||
package {{ .Package }}
|
||||
|
||||
// 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:
|
||||
// {{ .Source }}
|
||||
|
||||
import (
|
||||
atypes "github.com/cortezaproject/corteza/server/automation/types"
|
||||
"context"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/pkg/wfexec"
|
||||
{{- range .Imports }}
|
||||
{{ normalizeImport . }}
|
||||
{{- end }}
|
||||
)
|
||||
|
||||
var _ wfexec.ExecResponse
|
||||
|
||||
type (
|
||||
{{ $.Name }}HandlerRegistry interface {
|
||||
AddFunctions(ff ...*atypes.Function)
|
||||
Type(ref string) expr.Type
|
||||
}
|
||||
)
|
||||
|
||||
func (h {{ $.Name }}Handler) register() {
|
||||
h.reg.AddFunctions(
|
||||
{{- range .Functions }}
|
||||
h.{{ export .Name }}(),
|
||||
{{- end }}
|
||||
)
|
||||
}
|
||||
|
||||
{{ range .Functions }}
|
||||
{{ $REF := unexport $.Prefix $.Name .Name }}
|
||||
{{ $ARGS := unexport $.Name .Name "Args" }}
|
||||
{{ $RESULTS := unexport $.Name .Name "Results" }}
|
||||
|
||||
|
||||
type (
|
||||
{{ $ARGS }} struct {
|
||||
{{ range .Params }}
|
||||
{{ $name := .Name }}
|
||||
{{ $isArray := .IsArray }}
|
||||
has{{ export .Name }} bool
|
||||
{{- if gt (len .Types) 1 }}
|
||||
{{ export .Name }} interface{}
|
||||
{{- range .Types }}
|
||||
{{ $name }}{{ export .Suffix }} {{ if $isArray }}[]{{ end }}{{ .GoType }}
|
||||
{{- end }}
|
||||
{{- else -}}
|
||||
{{ range .Types }}
|
||||
{{ export $name }}{{ export .Suffix }} {{ if $isArray }}[]{{ end }}{{ .GoType }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
{{ if .Results }}
|
||||
{{ unexport $.Name .Name }}Results struct {
|
||||
{{ range .Results }}
|
||||
{{ export .Name }} {{ if .IsArray }}[]{{ end }}{{ .GoType }}
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
{{ range .Params }}
|
||||
{{- if gt (len .Types) 1 }}
|
||||
{{ $name := .Name }}
|
||||
{{ $isArray := .IsArray }}
|
||||
func (a {{ $ARGS }}) {{ export "get" $name }}() (bool, {{ range .Types }}{{ if $isArray }}[]{{ end }}{{ .GoType }},{{ end }}) {
|
||||
return a.has{{ export $name }}{{ range .Types }}, a.{{ $name }}{{ export .Suffix }}{{ end }}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
// {{ export .Name }} function {{ .Meta.Short }}
|
||||
//
|
||||
// expects implementation of {{ .Name }} function:
|
||||
// func (h {{ $.Name }}Handler) {{ .Name }}(ctx context.Context, args *{{ $ARGS }}) ({{ if .Results }}results *{{ $RESULTS }}, {{ end }}err error) {
|
||||
// return
|
||||
// }
|
||||
func (h {{ $.Name }}Handler) {{ export .Name }}() *atypes.Function {
|
||||
return &atypes.Function{
|
||||
Ref: {{ printf "%q" ( $REF ) }},
|
||||
Kind: {{ printf "%q" .Kind }},
|
||||
Labels: {{ printf "%#v" .Labels }},
|
||||
{{- if .Meta }}
|
||||
Meta: &atypes.FunctionMeta{
|
||||
{{- if .Meta.Short }}
|
||||
Short: {{ printf "%q" .Meta.Short }},
|
||||
{{- end }}
|
||||
{{- if .Meta.Description }}
|
||||
Description: {{ printf "%q" .Meta.Description }},
|
||||
{{- end }}
|
||||
{{- if .Meta.Visual }}
|
||||
Visual: {{ printf "%#v" .Meta.Visual }},
|
||||
{{- end }}
|
||||
},
|
||||
{{- end }}
|
||||
|
||||
Parameters: []*atypes.Param{
|
||||
{{- range .Params }}
|
||||
{
|
||||
Name: {{ printf "%q" .Name }},
|
||||
Types: []string{ {{ range .Types }}{{ printf "%q" .WorkflowType }},{{ end }} },
|
||||
{{- if .Required }}Required: true,{{ end }}
|
||||
{{- if .IsArray }}IsArray: true,{{ end }}
|
||||
{{- if .Meta }}
|
||||
Meta: &atypes.ParamMeta{
|
||||
{{- if .Meta.Label }}
|
||||
Label: {{ printf "%#v" .Meta.Label }},
|
||||
{{- end }}
|
||||
{{- if .Meta.Description }}
|
||||
Description: {{ printf "%#v" .Meta.Description }},
|
||||
{{- end }}
|
||||
{{- if .Meta.Visual }}
|
||||
Visual: {{ printf "%#v" .Meta.Visual }},
|
||||
{{- end }}
|
||||
},
|
||||
{{ end }}
|
||||
},
|
||||
{{- end }}
|
||||
},
|
||||
|
||||
|
||||
{{ if .Results }}
|
||||
Results: []*atypes.Param{
|
||||
{{ range .Results }}
|
||||
{
|
||||
Name: {{ printf "%q" .Name }},
|
||||
Types: []string{ {{ printf "%q" .WorkflowType }} },
|
||||
{{ if .IsArray }}IsArray: true,{{ end -}}
|
||||
{{ if .Meta -}}
|
||||
Meta: &atypes.ParamMeta{
|
||||
{{- if .Meta.Label }}
|
||||
Label: {{ printf "%#v" .Meta.Label }},
|
||||
{{- end }}
|
||||
{{- if .Meta.Description }}
|
||||
Description: {{ printf "%#v" .Meta.Description }},
|
||||
{{- end }}
|
||||
{{- if .Meta.Visual }}
|
||||
Visual: {{ printf "%#v" .Meta.Visual }},
|
||||
{{- end }}
|
||||
},
|
||||
{{ end }}
|
||||
},
|
||||
{{ end }}
|
||||
},
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .Kind "iterator" }}
|
||||
Iterator: func(ctx context.Context, in *expr.Vars) (out wfexec.IteratorHandler, err error) {
|
||||
var (
|
||||
args = &{{ $ARGS }}{
|
||||
{{- range .Params }}
|
||||
has{{ export .Name }}: in.Has({{ printf "%q" .Name }}),
|
||||
{{- end }}
|
||||
}
|
||||
)
|
||||
|
||||
{{ template "params" .Params }}
|
||||
|
||||
return h.{{ .Name }}(ctx, args)
|
||||
},
|
||||
{{ else }}
|
||||
Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) {
|
||||
var (
|
||||
args = &{{ $ARGS }}{
|
||||
{{- range .Params }}
|
||||
has{{ export .Name }}: in.Has({{ printf "%q" .Name }}),
|
||||
{{- end }}
|
||||
}
|
||||
)
|
||||
|
||||
{{ template "params" .Params }}
|
||||
|
||||
{{ if .Results }}
|
||||
var results *{{ $RESULTS }}
|
||||
if results, err = h.{{ .Name }}(ctx, args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out = &expr.Vars{}
|
||||
|
||||
{{- range .Results }}
|
||||
{{ if .IsArray }}
|
||||
|
||||
{
|
||||
// converting results.{{ export .Name }} ({{ .GoType }}) to Array (of {{ .WorkflowType }})
|
||||
var (
|
||||
tval expr.TypedValue
|
||||
tarr = make([]expr.TypedValue, len(results.{{ export .Name }}))
|
||||
)
|
||||
|
||||
for i := range results.{{ export .Name }} {
|
||||
if tarr[i], err = h.reg.Type({{ printf "%q" .WorkflowType }}).Cast(results.{{ export .Name }}[i]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if tval, err = expr.NewArray(tarr); err != nil {
|
||||
return
|
||||
} else if err = expr.Assign(out, {{ printf "%q" .Name }}, tval); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{{ else }}
|
||||
|
||||
{
|
||||
// converting results.{{ export .Name }} ({{ .GoType }}) to {{ .WorkflowType }}
|
||||
var (
|
||||
tval expr.TypedValue
|
||||
)
|
||||
|
||||
if tval, err = h.reg.Type({{ printf "%q" .WorkflowType }}).Cast(results.{{ export .Name }}); err != nil {
|
||||
return
|
||||
} else if err = expr.Assign(out, {{ printf "%q" .Name }}, tval); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
return
|
||||
{{- else }}
|
||||
return out, h.{{ .Name }}(ctx, args)
|
||||
{{- end }}
|
||||
},
|
||||
{{ end }}
|
||||
}
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
{{ define "params" }}
|
||||
if err = in.Decode(args); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
{{ range . }}
|
||||
{{ $name := .Name }}
|
||||
{{ $isArray := .IsArray }}
|
||||
{{ if gt (len .Types) 1 }}
|
||||
// Converting {{ export .Name }} argument
|
||||
if args.has{{ export .Name }} {
|
||||
aux := expr.Must(expr.Select(in, {{ printf "%q" .Name }}))
|
||||
switch aux.Type() {
|
||||
{{- range .Types }}
|
||||
case h.reg.Type({{ printf "%q" .WorkflowType }}).Type():
|
||||
args.{{ $name }}{{ export .Suffix }} = aux.Get().({{ if $isArray }}[]{{ end }}{{ .GoType }})
|
||||
{{- end -}}
|
||||
}
|
||||
}
|
||||
|
||||
{{- end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
55
server/pkg/codegen/assets/events.gen.adoc.tpl
Normal file
55
server/pkg/codegen/assets/events.gen.adoc.tpl
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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:
|
||||
{{- range .Definitions }}
|
||||
// - {{ .Source }}
|
||||
{{- end }}
|
||||
|
||||
= Resources and events
|
||||
{{- range .Definitions }}
|
||||
{{- range .Resources }}
|
||||
|
||||
== {{ .ResourceString }}
|
||||
|
||||
=== Events
|
||||
|
||||
{{- if .BeforeAfter }}
|
||||
.Before/after events:
|
||||
{{- range $ba := .BeforeAfter }}
|
||||
* `before('{{ $ba }}')`
|
||||
{{- end }}
|
||||
{{- range $ba := .BeforeAfter }}
|
||||
* `after('{{ $ba }}')`
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{ if .On -}}
|
||||
.On events:
|
||||
{{- range $on := .On }}
|
||||
* `on('{{ $on }}')`
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
=== Exec arguments
|
||||
|
||||
.Argument properties:
|
||||
[%header, cols=3*]
|
||||
|===
|
||||
|Name|Type|Mutable
|
||||
{{- range $p := .Properties }}
|
||||
| `{{ $p.Name }}`
|
||||
| `{{ $p.Type }}`
|
||||
{{- if $p.Immutable }}
|
||||
| no
|
||||
{{ else }}
|
||||
| yes
|
||||
{{ end -}}
|
||||
|
||||
{{ end -}}
|
||||
|===
|
||||
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
240
server/pkg/codegen/assets/events.gen.go.tpl
Normal file
240
server/pkg/codegen/assets/events.gen.go.tpl
Normal file
@@ -0,0 +1,240 @@
|
||||
package {{ .Package }}
|
||||
|
||||
// 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:
|
||||
// {{ .Source }}
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
{{- range .Imports }}
|
||||
{{ normalizeImport . }}
|
||||
{{- end }}
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
)
|
||||
|
||||
// dummy placing to simplify import generation logic
|
||||
var _ = json.NewEncoder
|
||||
|
||||
type (
|
||||
{{ range $r := $.Resources }}
|
||||
// {{ camelCase $r.ResourceIdent "base" }}
|
||||
//
|
||||
// This type is auto-generated.
|
||||
{{ camelCase $r.ResourceIdent "base" }} struct {
|
||||
immutable bool
|
||||
{{- range $r.Properties }}
|
||||
{{ .Name }} {{ .Type }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
{{ range $event := $r.Events }}
|
||||
// {{ camelCase $r.ResourceIdent $event }}
|
||||
//
|
||||
// This type is auto-generated.
|
||||
{{ camelCase $r.ResourceIdent $event }} struct {
|
||||
*{{ camelCase $r.ResourceIdent "base" }}
|
||||
}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
)
|
||||
|
||||
|
||||
{{ range $r := $.Resources }}
|
||||
|
||||
// ResourceType returns "{{ $r.ResourceString }}"
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ({{ camelCase .ResourceIdent "base" }}) ResourceType() string {
|
||||
return "{{ .ResourceString }}"
|
||||
}
|
||||
|
||||
{{ range $event := $r.Events }}
|
||||
// EventType on {{ camelCase $r.ResourceIdent $event }} returns "{{ $event }}"
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func ({{ camelCase $r.ResourceIdent $event }}) EventType() string {
|
||||
return "{{ $event }}"
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
{{ range $event := $r.Events }}
|
||||
// {{ camelCase "" $r.ResourceIdent $event }} creates {{ $event }} for {{ $r.ResourceString }} resource
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func {{ camelCase "" $r.ResourceIdent $event }}(
|
||||
{{- range $r.Properties }}
|
||||
{{- if not .Internal }}
|
||||
{{ camelCase "arg" .Name }} {{ .Type }},
|
||||
{{- end -}}
|
||||
{{- end}}
|
||||
) *{{ camelCase $r.ResourceIdent $event }} {
|
||||
return &{{ camelCase $r.ResourceIdent $event }}{
|
||||
{{ camelCase $r.ResourceIdent "base" }}: &{{ camelCase $r.ResourceIdent "base" }}{
|
||||
immutable: false,
|
||||
{{- range $r.Properties }}
|
||||
{{- if not .Internal }}
|
||||
{{ .Name }}: {{ camelCase "arg" .Name }},
|
||||
{{- end -}}
|
||||
{{- end}}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// {{ camelCase "" $r.ResourceIdent $event "Immutable" }} creates {{ $event }} for {{ $r.ResourceString }} resource
|
||||
//
|
||||
// None of the arguments will be mutable!
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func {{ camelCase "" $r.ResourceIdent $event "Immutable" }}(
|
||||
{{- range $r.Properties }}
|
||||
{{- if not .Internal }}
|
||||
{{ camelCase "arg" .Name }} {{ .Type }},
|
||||
{{- end -}}
|
||||
{{- end}}
|
||||
) *{{ camelCase $r.ResourceIdent $event }} {
|
||||
return &{{ camelCase $r.ResourceIdent $event }}{
|
||||
{{ camelCase $r.ResourceIdent "base" }}: &{{ camelCase $r.ResourceIdent "base" }}{
|
||||
immutable: true,
|
||||
{{- range $r.Properties }}
|
||||
{{- if not .Internal }}
|
||||
{{ .Name }}: {{ camelCase "arg" .Name }},
|
||||
{{- end -}}
|
||||
{{- end}}
|
||||
},
|
||||
}
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
|
||||
|
||||
{{ range $r.Properties }}
|
||||
{{ if not .Immutable }}
|
||||
// {{ camelCase "Set" .Name }} sets new {{ .Name }} value
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (res *{{ camelCase $r.ResourceIdent "base" }}) {{ camelCase "Set" .Name }}({{ camelCase "arg" .Name }} {{ .Type }}) {
|
||||
res.{{ .Name }} = {{ camelCase "arg" .Name }}
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
// {{ camelCase "" .Name }} returns {{ .Name }}
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (res {{ camelCase $r.ResourceIdent "base" }}) {{ camelCase "" .Name }}() {{ .Type }} {
|
||||
return res.{{ .Name }}
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
|
||||
// Encode internal data to be passed as event params & arguments to triggered Corredor script
|
||||
func (res {{ camelCase .ResourceIdent "base" }}) Encode() (args map[string][]byte, err error) {
|
||||
{{- if $r.Properties }}
|
||||
args = make(map[string][]byte)
|
||||
|
||||
{{ range $prop := $r.Properties }}
|
||||
if args["{{ $prop.Name }}"], err = json.Marshal(res.{{ $prop.Name }}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
// Handle argument encoding
|
||||
{{ end -}}
|
||||
return
|
||||
}
|
||||
|
||||
// Encode internal data to be passed as event params & arguments to workflow
|
||||
func (res {{ camelCase .ResourceIdent "base" }}) EncodeVars() (out *expr.Vars, err error) {
|
||||
{{- if $r.Properties }}
|
||||
out = &expr.Vars{}
|
||||
var v expr.TypedValue
|
||||
|
||||
{{ range $r.Properties }}
|
||||
{{- if .ExprType }}
|
||||
if v, err = automation.{{ export "new" .ExprType }}(res.{{ .Name }}); err == nil {
|
||||
err = out.Set({{ printf "%q" .Name }},v)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
{{- else }}
|
||||
// Could not found expression-type counterpart for {{ .Type }}
|
||||
{{- end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
_ = v
|
||||
return
|
||||
}
|
||||
|
||||
// Decode return values from Corredor script into struct props
|
||||
func (res *{{ camelCase .ResourceIdent "base" }}) Decode(results map[string][]byte)( err error) {
|
||||
if res.immutable {
|
||||
// Respect immutability
|
||||
return
|
||||
}
|
||||
|
||||
{{- if $r.Result }}
|
||||
if res.{{ $r.Result }} != nil {
|
||||
if r, ok := results["result"]; ok && len(results) == 1 {
|
||||
if err = json.Unmarshal(r, res.{{ $r.Result }}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
{{ end -}}
|
||||
|
||||
{{- range $prop := $r.Properties }}
|
||||
{{- if not $prop.Immutable }}
|
||||
if res.{{ $prop.Name }} != nil {
|
||||
if r, ok := results["{{ $prop.Name }}"]; ok {
|
||||
if err = json.Unmarshal(r, res.{{ $prop.Name }}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
{{ else }}
|
||||
// Do not decode {{ $prop.Name }}; marked as immutable
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (res *{{ camelCase .ResourceIdent "base" }}) DecodeVars(vars *expr.Vars) (err error) {
|
||||
if res.immutable {
|
||||
// Respect immutability
|
||||
return
|
||||
}
|
||||
|
||||
{{- range $r.Properties }}
|
||||
{{- if .Immutable }}
|
||||
// {{ .Name }} marked as immutable
|
||||
{{- else }}
|
||||
{{- if .ExprType }}
|
||||
if res.{{ .Name }} != nil && vars.Has({{ printf "%q" .Name }}) {
|
||||
var aux *automation.{{ export .ExprType }}
|
||||
aux, err = automation.{{ export "new" .ExprType }}(expr.Must(vars.Select({{ printf "%q" .Name }})))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res.{{ .Name }} = aux.GetValue()
|
||||
}
|
||||
{{- else }}
|
||||
// Could not find expression-type counterpart for {{ .Type }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
{{ end }}
|
||||
|
||||
|
||||
|
||||
|
||||
19
server/pkg/codegen/assets/events.go.tpl
Normal file
19
server/pkg/codegen/assets/events.go.tpl
Normal file
@@ -0,0 +1,19 @@
|
||||
package {{ .Package }}
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/eventbus"
|
||||
)
|
||||
|
||||
var _=eventbus.ConstraintMaker
|
||||
|
||||
// Match returns false if given conditions do not match event & resource internals
|
||||
func (res {{ camelCase .ResourceIdent "base" }}) Match(c eventbus.ConstraintMatcher) bool {
|
||||
// By default we match no mather what kind of constraints we receive
|
||||
//
|
||||
// Function will be called multiple times - once for every trigger constraint
|
||||
// All should match (return true):
|
||||
// constraint#1 AND constraint#2 AND constraint#3 ...
|
||||
//
|
||||
// When there are multiple values, Match() can decide how to treat them (OR, AND...)
|
||||
return true
|
||||
}
|
||||
45
server/pkg/codegen/assets/events_rest_def.gen.go.tpl
Normal file
45
server/pkg/codegen/assets/events_rest_def.gen.go.tpl
Normal file
@@ -0,0 +1,45 @@
|
||||
package rest
|
||||
|
||||
// This file is auto-generated.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
|
||||
func getEventTypeDefinitions() []eventTypeDef {
|
||||
return []eventTypeDef{
|
||||
{{ range .Definitions }}
|
||||
{{ range $r := .Resources }}
|
||||
{{ template "eventTypeDefinitions" dict "res" $r "type" "on" "types" .On }}
|
||||
{{ template "eventTypeDefinitions" dict "res" $r "type" "before" "types" .BeforeAfter }}
|
||||
{{ template "eventTypeDefinitions" dict "res" $r "type" "after" "types" .BeforeAfter }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
}
|
||||
}
|
||||
|
||||
{{ define "eventTypeDefinitions" }}
|
||||
{{ range $ev := $.types }}
|
||||
{
|
||||
ResourceType: {{ printf "%q" $.res.ResourceString }},
|
||||
EventType: {{ printf "%q" (camelCase $.type $ev) }},
|
||||
Properties: []eventTypePropertyDef{
|
||||
{{ range $.res.Properties }}
|
||||
{{ if not .Internal }}
|
||||
{
|
||||
Name: {{ printf "%q" .Name }},
|
||||
Type: {{ printf "%q" .ExprType }},
|
||||
Immutable: {{ printf "%v" .Immutable }},
|
||||
},
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
},
|
||||
Constraints: []eventTypeConstraintDef{
|
||||
{{ range $.res.Constraints }}
|
||||
{
|
||||
Name: {{ printf "%q" .Name }},
|
||||
},
|
||||
{{ end }}
|
||||
},
|
||||
},
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
32
server/pkg/codegen/assets/expr_types.gen.adoc.tpl
Normal file
32
server/pkg/codegen/assets/expr_types.gen.adoc.tpl
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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:
|
||||
{{- range .Definitions }}
|
||||
// - {{ .Source }}
|
||||
{{- end }}
|
||||
|
||||
[cols="2m,3a"]
|
||||
|===
|
||||
| Type | Structure
|
||||
{{- range .Definitions }}
|
||||
{{- range $tName, $tDef := .Types }}
|
||||
{{- if gt (len $tDef.Struct) 0}}
|
||||
| [#objref-{{ toLower $tName }}]#<<objref-{{ toLower $tName }},{{ $tName }}>>#
|
||||
|
|
||||
{{- if $tDef.Struct }}
|
||||
[source]
|
||||
----
|
||||
{
|
||||
{{- range $s := .Struct }}
|
||||
{{ $s.Name }} ({{ $s.ExprType }})
|
||||
{{- end }}
|
||||
}
|
||||
----
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{ end }}
|
||||
|===
|
||||
220
server/pkg/codegen/assets/expr_types.gen.go.tpl
Normal file
220
server/pkg/codegen/assets/expr_types.gen.go.tpl
Normal file
@@ -0,0 +1,220 @@
|
||||
package {{ .Package }}
|
||||
|
||||
// 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:
|
||||
// {{ .Source }}
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
{{- range .Imports }}
|
||||
{{ normalizeImport . }}
|
||||
{{- end }}
|
||||
{{- if ne .Package "expr" }}
|
||||
. "github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
{{- end }}
|
||||
)
|
||||
|
||||
var _ = context.Background
|
||||
var _ = fmt.Errorf
|
||||
|
||||
{{ range $exprType, $def := .Types }}
|
||||
{{ if not $def.CustomType }}
|
||||
// {{ $exprType }} is an expression type, wrapper for {{ $def.As }} type
|
||||
type {{ $exprType }} struct{
|
||||
value {{ $def.As }}
|
||||
mux sync.RWMutex
|
||||
}
|
||||
|
||||
// New{{ $exprType }} creates new instance of {{ $exprType }} expression type
|
||||
func New{{ $exprType }}(val interface{}) (*{{ $exprType }}, error) {
|
||||
if c, err := {{ export "CastTo" $exprType }}(val); err != nil {
|
||||
return nil, fmt.Errorf("unable to create {{ $exprType }}: %w", err)
|
||||
} else {
|
||||
return &{{ $exprType }}{value: c}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get return underlying value on {{ $exprType }}
|
||||
func (t *{{ $exprType }}) Get() interface{} {
|
||||
t.mux.RLock()
|
||||
defer t.mux.RUnlock()
|
||||
return t.value
|
||||
}
|
||||
|
||||
// GetValue returns underlying value on {{ $exprType }}
|
||||
func (t *{{ $exprType }}) GetValue() {{ $def.As }} {
|
||||
t.mux.RLock()
|
||||
defer t.mux.RUnlock()
|
||||
return t.value
|
||||
}
|
||||
|
||||
// Type return type name
|
||||
func ({{ $exprType }}) Type() string { return "{{ $exprType }}" }
|
||||
|
||||
// Cast converts value to {{ $def.As }}
|
||||
func ({{ $exprType }}) Cast(val interface{}) (TypedValue, error) {
|
||||
return New{{ $exprType }}(val)
|
||||
}
|
||||
|
||||
// Assign new value to {{ $exprType }}
|
||||
//
|
||||
// value is first passed through {{ export "CastTo" $exprType }}
|
||||
func (t *{{ $exprType }}) Assign(val interface{}) (error) {
|
||||
if c, err := {{ export "CastTo" $exprType }}(val); err != nil {
|
||||
return err
|
||||
} else {
|
||||
t.value = c
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
{{ if $def.Comparable }}
|
||||
{{ if not $def.CustomComparator }}
|
||||
// Compare the two {{ $exprType }} values
|
||||
func (t {{ $exprType }}) Compare(to TypedValue) (int, error) {
|
||||
c, err := New{{ $exprType }}(to)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot compare %s and %s: %s", t.Type(), c.Type(), err.Error())
|
||||
}
|
||||
|
||||
switch {
|
||||
case t.value == c.value:
|
||||
return 0, nil
|
||||
case t.value < c.value:
|
||||
return -1, nil
|
||||
case t.value > c.value:
|
||||
return 1, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("cannot compare %s and %s: unknown state", t.Type(), c.Type())
|
||||
}
|
||||
}
|
||||
{{ else }}
|
||||
// Compare the two {{ $exprType }} values
|
||||
func (t {{ $exprType }}) Compare(to TypedValue) (int, error) {
|
||||
return compareTo{{ $exprType }}(t, to)
|
||||
}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if $def.Struct }}
|
||||
{{ if not $def.CustomFieldAssigner }}
|
||||
func (t *{{ $exprType }}) AssignFieldValue(key string, val TypedValue) error {
|
||||
t.mux.Lock()
|
||||
defer t.mux.Unlock()
|
||||
return {{ $def.AssignerFn }}({{ if not (hasPtr $def.As) }}&{{ end }}t.value, key, val)
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
{{ if not $def.CustomGValSelector }}
|
||||
// SelectGVal implements gval.Selector requirements
|
||||
//
|
||||
// It allows gval lib to access {{ $exprType }}'s underlying value ({{ $def.As }})
|
||||
// and it's fields
|
||||
//
|
||||
func (t *{{ $exprType }}) SelectGVal(ctx context.Context, k string) (interface{}, error) {
|
||||
t.mux.RLock()
|
||||
defer t.mux.RUnlock()
|
||||
return {{ unexport $exprType "GValSelector" }}(t.value, k)
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
{{ if not $def.CustomSelector }}
|
||||
// Select is field accessor for {{ $def.As }}
|
||||
//
|
||||
// Similar to SelectGVal but returns typed values
|
||||
func (t *{{ $exprType }}) Select(k string) (TypedValue, error) {
|
||||
t.mux.RLock()
|
||||
defer t.mux.RUnlock()
|
||||
return {{ unexport $exprType "TypedValueSelector" }}(t.value, k)
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
func (t *{{ $exprType }}) Has(k string) bool {
|
||||
t.mux.RLock()
|
||||
defer t.mux.RUnlock()
|
||||
switch k {
|
||||
{{- range $def.Struct }}
|
||||
{{- if .ExprType }}
|
||||
case {{ printf "%q" .Name }}{{ if .Alias }}, {{ printf "%q" .Alias }}{{ end }}:
|
||||
return true
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// {{ unexport $exprType "GValSelector" }} is field accessor for {{ $def.As }}
|
||||
func {{ unexport $exprType "GValSelector" }}(res {{ $def.As }}, k string) (interface{}, error) {
|
||||
{{- if hasPtr $def.As }}
|
||||
if res == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
{{- end }}
|
||||
switch k {
|
||||
{{- range $def.Struct }}
|
||||
{{- if .ExprType }}
|
||||
case {{ printf "%q" .Name }}{{ if .Alias }}, {{ printf "%q" .Alias }}{{ end }}:
|
||||
return res.{{ export .Name }}, nil
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown field '%s'", k)}
|
||||
|
||||
// {{ unexport $exprType "TypedValueSelector" }} is field accessor for {{ $def.As }}
|
||||
func {{ unexport $exprType "TypedValueSelector" }}(res {{ $def.As }}, k string) (TypedValue, error) {
|
||||
{{- if hasPtr $def.As }}
|
||||
if res == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
{{- end }}
|
||||
switch k {
|
||||
{{- range $def.Struct }}
|
||||
{{- if .ExprType }}
|
||||
case {{ printf "%q" .Name }}{{ if .Alias }}, {{ printf "%q" .Alias }}{{ end }}:
|
||||
return {{ export "New" .ExprType }}(res.{{ export .Name }})
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown field '%s'", k)
|
||||
}
|
||||
|
||||
{{ if $def.BuiltInAssignerFn }}
|
||||
// {{ $def.AssignerFn }} is field value setter for {{ $def.As }}
|
||||
//
|
||||
// Value types are passed by pointer, otherwise assigned values are lost
|
||||
func {{ $def.AssignerFn }}(res {{ if not (hasPtr $def.As) }}*{{ end }}{{ $def.As }}, k string, val interface{}) (error) {
|
||||
switch k {
|
||||
{{- range $def.Struct }}
|
||||
case {{ printf "%q" .Name }}{{ if .Alias }}, {{ printf "%q" .Alias }}{{ end }}:
|
||||
{{- if .Readonly }}
|
||||
return fmt.Errorf("field '%s' is read-only", k)
|
||||
{{- else }}
|
||||
aux, err := {{ export "CastTo" .ExprType }}(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res.{{ export .Name }} = aux
|
||||
return nil
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
return fmt.Errorf("unknown field '%s'", k)
|
||||
}
|
||||
{{ end }}
|
||||
{{ end }} {{/* if $def.Struct */}}
|
||||
{{ end }} {{/* if not $def.CustomType */}}
|
||||
{{ end }} {{/* types loop */}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user