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

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

View 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()
}

View 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 ")
}
}
})
}
}

View 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 ""
}

View 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
}

View 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 ")
}
}
})
}
}

View 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
View 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
View 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))
}
}

View 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) }

View File

@@ -0,0 +1,3 @@
package: actionlog
types:
Action: {}

View 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
View 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
View 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
View 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)
}

View 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)
}
})
}
}

View 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)
}

View 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())
}
}

View 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)
}

View 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)
}
}

View 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)
})
}

View 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())
})
}

View 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
}
}

View 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))
}

View 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)
}

View 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)
}

View 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
}

View 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
}
}

View 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{}
}

View 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
}
}

View 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)
}
}
}

View 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
}

View 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
}

View 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()
}

View 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(),
}
}

View 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)
}
})
}
}

View 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)
}
})
}
}

View 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)
}

View 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) {})
}

View 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)
}

View 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
}

View 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())
}
}

View 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)
})
}

View 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
}

View 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
}

View 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)
}

View File

@@ -0,0 +1,7 @@
package profiler
type (
PurgeFilter struct {
RouteID uint64
}
)

View File

@@ -0,0 +1,8 @@
package profiler
type (
Sort struct {
Hit string
Path string
}
)

View 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
}

View 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
View 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)
}

View 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
View 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
}

View 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)
})
}
}

View 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
}
}
)

View 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())
}
}

View 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
}

View 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)
)

View 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
}

View 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)
})
}
}

View 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)
}
)

View File

@@ -0,0 +1,5 @@
package types
type (
SecureStorager interface{}
)

View 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
View 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
View 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{}
}
}

View 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
}

View 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
}
}

View 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
}
)

View 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
}

View 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
View 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
View 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
View 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()
}

View 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
}

View 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())
})
}
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
View 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)
}

View 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
View 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
}

View 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
View 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
}

View 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)
}

View 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
View 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
View 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
View 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
}
}

View 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
View 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,
})
}

View 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
}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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
}

View 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 }}

View 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 }}
|===

View 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