Compare commits

..

35 Commits

Author SHA1 Message Date
xdrm-brackets 39978a6743
Merge pull request #4 from xdrm-io/test/coverage
improve test coverage to 80% for every package
2021-06-23 09:13:39 +02:00
Adrien Marquès ccacd72a36
feat: handler differentiates missing and invalid parameter 2021-06-23 09:12:33 +02:00
Adrien Marquès 3613581b1c
fix: reqdata checks for missing form input globally for json, multipart, url encoded 2021-06-23 09:12:04 +02:00
Adrien Marquès 90e62b7e72 test: handler top-level errors: service, params 2021-06-22 23:49:03 +02:00
Adrien Marquès 1cc24be254 feat: url encoded parameters (uri + form) are only considered a slice when multiple values are set
- if `?a=123`, "123" is the value that can be validated as string, int, etc
 - if `?a=123&a=456`, the slice []type{123,456} is the value that can be validated as slice of strings, ints, etc.
2021-06-22 23:44:21 +02:00
Adrien Marquès fcc8b39717
fix: ignore uri query for service pattern matching 2021-06-22 22:45:47 +02:00
Adrien Marquès 2b67655cfd
refactor: reduce cyclomatic complexity of service.validateInput()
- simplify matchPattern()
 - rename isMethodAvailable() to checkMethod()
 - rename isPatternValid() to checkPattern()
 - rename validateInput() to checkInput()
 - rename validateOutput() to checkOutput()
 - refactor per-type input param management in new method parseParam(); that returns the param type (added unexported enum) and the error
 - refactor collision detection from checkInput() and checkOutput() in new method nameConflicts()
2021-06-22 22:18:29 +02:00
Adrien Marquès 140fbb8b23
fix: gofmt: with -s argument 2021-06-22 21:16:25 +02:00
Adrien Marquès f4f49e6ae6
fix: lint: consistent receiver name 2021-06-22 21:15:25 +02:00
Adrien Marquès c048db76e6
fix: ineffectual assignments 2021-06-22 21:14:38 +02:00
Adrien Marquès ad86a3b46b
fix: mispells 2021-06-22 21:11:59 +02:00
Adrien Marquès ad178781ac
test: cover dynfunc signature to 100% 2021-06-21 22:46:04 +02:00
Adrien Marquès 178d9a8eee
refactor: export config errors 2021-06-21 21:50:57 +02:00
Adrien Marquès 19bcc2e8dc
test: cover api context 2021-06-21 21:46:03 +02:00
Adrien Marquès 8b92abd1c2 fix: remove debug/typo 2021-06-21 21:38:06 +02:00
Adrien Marquès 89e81617d5 test: cover response 2021-06-21 21:38:06 +02:00
Adrien Marquès cff4106bf5 refactor: unexport api.Response into aicra.response 2021-06-21 21:38:06 +02:00
Adrien Marquès f17622195a test: cover builtin types 2021-06-21 21:38:03 +02:00
Adrien Marquès 461c17299e test: cover builder 2021-06-21 21:35:14 +02:00
Adrien Marquès 8c122e9ddf feat: export dynfunc errors 2021-06-21 21:34:52 +02:00
xdrm-brackets b4a426adcc fix: cover api.Auth 2021-06-21 21:34:52 +02:00
xdrm-brackets 6182276856
Merge pull request #3 from xdrm-io/refactor/validators
refactor: semantic rename and simplify validators
2021-06-21 21:34:17 +02:00
Adrien Marquès de547576c9
refactor: semantic move 'builtin' into 'validator' 2021-06-21 21:30:33 +02:00
Adrien Marquès defa2c3645
refactor: rename semantics of datatype to validator.Type 2021-06-21 21:08:22 +02:00
xdrm-brackets 0ee814abbe
Merge pull request #1 from xdrm-io/migrate/github
Migrate repo to GitHub
2021-06-20 21:51:03 +02:00
Adrien Marquès 36991ea9ef
docs: add build status 2021-06-20 21:49:56 +02:00
Adrien Marquès b3ef7de624
migrate: drone CI to github actions 2021-06-20 21:47:17 +02:00
Adrien Marquès 822ef823e9
migrate: symbols import paths to github 2021-06-20 21:29:46 +02:00
Adrien Marquès 77a1f3b11d Merge pull request 'docs: fix logo asset url to branch 0.4.0' (#26) from fix/readme-asset-url into 0.4.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Reviewed-on: #26
2021-06-20 08:35:45 +00:00
Adrien Marquès a8d7905180
docs: fix logo asset url to branch 0.4.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-06-20 10:35:08 +02:00
Adrien Marquès cc25995659 Merge pull request 'refactor/idiomatic-handlers-middlewares' (#25) from refactor/idiomatic-handlers-middlewares into 0.4.0
continuous-integration/drone/push Build is passing Details
Reviewed-on: #25
2021-06-20 08:26:27 +00:00
Adrien Marquès fd1ced5a8b
fix: restore request denied on invalid auth after contextual middlwares
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-06-20 10:24:12 +02:00
Adrien Marquès 97941da901
docs: update README for context.Context and middlewares
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-06-20 02:16:24 +02:00
Adrien Marquès af63c4514b
refactor: idiomatic remove of api.Context for context.Context, custom middlewares for standard http middlewares
continuous-integration/drone/push Build is passing Details
- remove api.Context as using context.Context is more idiomatic
 - remove api.Adapter as it is redundant with func(http.Handler) http.Handler
 - remove authentication middlewares as they be achieved as normal middlewares but launched around the handler (after the service has been found and validated)
 - builder.With() adds an standard Middleware that runs before any aicra code
 - builder.WithContext() adds an http middleware that runs just before the service handler is called. The http.Request provided contains a context with useful values such as the required permissions (from the service configuration).
 - handlers take a context.Context variable as first argument instead of api.Context
2021-06-20 02:14:31 +02:00
Adrien Marquès 6a78351a2c
doc: update README for *api.Context handler argument 2021-06-20 00:56:25 +02:00
43 changed files with 3124 additions and 1744 deletions

View File

@ -1,11 +0,0 @@
---
kind: pipeline
type: docker
name: default
steps:
- name: test
image: golang:1.13
commands:
- go get ./...
- go test -v -race -cover -coverprofile ./coverage.out ./...

27
.github/workflows/go.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: Go
on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.16
- name: Build
run: go build -v ./...
- name: Test
run: go test -race -v ./... -cover

View File

@ -1,6 +1,6 @@
<p align="center">
<a href="https://git.xdrm.io/go/aicra">
<img src="https://git.xdrm.io/go/aicra/raw/branch/feature/improve-readme/readme.assets/logo.png" alt="aicra logo" width="200" height="200">
<a href="https://github.com/xdrm-io/aicra">
<img src="https://github.com/xdrm-io/aicra/raw/0.4.0/readme.assets/logo.png" alt="aicra logo" width="200" height="200">
</a>
</p>
@ -10,7 +10,11 @@
Fast, intuitive, and powerful configuration-driven engine for faster and easier <em>REST</em> development.
</p>
[![Go version](https://img.shields.io/badge/go_version-1.16-blue.svg)](https://golang.org/doc/go1.16) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Go Report Card](https://goreportcard.com/badge/git.xdrm.io/go/aicra)](https://goreportcard.com/report/git.xdrm.io/go/aicra) [![Go doc](https://godoc.org/git.xdrm.io/go/aicra?status.svg)](https://godoc.org/git.xdrm.io/go/aicra) [![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go version](https://img.shields.io/badge/go_version-1.16-blue.svg)](https://golang.org/doc/go1.16)
[![Go doc](https://pkg.go.dev/badge/github.com/xdrm-io/aicra)](https://pkg.go.dev/github.com/xdrm-io/aicra)
[![Go Report Card](https://goreportcard.com/badge/github.com/xdrm-io/aicra)](https://goreportcard.com/report/github.com/xdrm-io/aicra)
[![Build status](https://github.com/xdrm-io/aicra/actions/workflows/go.yml/badge.svg)](https://github.com/xdrm-io/aicra/actions/workflows/go.yml)
## Presentation
@ -43,11 +47,11 @@ To install the aicra package, you need to install Go and set your Go workspace f
1. you can use the below Go command to install aicra.
```bash
$ go get -u git.xdrm.io/go/aicra
$ go get -u github.com/xdrm-io/aicra
```
2. Import it in your code:
```go
import "git.xdrm.io/go/aicra"
import "github.com/xdrm-io/aicra"
```
## What's automated
@ -83,18 +87,18 @@ import (
"net/http"
"os"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/validator/builtin"
)
func main() {
builder := &aicra.Builder{}
// register data validators
builder.AddType(builtin.BoolDataType{})
builder.AddType(builtin.UintDataType{})
builder.AddType(builtin.StringDataType{})
// add custom type validators
builder.Validate(validator.BoolDataType{})
builder.Validate(validator.UintDataType{})
builder.Validate(validator.StringDataType{})
// load your configuration
config, err := os.Open("api.json")
@ -107,12 +111,17 @@ func main() {
log.Fatalf("invalid config: %s", err)
}
// add http middlewares (logger)
builder.With(func(next http.Handler) http.Handler{ /* ... */ })
// add contextual middlewares (authentication)
builder.WithContext(func(next http.Handler) http.Handler{ /* ... */ })
// bind handlers
err = builder.Bind(http.MethodGet, "/user/{id}", getUserById)
if err != nil {
log.Fatalf("cannog bind GET /user/{id}: %s", err)
}
// ...
// build your services
handler, err := builder.Build()
@ -261,7 +270,7 @@ type res struct{
Output2 bool
}
func myHandler(r req) (*res, api.Err) {
func myHandler(ctx context.Context, r req) (*res, api.Err) {
err := doSomething()
if err != nil {
return nil, api.ErrFailure

View File

@ -1,13 +0,0 @@
package api
import "net/http"
// Adapter to encapsulate incoming requests
type Adapter func(http.HandlerFunc) http.HandlerFunc
// AuthHandlerFunc is http.HandlerFunc with additional Authorization information
type AuthHandlerFunc func(Auth, http.ResponseWriter, *http.Request)
// AuthAdapter to encapsulate incoming request with access to api.Auth
// to manage permissions
type AuthAdapter func(AuthHandlerFunc) AuthHandlerFunc

View File

@ -21,7 +21,7 @@ type Auth struct {
// Granted returns whether the authorization is granted
// i.e. Auth.Active fulfills Auth.Required
func (a Auth) Granted() bool {
func (a *Auth) Granted() bool {
var nothingRequired = true
// first dimension: OR ; at least one is valid
@ -43,7 +43,7 @@ func (a Auth) Granted() bool {
}
// returns whether Auth.Active fulfills (contains) all @required roles
func (a Auth) fulfills(required []string) bool {
func (a *Auth) fulfills(required []string) bool {
for _, requiredRole := range required {
var found = false
for _, activeRole := range a.Active {

View File

@ -17,6 +17,12 @@ func TestCombination(t *testing.T) {
Active: []string{},
Granted: true,
},
{
Name: "empty requirements none given",
Required: [][]string{{}},
Active: []string{},
Granted: true,
},
{
Name: "no requirement 1 given",
Required: [][]string{},

View File

@ -4,15 +4,11 @@ import (
"context"
"net/http"
"git.xdrm.io/go/aicra/internal/ctx"
"github.com/xdrm-io/aicra/internal/ctx"
)
// Context is a simple wrapper around context.Context that adds helper methods
// to access additional information
type Context struct{ context.Context }
// Request current request
func (c Context) Request() *http.Request {
// GetRequest extracts the current request from a context.Context
func GetRequest(c context.Context) *http.Request {
var (
raw = c.Value(ctx.Request)
cast, ok = raw.(*http.Request)
@ -23,8 +19,8 @@ func (c Context) Request() *http.Request {
return cast
}
// ResponseWriter for this request
func (c Context) ResponseWriter() http.ResponseWriter {
// GetResponseWriter extracts the response writer from a context.Context
func GetResponseWriter(c context.Context) http.ResponseWriter {
var (
raw = c.Value(ctx.Response)
cast, ok = raw.(http.ResponseWriter)
@ -35,8 +31,8 @@ func (c Context) ResponseWriter() http.ResponseWriter {
return cast
}
// Auth associated with this request
func (c Context) Auth() *Auth {
// GetAuth returns the api.Auth associated with this request from a context.Context
func GetAuth(c context.Context) *Auth {
var (
raw = c.Value(ctx.Auth)
cast, ok = raw.(*Auth)

79
api/context_test.go Normal file
View File

@ -0,0 +1,79 @@
package api_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/ctx"
)
func TestContextGetRequest(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/random", nil)
if err != nil {
t.Fatalf("cannot create http request: %s", err)
}
// store in bare context
c := context.Background()
c = context.WithValue(c, ctx.Request, req)
// fetch from context
fetched := api.GetRequest(c)
if fetched != req {
t.Fatalf("fetched http request %v ; expected %v", fetched, req)
}
}
func TestContextGetNilRequest(t *testing.T) {
// fetch from bare context
fetched := api.GetRequest(context.Background())
if fetched != nil {
t.Fatalf("fetched http request %v from empty context; expected nil", fetched)
}
}
func TestContextGetResponseWriter(t *testing.T) {
res := httptest.NewRecorder()
// store in bare context
c := context.Background()
c = context.WithValue(c, ctx.Response, res)
// fetch from context
fetched := api.GetResponseWriter(c)
if fetched != res {
t.Fatalf("fetched http response writer %v ; expected %v", fetched, res)
}
}
func TestContextGetNilResponseWriter(t *testing.T) {
// fetch from bare context
fetched := api.GetResponseWriter(context.Background())
if fetched != nil {
t.Fatalf("fetched http response writer %v from empty context; expected nil", fetched)
}
}
func TestContextGetAuth(t *testing.T) {
auth := &api.Auth{}
// store in bare context
c := context.Background()
c = context.WithValue(c, ctx.Auth, auth)
// fetch from context
fetched := api.GetAuth(c)
if fetched != auth {
t.Fatalf("fetched api auth %v ; expected %v", fetched, auth)
}
}
func TestContextGetNilAuth(t *testing.T) {
// fetch from bare context
fetched := api.GetAuth(context.Background())
if fetched != nil {
t.Fatalf("fetched api auth %v from empty context; expected nil", fetched)
}
}

View File

@ -1,63 +0,0 @@
package api
import (
"encoding/json"
"net/http"
)
// ResponseData defines format for response parameters to return
type ResponseData map[string]interface{}
// Response represents an API response to be sent
type Response struct {
Data ResponseData
Status int
Headers http.Header
err Err
}
// EmptyResponse creates an empty response.
func EmptyResponse() *Response {
return &Response{
Status: http.StatusOK,
Data: make(ResponseData),
err: ErrFailure,
Headers: make(http.Header),
}
}
// WithError sets the error
func (res *Response) WithError(err Err) *Response {
res.err = err
return res
}
func (res *Response) Error() string {
return res.err.Error()
}
// SetData adds/overrides a new response field
func (res *Response) SetData(name string, value interface{}) {
res.Data[name] = value
}
// MarshalJSON implements the 'json.Marshaler' interface and is used
// to generate the JSON representation of the response
func (res *Response) MarshalJSON() ([]byte, error) {
fmt := make(map[string]interface{})
for k, v := range res.Data {
fmt[k] = v
}
fmt["error"] = res.err
return json.Marshal(fmt)
}
func (res *Response) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(res.err.Status)
encoded, err := json.Marshal(res)
if err != nil {
return err
}
w.Write(encoded)
return nil
}

View File

@ -5,18 +5,23 @@ import (
"io"
"net/http"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/dynfunc"
"github.com/xdrm-io/aicra/internal/config"
"github.com/xdrm-io/aicra/internal/dynfunc"
"github.com/xdrm-io/aicra/validator"
)
// Builder for an aicra server
type Builder struct {
// the server configuration defining available services
conf *config.Server
// user-defined handlers bound to services from the configuration
handlers []*apiHandler
adapters []api.Adapter
authAdapters []api.AuthAdapter
// http middlewares wrapping the entire http connection (e.g. logger)
middlewares []func(http.Handler) http.Handler
// custom middlewares only wrapping the service handler of a request
// they will benefit from the request's context that contains service-specific
// information (e.g. required permissions from the configuration)
ctxMiddlewares []func(http.Handler) http.Handler
}
// represents an api handler (method-pattern combination)
@ -26,41 +31,45 @@ type apiHandler struct {
dyn *dynfunc.Handler
}
// AddType adds an available datatype to the api definition
func (b *Builder) AddType(t datatype.T) error {
// Validate adds an available Type to validate in the api definition
func (b *Builder) Validate(t validator.Type) error {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
return errLateType
}
if b.conf.Types == nil {
b.conf.Types = make([]datatype.T, 0)
if b.conf.Validators == nil {
b.conf.Validators = make([]validator.Type, 0)
}
b.conf.Types = append(b.conf.Types, t)
b.conf.Validators = append(b.conf.Validators, t)
return nil
}
// With adds an http adapter (middleware)
func (b *Builder) With(adapter api.Adapter) {
if b.conf == nil {
b.conf = &config.Server{}
// With adds an http middleware on top of the http connection
//
// Authentication management can only be done with the WithContext() methods as
// the service associated with the request has not been found at this stage.
// This stage is perfect for logging or generic request management.
func (b *Builder) With(mw func(http.Handler) http.Handler) {
if b.middlewares == nil {
b.middlewares = make([]func(http.Handler) http.Handler, 0)
}
if b.adapters == nil {
b.adapters = make([]api.Adapter, 0)
}
b.adapters = append(b.adapters, adapter)
b.middlewares = append(b.middlewares, mw)
}
// WithAuth adds an http adapter with auth capabilities (middleware)
func (b *Builder) WithAuth(adapter api.AuthAdapter) {
if b.conf == nil {
b.conf = &config.Server{}
// WithContext adds an http middleware with the fully loaded context
//
// Logging or generic request management should be done with the With() method as
// it wraps the full http connection. Middlewares added through this method only
// wrap the user-defined service handler. The context.Context is filled with useful
// data that can be access with api.GetRequest(), api.GetResponseWriter(),
// api.GetAuth(), etc methods.
func (b *Builder) WithContext(mw func(http.Handler) http.Handler) {
if b.ctxMiddlewares == nil {
b.ctxMiddlewares = make([]func(http.Handler) http.Handler, 0)
}
if b.authAdapters == nil {
b.authAdapters = make([]api.AuthAdapter, 0)
}
b.authAdapters = append(b.authAdapters, adapter)
b.ctxMiddlewares = append(b.ctxMiddlewares, mw)
}
// Setup the builder with its api definition file
@ -70,14 +79,14 @@ func (b *Builder) Setup(r io.Reader) error {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
panic(errAlreadySetup)
return errAlreadySetup
}
return b.conf.Parse(r)
}
// Bind a dynamic handler to a REST service (method and pattern)
func (b *Builder) Bind(method, path string, fn interface{}) error {
if b.conf.Services == nil {
if b.conf == nil || b.conf.Services == nil {
return errNotSetup
}

View File

@ -1,40 +1,44 @@
package aicra
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/dynfunc"
"github.com/xdrm-io/aicra/validator"
)
func addBuiltinTypes(b *Builder) error {
if err := b.AddType(builtin.AnyDataType{}); err != nil {
if err := b.Validate(validator.AnyType{}); err != nil {
return err
}
if err := b.AddType(builtin.BoolDataType{}); err != nil {
if err := b.Validate(validator.BoolType{}); err != nil {
return err
}
if err := b.AddType(builtin.FloatDataType{}); err != nil {
if err := b.Validate(validator.FloatType{}); err != nil {
return err
}
if err := b.AddType(builtin.IntDataType{}); err != nil {
if err := b.Validate(validator.IntType{}); err != nil {
return err
}
if err := b.AddType(builtin.StringDataType{}); err != nil {
if err := b.Validate(validator.StringType{}); err != nil {
return err
}
if err := b.AddType(builtin.UintDataType{}); err != nil {
if err := b.Validate(validator.UintType{}); err != nil {
return err
}
return nil
}
func TestAddType(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.AddType(builtin.BoolDataType{})
err := builder.Validate(validator.BoolType{})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
@ -42,13 +46,186 @@ func TestAddType(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.AddType(builtin.FloatDataType{})
err = builder.Validate(validator.FloatType{})
if err != errLateType {
t.Fatalf("expected <%v> got <%v>", errLateType, err)
}
}
func TestSetupNoType(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader("[]"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
}
func TestSetupTwice(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader("[]"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// double Setup() must fail
err = builder.Setup(strings.NewReader("[]"))
if err != errAlreadySetup {
t.Fatalf("expected error %v, got %v", errAlreadySetup, err)
}
}
func TestBindBeforeSetup(t *testing.T) {
t.Parallel()
builder := &Builder{}
// binding before Setup() must fail
err := builder.Bind(http.MethodGet, "/path", func() {})
if err != errNotSetup {
t.Fatalf("expected error %v, got %v", errNotSetup, err)
}
}
func TestBindUnknownService(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader("[]"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Bind(http.MethodGet, "/path", func() {})
if !errors.Is(err, errUnknownService) {
t.Fatalf("expected error %v, got %v", errUnknownService, err)
}
}
func TestBindInvalidHandler(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader(`[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Bind(http.MethodGet, "/path", func() {})
if err == nil {
t.Fatalf("expected an error")
}
if !errors.Is(err, dynfunc.ErrMissingHandlerContextArgument) {
t.Fatalf("expected a dynfunc.Err got %v", err)
}
}
func TestBindGet(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader(`[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
},
{
"method": "POST",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
},
{
"method": "PUT",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
},
{
"method": "DELETE",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Get("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
err = builder.Post("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
err = builder.Put("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
err = builder.Delete("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
}
func TestUnhandledService(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader(`[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
},
{
"method": "POST",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Get("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
_, err = builder.Build()
if !errors.Is(err, errMissingHandler) {
t.Fatalf("expected a %v error, got %v", errMissingHandler, err)
}
}
func TestBind(t *testing.T) {
t.Parallel()
tcases := []struct {
Name string
Config string
@ -72,7 +249,7 @@ func TestBind(t *testing.T) {
Config: "[]",
HandlerMethod: "",
HandlerPath: "",
HandlerFn: func(*api.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HandlerFn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: nil,
},
@ -108,7 +285,7 @@ func TestBind(t *testing.T) {
]`,
HandlerMethod: http.MethodPost,
HandlerPath: "/path",
HandlerFn: func(*api.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HandlerFn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: errMissingHandler,
},
@ -126,7 +303,7 @@ func TestBind(t *testing.T) {
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/paths",
HandlerFn: func(*api.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HandlerFn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: errMissingHandler,
},
@ -144,7 +321,7 @@ func TestBind(t *testing.T) {
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(*api.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HandlerFn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
@ -164,7 +341,7 @@ func TestBind(t *testing.T) {
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(*api.Context, struct{ Name int }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HandlerFn: func(context.Context, struct{ Name int }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
@ -184,7 +361,7 @@ func TestBind(t *testing.T) {
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(*api.Context, struct{ Name uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HandlerFn: func(context.Context, struct{ Name uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
@ -204,7 +381,7 @@ func TestBind(t *testing.T) {
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(*api.Context, struct{ Name string }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HandlerFn: func(context.Context, struct{ Name string }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
@ -224,7 +401,7 @@ func TestBind(t *testing.T) {
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(*api.Context, struct{ Name bool }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HandlerFn: func(context.Context, struct{ Name bool }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},

View File

@ -1,26 +0,0 @@
package builtin
import (
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// AnyDataType is what its name tells
type AnyDataType struct{}
// Type returns the type of data
func (AnyDataType) Type() reflect.Type {
return reflect.TypeOf(interface{}(nil))
}
// Build returns the validator
func (AnyDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "any" {
return nil
}
return func(value interface{}) (interface{}, bool) {
return value, true
}
}

View File

@ -1,23 +0,0 @@
package datatype
import (
"reflect"
)
// Validator returns whether a given value fulfills the datatype
// and casts the value into a common go type.
//
// for example, if a validator checks for upper case strings,
// whether the value is a []byte, a string or a []rune, if the
// value matches the validator's checks, it will be cast it into
// a common go type, say, string.
type Validator func(value interface{}) (cast interface{}, valid bool)
// T represents a datatype. The Build function returns a Validator if
// it manages types with the name `typeDefinition` (from the configuration field "type"); else it or returns NIL if the type
// definition does not match this datatype; the registry is passed to allow recursive datatypes (e.g. slices, structs, etc)
// The datatype's validator (when input is valid) must return a cast's go type matching the `Type() reflect.Type`
type T interface {
Type() reflect.Type
Build(typeDefinition string, registry ...T) Validator
}

2
go.mod
View File

@ -1,3 +1,3 @@
module git.xdrm.io/go/aicra
module github.com/xdrm-io/aicra
go 1.16

View File

@ -2,14 +2,15 @@ package aicra
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/ctx"
"git.xdrm.io/go/aicra/internal/reqdata"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/config"
"github.com/xdrm-io/aicra/internal/ctx"
"github.com/xdrm-io/aicra/internal/reqdata"
)
// Handler wraps the builder to handle requests
@ -17,30 +18,35 @@ type Handler Builder
// ServeHTTP implements http.Handler and wraps it in middlewares (adapters)
func (s Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var h = http.HandlerFunc(s.resolve)
var h http.Handler = http.HandlerFunc(s.resolve)
for _, adapter := range s.adapters {
h = adapter(h)
for _, mw := range s.middlewares {
h = mw(h)
}
h(w, r)
h.ServeHTTP(w, r)
}
// ServeHTTP implements http.Handler and wraps it in middlewares (adapters)
func (s Handler) resolve(w http.ResponseWriter, r *http.Request) {
// 1. find a matching service from config
// match service from config
var service = s.conf.Find(r)
if service == nil {
handleError(api.ErrUnknownService, w, r)
newResponse().WithError(api.ErrUnknownService).ServeHTTP(w, r)
return
}
// 2. extract request data
// extract request data
var input, err = extractInput(service, *r)
if err != nil {
handleError(api.ErrMissingParam, w, r)
if errors.Is(err, reqdata.ErrInvalidType) {
newResponse().WithError(api.ErrInvalidParam).ServeHTTP(w, r)
} else {
newResponse().WithError(api.ErrMissingParam).ServeHTTP(w, r)
}
return
}
// 3. find a matching handler
// match handler
var handler *apiHandler
for _, h := range s.handlers {
if h.Method == service.Method && h.Path == service.Pattern {
@ -48,92 +54,69 @@ func (s Handler) resolve(w http.ResponseWriter, r *http.Request) {
}
}
// 4. fail on no matching handler
// no handler found
if handler == nil {
handleError(api.ErrUncallableService, w, r)
newResponse().WithError(api.ErrUncallableService).ServeHTTP(w, r)
return
}
// replace format '[a]' in scope where 'a' is an existing input's name
scope := make([][]string, len(service.Scope))
for a, list := range service.Scope {
scope[a] = make([]string, len(list))
for b, perm := range list {
scope[a][b] = perm
for name, value := range input.Data {
var (
token = fmt.Sprintf("[%s]", name)
replacement = ""
)
if value != nil {
replacement = fmt.Sprintf("[%v]", value)
}
scope[a][b] = strings.ReplaceAll(scope[a][b], token, replacement)
}
}
}
var auth = api.Auth{
Required: scope,
Active: []string{},
}
// 5. run auth-aware middlewares
var h = api.AuthHandlerFunc(func(a api.Auth, w http.ResponseWriter, r *http.Request) {
if !a.Granted() {
handleError(api.ErrPermission, w, r)
return
}
s.handle(input, handler, service, w, r)
})
for _, adapter := range s.authAdapters {
h = adapter(h)
}
h(auth, w, r)
}
func (s *Handler) handle(input *reqdata.T, handler *apiHandler, service *config.Service, w http.ResponseWriter, r *http.Request) {
// build context with builtin data
// add info into context
c := r.Context()
c = context.WithValue(c, ctx.Request, r)
c = context.WithValue(c, ctx.Response, w)
c = context.WithValue(c, ctx.Auth, w)
apictx := &api.Context{Context: c}
c = context.WithValue(c, ctx.Auth, buildAuth(service.Scope, input.Data))
// pass execution to the handler
var outData, outErr = handler.dyn.Handle(apictx, input.Data)
// create http handler
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// should not happen
auth := api.GetAuth(r.Context())
if auth == nil {
newResponse().WithError(api.ErrPermission).ServeHTTP(w, r)
return
}
// build response from returned arguments
var res = api.EmptyResponse().WithError(outErr)
// reject non granted requests
if !auth.Granted() {
newResponse().WithError(api.ErrPermission).ServeHTTP(w, r)
return
}
// execute the service handler
s.handle(r.Context(), input, handler, service, w, r)
})
// run contextual middlewares
for _, mw := range s.ctxMiddlewares {
h = mw(h)
}
// serve using the pre-filled context
h.ServeHTTP(w, r.WithContext(c))
}
// handle the service request with the associated handler func and respond using
// the handler func output
func (s *Handler) handle(c context.Context, input *reqdata.T, handler *apiHandler, service *config.Service, w http.ResponseWriter, r *http.Request) {
// pass execution to the handler function
var outData, outErr = handler.dyn.Handle(c, input.Data)
// build response from output arguments
var res = newResponse().WithError(outErr)
for key, value := range outData {
// find original name from 'rename' field
for name, param := range service.Output {
if param.Rename == key {
res.SetData(name, value)
res.WithValue(name, value)
}
}
}
// 7. apply headers
// write response and close request
w.Header().Set("Content-Type", "application/json; charset=utf-8")
for key, values := range res.Headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
res.ServeHTTP(w, r)
}
func handleError(err api.Err, w http.ResponseWriter, r *http.Request) {
var response = api.EmptyResponse().WithError(err)
response.ServeHTTP(w, r)
}
func extractInput(service *config.Service, req http.Request) (*reqdata.T, error) {
var dataset = reqdata.New(service)
@ -157,3 +140,35 @@ func extractInput(service *config.Service, req http.Request) (*reqdata.T, error)
return dataset, nil
}
// buildAuth builds the api.Auth struct from the service scope configuration
//
// it replaces format '[a]' in scope where 'a' is an existing input argument's
// name with its value
func buildAuth(scope [][]string, in map[string]interface{}) *api.Auth {
updated := make([][]string, len(scope))
// replace '[arg_name]' with the 'arg_name' value if it is a known variable
// name
for a, list := range scope {
updated[a] = make([]string, len(list))
for b, perm := range list {
updated[a][b] = perm
for name, value := range in {
var (
token = fmt.Sprintf("[%s]", name)
replacement = ""
)
if value != nil {
replacement = fmt.Sprintf("[%v]", value)
}
updated[a][b] = strings.ReplaceAll(updated[a][b], token, replacement)
}
}
}
return &api.Auth{
Required: updated,
Active: []string{},
}
}

View File

@ -3,42 +3,49 @@ package aicra_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/validator"
)
func addBuiltinTypes(b *aicra.Builder) error {
if err := b.AddType(builtin.AnyDataType{}); err != nil {
func printEscaped(raw string) string {
raw = strings.ReplaceAll(raw, "\n", "\\n")
raw = strings.ReplaceAll(raw, "\r", "\\r")
return raw
}
func addDefaultTypes(b *aicra.Builder) error {
if err := b.Validate(validator.AnyType{}); err != nil {
return err
}
if err := b.AddType(builtin.BoolDataType{}); err != nil {
if err := b.Validate(validator.BoolType{}); err != nil {
return err
}
if err := b.AddType(builtin.FloatDataType{}); err != nil {
if err := b.Validate(validator.FloatType{}); err != nil {
return err
}
if err := b.AddType(builtin.IntDataType{}); err != nil {
if err := b.Validate(validator.IntType{}); err != nil {
return err
}
if err := b.AddType(builtin.StringDataType{}); err != nil {
if err := b.Validate(validator.StringType{}); err != nil {
return err
}
if err := b.AddType(builtin.UintDataType{}); err != nil {
if err := b.Validate(validator.UintType{}); err != nil {
return err
}
return nil
}
func TestWith(t *testing.T) {
func TestHandler_With(t *testing.T) {
builder := &aicra.Builder{}
if err := addBuiltinTypes(builder); err != nil {
if err := addDefaultTypes(builder); err != nil {
t.Fatalf("unexpected error <%v>", err)
}
@ -48,15 +55,13 @@ func TestWith(t *testing.T) {
type ckey int
const key ckey = 0
middleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
newr := r
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// first time -> store 1
value := r.Context().Value(key)
if value == nil {
newr = r.WithContext(context.WithValue(r.Context(), key, int(1)))
next(w, newr)
r = r.WithContext(context.WithValue(r.Context(), key, int(1)))
next.ServeHTTP(w, r)
return
}
@ -66,9 +71,9 @@ func TestWith(t *testing.T) {
t.Fatalf("value is not an int")
}
cast++
newr = r.WithContext(context.WithValue(r.Context(), key, cast))
next(w, newr)
}
r = r.WithContext(context.WithValue(r.Context(), key, cast))
next.ServeHTTP(w, r)
})
}
// add middleware @n times
@ -82,7 +87,7 @@ func TestWith(t *testing.T) {
t.Fatalf("setup: unexpected error <%v>", err)
}
pathHandler := func(ctx *api.Context) (*struct{}, api.Err) {
pathHandler := func(ctx context.Context) (*struct{}, api.Err) {
// write value from middlewares into response
value := ctx.Value(key)
if value == nil {
@ -93,7 +98,7 @@ func TestWith(t *testing.T) {
t.Fatalf("cannot cast context data to int")
}
// write to response
ctx.ResponseWriter().Write([]byte(fmt.Sprintf("#%d#", cast)))
api.GetResponseWriter(ctx).Write([]byte(fmt.Sprintf("#%d#", cast)))
return nil, api.ErrSuccess
}
@ -122,7 +127,7 @@ func TestWith(t *testing.T) {
}
func TestWithAuth(t *testing.T) {
func TestHandler_WithAuth(t *testing.T) {
tt := []struct {
name string
@ -207,13 +212,18 @@ func TestWithAuth(t *testing.T) {
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
builder := &aicra.Builder{}
if err := addBuiltinTypes(builder); err != nil {
if err := addDefaultTypes(builder); err != nil {
t.Fatalf("unexpected error <%v>", err)
}
// tester middleware (last executed)
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
builder.WithContext(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a := api.GetAuth(r.Context())
if a == nil {
t.Fatalf("cannot access api.Auth form request context")
}
if a.Granted() == tc.granted {
return
}
@ -222,14 +232,20 @@ func TestWithAuth(t *testing.T) {
} else {
t.Fatalf("expected granted auth")
}
}
next.ServeHTTP(w, r)
})
})
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
a.Active = tc.permissions
next(a, w, r)
builder.WithContext(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a := api.GetAuth(r.Context())
if a == nil {
t.Fatalf("cannot access api.Auth form request context")
}
a.Active = tc.permissions
next.ServeHTTP(w, r)
})
})
err := builder.Setup(strings.NewReader(tc.manifest))
@ -237,7 +253,7 @@ func TestWithAuth(t *testing.T) {
t.Fatalf("setup: unexpected error <%v>", err)
}
pathHandler := func(ctx *api.Context) (*struct{}, api.Err) {
pathHandler := func(ctx context.Context) (*struct{}, api.Err) {
return nil, api.ErrNotImplemented
}
@ -264,7 +280,98 @@ func TestWithAuth(t *testing.T) {
}
func TestDynamicScope(t *testing.T) {
func TestHandler_PermissionError(t *testing.T) {
tt := []struct {
name string
manifest string
permissions []string
granted bool
}{
{
name: "permission fulfilled",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A"},
granted: true,
},
{
name: "missing permission",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{},
granted: false,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
builder := &aicra.Builder{}
if err := addDefaultTypes(builder); err != nil {
t.Fatalf("unexpected error <%v>", err)
}
// add active permissions
builder.WithContext(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a := api.GetAuth(r.Context())
if a == nil {
t.Fatalf("cannot access api.Auth form request context")
}
a.Active = tc.permissions
next.ServeHTTP(w, r)
})
})
err := builder.Setup(strings.NewReader(tc.manifest))
if err != nil {
t.Fatalf("setup: unexpected error <%v>", err)
}
pathHandler := func(ctx context.Context) (*struct{}, api.Err) {
return nil, api.ErrNotImplemented
}
if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil {
t.Fatalf("bind: unexpected error <%v>", err)
}
handler, err := builder.Build()
if err != nil {
t.Fatalf("build: unexpected error <%v>", err)
}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{})
// test request
handler.ServeHTTP(response, request)
if response.Body == nil {
t.Fatalf("response has no body")
}
type jsonResponse struct {
Err api.Err `json:"error"`
}
var res jsonResponse
err = json.Unmarshal(response.Body.Bytes(), &res)
if err != nil {
t.Fatalf("cannot unmarshal response: %s", err)
}
expectedError := api.ErrNotImplemented
if !tc.granted {
expectedError = api.ErrPermission
}
if res.Err.Code != expectedError.Code {
t.Fatalf("expected error code %d got %d", expectedError.Code, res.Err.Code)
}
})
}
}
func TestHandler_DynamicScope(t *testing.T) {
tt := []struct {
name string
manifest string
@ -290,7 +397,7 @@ func TestDynamicScope(t *testing.T) {
}
]`,
path: "/path/{id}",
handler: func(*api.Context, struct{ Input1 uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
handler: func(context.Context, struct{ Input1 uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
url: "/path/123",
body: ``,
permissions: []string{"user[123]"},
@ -311,7 +418,7 @@ func TestDynamicScope(t *testing.T) {
}
]`,
path: "/path/{id}",
handler: func(*api.Context, struct{ Input1 uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
handler: func(context.Context, struct{ Input1 uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
url: "/path/666",
body: ``,
permissions: []string{"user[123]"},
@ -332,7 +439,7 @@ func TestDynamicScope(t *testing.T) {
}
]`,
path: "/path/{id}",
handler: func(*api.Context, struct{ User uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
handler: func(context.Context, struct{ User uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
url: "/path/123",
body: ``,
permissions: []string{"prefix.user[123].suffix"},
@ -354,7 +461,7 @@ func TestDynamicScope(t *testing.T) {
}
]`,
path: "/prefix/{pid}/user/{uid}",
handler: func(*api.Context, struct {
handler: func(context.Context, struct {
Prefix uint
User uint
}) (*struct{}, api.Err) {
@ -381,7 +488,7 @@ func TestDynamicScope(t *testing.T) {
}
]`,
path: "/prefix/{pid}/user/{uid}",
handler: func(*api.Context, struct {
handler: func(context.Context, struct {
Prefix uint
User uint
}) (*struct{}, api.Err) {
@ -409,7 +516,7 @@ func TestDynamicScope(t *testing.T) {
}
]`,
path: "/prefix/{pid}/user/{uid}/suffix/{sid}",
handler: func(*api.Context, struct {
handler: func(context.Context, struct {
Prefix uint
User uint
Suffix uint
@ -438,7 +545,7 @@ func TestDynamicScope(t *testing.T) {
}
]`,
path: "/prefix/{pid}/user/{uid}/suffix/{sid}",
handler: func(*api.Context, struct {
handler: func(context.Context, struct {
Prefix uint
User uint
Suffix uint
@ -455,13 +562,17 @@ func TestDynamicScope(t *testing.T) {
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
builder := &aicra.Builder{}
if err := addBuiltinTypes(builder); err != nil {
if err := addDefaultTypes(builder); err != nil {
t.Fatalf("unexpected error <%v>", err)
}
// tester middleware (last executed)
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
builder.WithContext(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a := api.GetAuth(r.Context())
if a == nil {
t.Fatalf("cannot access api.Auth form request context")
}
if a.Granted() == tc.granted {
return
}
@ -470,15 +581,20 @@ func TestDynamicScope(t *testing.T) {
} else {
t.Fatalf("expected granted auth")
}
}
next.ServeHTTP(w, r)
})
})
// update permissions
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
a.Active = tc.permissions
next(a, w, r)
builder.WithContext(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a := api.GetAuth(r.Context())
if a == nil {
t.Fatalf("cannot access api.Auth form request context")
}
a.Active = tc.permissions
next.ServeHTTP(w, r)
})
})
err := builder.Setup(strings.NewReader(tc.manifest))
@ -509,3 +625,514 @@ func TestDynamicScope(t *testing.T) {
}
}
func TestHandler_ServiceErrors(t *testing.T) {
tt := []struct {
name string
manifest string
// handler
hmethod, huri string
hfn interface{}
// request
method, url string
contentType string
body string
permissions []string
err api.Err
}{
// service match
{
name: "unknown service method",
manifest: `[
{
"method": "GET",
"path": "/",
"info": "info",
"scope": [],
"in": {},
"out": {}
}
]`,
hmethod: http.MethodGet,
huri: "/",
hfn: func(context.Context) api.Err {
return api.ErrSuccess
},
method: http.MethodPost,
url: "/",
body: ``,
permissions: []string{},
err: api.ErrUnknownService,
},
{
name: "unknown service path",
manifest: `[
{
"method": "GET",
"path": "/",
"info": "info",
"scope": [],
"in": {},
"out": {}
}
]`,
hmethod: http.MethodGet,
huri: "/",
hfn: func(context.Context) api.Err {
return api.ErrSuccess
},
method: http.MethodGet,
url: "/invalid",
body: ``,
permissions: []string{},
err: api.ErrUnknownService,
},
{
name: "valid empty service",
manifest: `[
{
"method": "GET",
"path": "/",
"info": "info",
"scope": [],
"in": {},
"out": {}
}
]`,
hmethod: http.MethodGet,
huri: "/",
hfn: func(context.Context) api.Err {
return api.ErrSuccess
},
method: http.MethodGet,
url: "/",
body: ``,
permissions: []string{},
err: api.ErrSuccess,
},
// invalid uri param -> unknown service
{
name: "invalid uri param",
manifest: `[
{
"method": "GET",
"path": "/a/{id}/b",
"info": "info",
"scope": [],
"in": {
"{id}": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodGet,
huri: "/a/{id}/b",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
method: http.MethodGet,
url: "/a/invalid/b",
body: ``,
permissions: []string{},
err: api.ErrUnknownService,
},
// query param
{
name: "missing query param",
manifest: `[
{
"method": "GET",
"path": "/",
"info": "info",
"scope": [],
"in": {
"GET@id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodGet,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
method: http.MethodGet,
url: "/",
body: ``,
permissions: []string{},
err: api.ErrMissingParam,
},
{
name: "invalid query param",
manifest: `[
{
"method": "GET",
"path": "/a",
"info": "info",
"scope": [],
"in": {
"GET@id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodGet,
huri: "/a",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
method: http.MethodGet,
url: "/a?id=abc",
body: ``,
permissions: []string{},
err: api.ErrInvalidParam,
},
{
name: "invalid query multi param",
manifest: `[
{
"method": "GET",
"path": "/a",
"info": "info",
"scope": [],
"in": {
"GET@id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodGet,
huri: "/a",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
method: http.MethodGet,
url: "/a?id=123&id=456",
body: ``,
permissions: []string{},
err: api.ErrInvalidParam,
},
{
name: "valid query param",
manifest: `[
{
"method": "GET",
"path": "/a",
"info": "info",
"scope": [],
"in": {
"GET@id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodGet,
huri: "/a",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
method: http.MethodGet,
url: "/a?id=123",
body: ``,
permissions: []string{},
err: api.ErrSuccess,
},
// json param
{
name: "missing json param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "application/json",
method: http.MethodPost,
url: "/",
body: ``,
permissions: []string{},
err: api.ErrMissingParam,
},
{
name: "invalid json param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "application/json",
method: http.MethodPost,
url: "/",
body: `{ "id": "invalid type" }`,
permissions: []string{},
err: api.ErrInvalidParam,
},
{
name: "valid json param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "application/json",
method: http.MethodPost,
url: "/",
body: `{ "id": 123 }`,
permissions: []string{},
err: api.ErrSuccess,
},
// urlencoded param
{
name: "missing urlencoded param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "application/x-www-form-urlencoded",
method: http.MethodPost,
url: "/",
body: ``,
permissions: []string{},
err: api.ErrMissingParam,
},
{
name: "invalid urlencoded param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "application/x-www-form-urlencoded",
method: http.MethodPost,
url: "/",
body: `id=abc`,
permissions: []string{},
err: api.ErrInvalidParam,
},
{
name: "valid urlencoded param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "application/x-www-form-urlencoded",
method: http.MethodPost,
url: "/",
body: `id=123`,
permissions: []string{},
err: api.ErrSuccess,
},
// formdata param
{
name: "missing multipart param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "multipart/form-data; boundary=xxx",
method: http.MethodPost,
url: "/",
body: ``,
permissions: []string{},
err: api.ErrMissingParam,
},
{
name: "invalid multipart param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "multipart/form-data; boundary=xxx",
method: http.MethodPost,
url: "/",
body: `--xxx
Content-Disposition: form-data; name="id"
abc
--xxx--`,
permissions: []string{},
err: api.ErrInvalidParam,
},
{
name: "valid multipart param",
manifest: `[
{
"method": "POST",
"path": "/",
"info": "info",
"scope": [],
"in": {
"id": { "info": "info", "type": "int", "name": "ID" }
},
"out": {}
}
]`,
hmethod: http.MethodPost,
huri: "/",
hfn: func(context.Context, struct{ ID int }) api.Err {
return api.ErrSuccess
},
contentType: "multipart/form-data; boundary=xxx",
method: http.MethodPost,
url: "/",
body: `--xxx
Content-Disposition: form-data; name="id"
123
--xxx--`,
permissions: []string{},
err: api.ErrSuccess,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
builder := &aicra.Builder{}
if err := addDefaultTypes(builder); err != nil {
t.Fatalf("unexpected error <%v>", err)
}
err := builder.Setup(strings.NewReader(tc.manifest))
if err != nil {
t.Fatalf("setup: unexpected error <%v>", err)
}
if err := builder.Bind(tc.hmethod, tc.huri, tc.hfn); err != nil {
t.Fatalf("bind: unexpected error <%v>", err)
}
handler, err := builder.Build()
if err != nil {
t.Fatalf("build: unexpected error <%v>", err)
}
var (
response = httptest.NewRecorder()
body = strings.NewReader(tc.body)
request = httptest.NewRequest(tc.method, tc.url, body)
)
if len(tc.contentType) > 0 {
request.Header.Add("Content-Type", tc.contentType)
}
// test request
handler.ServeHTTP(response, request)
if response.Body == nil {
t.Fatalf("response has no body")
}
jsonErr, err := json.Marshal(tc.err)
if err != nil {
t.Fatalf("cannot marshal expected error: %v", err)
}
jsonExpected := fmt.Sprintf(`{"error":%s}`, jsonErr)
if response.Body.String() != jsonExpected {
t.Fatalf("invalid response:\n- actual: %s\n- expect: %s\n", printEscaped(response.Body.String()), printEscaped(jsonExpected))
}
})
}
}

View File

@ -7,48 +7,48 @@ import (
"net/http"
"strings"
"git.xdrm.io/go/aicra/datatype"
"github.com/xdrm-io/aicra/validator"
)
// Server definition
type Server struct {
Types []datatype.T
Validators []validator.Type
Services []*Service
}
// Parse a configuration into a server. Server.Types must be set beforehand to
// make datatypes available when checking and formatting the read configuration.
func (srv *Server) Parse(r io.Reader) error {
err := json.NewDecoder(r).Decode(&srv.Services)
func (s *Server) Parse(r io.Reader) error {
err := json.NewDecoder(r).Decode(&s.Services)
if err != nil {
return fmt.Errorf("%s: %w", errRead, err)
return fmt.Errorf("%s: %w", ErrRead, err)
}
err = srv.validate()
err = s.validate()
if err != nil {
return fmt.Errorf("%s: %w", errFormat, err)
return fmt.Errorf("%s: %w", ErrFormat, err)
}
return nil
}
// validate implements the validator interface
func (server Server) validate(datatypes ...datatype.T) error {
for _, service := range server.Services {
err := service.validate(server.Types...)
func (s Server) validate(datatypes ...validator.Type) error {
for _, service := range s.Services {
err := service.validate(s.Validators...)
if err != nil {
return fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, err)
}
}
if err := server.collide(); err != nil {
return fmt.Errorf("%s: %w", errFormat, err)
if err := s.collide(); err != nil {
return fmt.Errorf("%s: %w", ErrFormat, err)
}
return nil
}
// Find a service matching an incoming HTTP request
func (server Server) Find(r *http.Request) *Service {
for _, service := range server.Services {
func (s Server) Find(r *http.Request) *Service {
for _, service := range s.Services {
if matches := service.Match(r); matches {
return service
}
@ -62,14 +62,14 @@ func (server Server) Find(r *http.Request) *Service {
// - example 1: `/user/{id}` and `/user/articles` will not collide as {id} is an int and "articles" is not
// - example 2: `/user/{name}` and `/user/articles` will collide as {name} is a string so as "articles"
// - example 3: `/user/{name}` and `/user/{id}` will collide as {name} and {id} cannot be checked against their potential values
func (server *Server) collide() error {
length := len(server.Services)
func (s *Server) collide() error {
length := len(s.Services)
// for each service combination
for a := 0; a < length; a++ {
for b := a + 1; b < length; b++ {
aService := server.Services[a]
bService := server.Services[b]
aService := s.Services[a]
bService := s.Services[b]
if aService.Method != bService.Method {
continue
@ -105,14 +105,14 @@ func checkURICollision(uriA, uriB []string, inputA, inputB map[string]*Parameter
// both captures -> as we cannot check, consider a collision
if aIsCapture && bIsCapture {
errors = append(errors, fmt.Errorf("%w (path %s and %s)", errPatternCollision, aPart, bPart))
errors = append(errors, fmt.Errorf("%w (path %s and %s)", ErrPatternCollision, aPart, bPart))
continue
}
// no capture -> check strict equality
if !aIsCapture && !bIsCapture {
if aPart == bPart {
errors = append(errors, fmt.Errorf("%w (same path '%s')", errPatternCollision, aPart))
errors = append(errors, fmt.Errorf("%w (same path '%s')", ErrPatternCollision, aPart))
continue
}
}
@ -123,13 +123,13 @@ func checkURICollision(uriA, uriB []string, inputA, inputB map[string]*Parameter
// fail if no type or no validator
if !exists || input.Validator == nil {
errors = append(errors, fmt.Errorf("%w (invalid type for %s)", errPatternCollision, aPart))
errors = append(errors, fmt.Errorf("%w (invalid type for %s)", ErrPatternCollision, aPart))
continue
}
// fail if not valid
if _, valid := input.Validator(bPart); valid {
errors = append(errors, fmt.Errorf("%w (%s captures '%s')", errPatternCollision, aPart, bPart))
errors = append(errors, fmt.Errorf("%w (%s captures '%s')", ErrPatternCollision, aPart, bPart))
continue
}
@ -139,13 +139,13 @@ func checkURICollision(uriA, uriB []string, inputA, inputB map[string]*Parameter
// fail if no type or no validator
if !exists || input.Validator == nil {
errors = append(errors, fmt.Errorf("%w (invalid type for %s)", errPatternCollision, bPart))
errors = append(errors, fmt.Errorf("%w (invalid type for %s)", ErrPatternCollision, bPart))
continue
}
// fail if not valid
if _, valid := input.Validator(aPart); valid {
errors = append(errors, fmt.Errorf("%w (%s captures '%s')", errPatternCollision, bPart, aPart))
errors = append(errors, fmt.Errorf("%w (%s captures '%s')", ErrPatternCollision, bPart, aPart))
continue
}
}

View File

@ -8,7 +8,7 @@ import (
"strings"
"testing"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra/validator"
)
func TestLegalServiceName(t *testing.T) {
@ -21,15 +21,15 @@ func TestLegalServiceName(t *testing.T) {
// empty
{
`[ { "method": "GET", "info": "a", "path": "" } ]`,
errInvalidPattern,
ErrInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "no-starting-slash" } ]`,
errInvalidPattern,
ErrInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "ending-slash/" } ]`,
errInvalidPattern,
ErrInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "/" } ]`,
@ -45,35 +45,35 @@ func TestLegalServiceName(t *testing.T) {
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/s{braces}" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}a" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}" } ]`,
errUndefinedBraceCapture,
ErrUndefinedBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/s{braces}/abc" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}s/abc" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}/abc" } ]`,
errUndefinedBraceCapture,
ErrUndefinedBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{b{races}s/abc" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}/}abc" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
}
@ -143,8 +143,8 @@ func TestAvailableMethods(t *testing.T) {
t.FailNow()
}
if !test.ValidMethod && !errors.Is(err, errUnknownMethod) {
t.Errorf("expected error <%s> got <%s>", errUnknownMethod, err)
if !test.ValidMethod && !errors.Is(err, ErrUnknownMethod) {
t.Errorf("expected error <%s> got <%s>", ErrUnknownMethod, err)
t.FailNow()
}
})
@ -184,7 +184,7 @@ func TestParseMissingMethodDescription(t *testing.T) {
`[ { "method": "GET", "path": "/" }]`,
false,
},
{ // missing description
{ // missing descriptiontype
`[ { "method": "GET", "path": "/subservice" }]`,
false,
},
@ -217,8 +217,8 @@ func TestParseMissingMethodDescription(t *testing.T) {
t.FailNow()
}
if !test.ValidDescription && !errors.Is(err, errMissingDescription) {
t.Errorf("expected error <%s> got <%s>", errMissingDescription, err)
if !test.ValidDescription && !errors.Is(err, ErrMissingDescription) {
t.Errorf("expected error <%s> got <%s>", ErrMissingDescription, err)
t.FailNow()
}
})
@ -239,7 +239,7 @@ func TestParamEmptyRenameNoRename(t *testing.T) {
}
]`)
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Validators = append(srv.Validators, validator.AnyType{})
err := srv.Parse(r)
if err != nil {
t.Errorf("unexpected error: '%s'", err)
@ -275,8 +275,8 @@ func TestOptionalParam(t *testing.T) {
}
]`)
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
srv.Validators = append(srv.Validators, validator.AnyType{})
srv.Validators = append(srv.Validators, validator.BoolType{})
err := srv.Parse(r)
if err != nil {
t.Errorf("unexpected error: '%s'", err)
@ -321,7 +321,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamDesc,
ErrMissingParamDesc,
},
{ // invalid param name suffix
`[
@ -334,7 +334,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamDesc,
ErrMissingParamDesc,
},
{ // missing param description
@ -348,7 +348,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamDesc,
ErrMissingParamDesc,
},
{ // empty param description
`[
@ -361,7 +361,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamDesc,
ErrMissingParamDesc,
},
{ // missing param type
@ -375,7 +375,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamType,
ErrMissingParamType,
},
{ // empty param type
`[
@ -388,7 +388,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamType,
ErrMissingParamType,
},
{ // invalid type (optional mark only)
`[
@ -402,7 +402,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
errMissingParamType,
ErrMissingParamType,
},
{ // valid description + valid type
`[
@ -444,7 +444,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
errParamNameConflict,
ErrParamNameConflict,
},
{ // rename conflict with name
`[
@ -459,7 +459,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
errParamNameConflict,
ErrParamNameConflict,
},
{ // rename conflict with rename
`[
@ -474,7 +474,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
errParamNameConflict,
ErrParamNameConflict,
},
{ // both renamed with no conflict
@ -503,7 +503,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMandatoryRename,
ErrMandatoryRename,
},
{
`[
@ -516,7 +516,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMandatoryRename,
ErrMandatoryRename,
},
{
`[
@ -556,7 +556,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errIllegalOptionalURIParam,
ErrIllegalOptionalURIParam,
},
{ // URI parameter not specified
`[
@ -569,7 +569,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errUnspecifiedBraceCapture,
ErrUnspecifiedBraceCapture,
},
{ // URI parameter not defined
`[
@ -580,7 +580,7 @@ func TestParseParameters(t *testing.T) {
"in": { }
}
]`,
errUndefinedBraceCapture,
ErrUndefinedBraceCapture,
},
}
@ -588,7 +588,7 @@ func TestParseParameters(t *testing.T) {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Validators = append(srv.Validators, validator.AnyType{})
err := srv.Parse(strings.NewReader(test.Raw))
if err == nil && test.Error != nil {
@ -637,7 +637,7 @@ func TestServiceCollision(t *testing.T) {
"info": "info", "in": {}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -672,7 +672,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -698,7 +698,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -711,7 +711,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -750,7 +750,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -789,7 +789,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -804,7 +804,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -827,8 +827,8 @@ func TestServiceCollision(t *testing.T) {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Types = append(srv.Types, builtin.StringDataType{})
srv.Types = append(srv.Types, builtin.UintDataType{})
srv.Validators = append(srv.Validators, validator.StringType{})
srv.Validators = append(srv.Validators, validator.UintType{})
err := srv.Parse(strings.NewReader(test.Config))
if err == nil && test.Error != nil {
@ -997,9 +997,9 @@ func TestMatchSimple(t *testing.T) {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.IntDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
srv.Validators = append(srv.Validators, validator.AnyType{})
srv.Validators = append(srv.Validators, validator.IntType{})
srv.Validators = append(srv.Validators, validator.BoolType{})
err := srv.Parse(strings.NewReader(test.Config))
if err != nil {
@ -1081,9 +1081,9 @@ func TestFindPriority(t *testing.T) {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.IntDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
srv.Validators = append(srv.Validators, validator.AnyType{})
srv.Validators = append(srv.Validators, validator.IntType{})
srv.Validators = append(srv.Validators, validator.BoolType{})
err := srv.Parse(strings.NewReader(test.Config))
if err != nil {

View File

@ -1,59 +1,61 @@
package config
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Err allows you to create constant "const" error with type boxing.
type Err string
func (err cerr) Error() string {
func (err Err) Error() string {
return string(err)
}
// errRead - read error
const errRead = cerr("cannot read config")
const (
// ErrRead - read error
ErrRead = Err("cannot read config")
// errUnknownMethod - unknown http method
const errUnknownMethod = cerr("unknown HTTP method")
// ErrUnknownMethod - unknown http method
ErrUnknownMethod = Err("unknown HTTP method")
// errFormat - invalid format
const errFormat = cerr("invalid config format")
// ErrFormat - invalid format
ErrFormat = Err("invalid config format")
// errPatternCollision - collision between 2 services' patterns
const errPatternCollision = cerr("pattern collision")
// ErrPatternCollision - collision between 2 services' patterns
ErrPatternCollision = Err("pattern collision")
// errInvalidPattern - malformed service pattern
const errInvalidPattern = cerr("malformed service path: must begin with a '/' and not end with")
// ErrInvalidPattern - malformed service pattern
ErrInvalidPattern = Err("malformed service path: must begin with a '/' and not end with")
// errInvalidPatternBraceCapture - invalid brace capture
const errInvalidPatternBraceCapture = cerr("invalid uri parameter")
// ErrInvalidPatternBraceCapture - invalid brace capture
ErrInvalidPatternBraceCapture = Err("invalid uri parameter")
// errUnspecifiedBraceCapture - missing path brace capture
const errUnspecifiedBraceCapture = cerr("missing uri parameter")
// ErrUnspecifiedBraceCapture - missing path brace capture
ErrUnspecifiedBraceCapture = Err("missing uri parameter")
// errUndefinedBraceCapture - missing capturing brace definition
const errUndefinedBraceCapture = cerr("missing uri parameter definition")
// ErrUndefinedBraceCapture - missing capturing brace definition
ErrUndefinedBraceCapture = Err("missing uri parameter definition")
// errMandatoryRename - capture/query parameters must be renamed
const errMandatoryRename = cerr("uri and query parameters must be renamed")
// ErrMandatoryRename - capture/query parameters must be renamed
ErrMandatoryRename = Err("uri and query parameters must be renamed")
// errMissingDescription - a service is missing its description
const errMissingDescription = cerr("missing description")
// ErrMissingDescription - a service is missing its description
ErrMissingDescription = Err("missing description")
// errIllegalOptionalURIParam - uri parameter cannot optional
const errIllegalOptionalURIParam = cerr("uri parameter cannot be optional")
// ErrIllegalOptionalURIParam - uri parameter cannot optional
ErrIllegalOptionalURIParam = Err("uri parameter cannot be optional")
// errOptionalOption - cannot have optional output
const errOptionalOption = cerr("output cannot be optional")
// ErrOptionalOption - cannot have optional output
ErrOptionalOption = Err("output cannot be optional")
// errMissingParamDesc - missing parameter description
const errMissingParamDesc = cerr("missing parameter description")
// ErrMissingParamDesc - missing parameter description
ErrMissingParamDesc = Err("missing parameter description")
// errUnknownDataType - unknown parameter datatype
const errUnknownDataType = cerr("unknown parameter datatype")
// ErrUnknownParamType - unknown parameter type
ErrUnknownParamType = Err("unknown parameter datatype")
// errIllegalParamName - illegal parameter name
const errIllegalParamName = cerr("illegal parameter name")
// ErrIllegalParamName - illegal parameter name
ErrIllegalParamName = Err("illegal parameter name")
// errMissingParamType - missing parameter type
const errMissingParamType = cerr("missing parameter type")
// ErrMissingParamType - missing parameter type
ErrMissingParamType = Err("missing parameter type")
// errParamNameConflict - name/rename conflict
const errParamNameConflict = cerr("parameter name conflict")
// ErrParamNameConflict - name/rename conflict
ErrParamNameConflict = Err("parameter name conflict")
)

View File

@ -3,7 +3,7 @@ package config
import (
"reflect"
"git.xdrm.io/go/aicra/datatype"
"github.com/xdrm-io/aicra/validator"
)
// Parameter represents a parameter definition (from api.json)
@ -12,19 +12,19 @@ type Parameter struct {
Type string `json:"type"`
Rename string `json:"name,omitempty"`
Optional bool
// ExtractType is the type the Validator will cast into
ExtractType reflect.Type
// GoType is the type the Validator will cast into
GoType reflect.Type
// Validator is inferred from the "type" property
Validator datatype.Validator
Validator validator.ValidateFunc
}
func (param *Parameter) validate(datatypes ...datatype.T) error {
func (param *Parameter) validate(datatypes ...validator.Type) error {
if len(param.Description) < 1 {
return errMissingParamDesc
return ErrMissingParamDesc
}
if len(param.Type) < 1 || param.Type == "?" {
return errMissingParamType
return ErrMissingParamType
}
// optional type
@ -35,14 +35,14 @@ func (param *Parameter) validate(datatypes ...datatype.T) error {
// find validator
for _, dtype := range datatypes {
param.Validator = dtype.Build(param.Type, datatypes...)
param.ExtractType = dtype.Type()
param.Validator = dtype.Validator(param.Type, datatypes...)
param.GoType = dtype.GoType()
if param.Validator != nil {
break
}
}
if param.Validator == nil {
return errUnknownDataType
return ErrUnknownParamType
}
return nil
}

View File

@ -6,12 +6,14 @@ import (
"regexp"
"strings"
"git.xdrm.io/go/aicra/datatype"
"github.com/xdrm-io/aicra/validator"
)
var braceRegex = regexp.MustCompile(`^{([a-z_-]+)}$`)
var queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`)
var availableHTTPMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
var (
captureRegex = regexp.MustCompile(`^{([a-z_-]+)}$`)
queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`)
availableHTTPMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
)
// Service definition
type Service struct {
@ -43,19 +45,25 @@ type BraceCapture struct {
// Match returns if this service would handle this HTTP request
func (svc *Service) Match(req *http.Request) bool {
if req.Method != svc.Method {
return false
var (
uri = req.RequestURI
queryIndex = strings.IndexByte(uri, '?')
)
// remove query part for matching the pattern
if queryIndex > -1 {
uri = uri[:queryIndex]
}
if !svc.matchPattern(req.RequestURI) {
return false
}
return true
return req.Method == svc.Method && svc.matchPattern(uri)
}
// checks if an uri matches the service's pattern
func (svc *Service) matchPattern(uri string) bool {
uriparts := SplitURL(uri)
parts := SplitURL(svc.Pattern)
var (
uriparts = SplitURL(uri)
parts = SplitURL(svc.Pattern)
)
if len(uriparts) != len(parts) {
return false
@ -97,40 +105,35 @@ func (svc *Service) matchPattern(uri string) bool {
}
// Validate implements the validator interface
func (svc *Service) validate(datatypes ...datatype.T) error {
// check method
err := svc.isMethodAvailable()
func (svc *Service) validate(datatypes ...validator.Type) error {
err := svc.checkMethod()
if err != nil {
return fmt.Errorf("field 'method': %w", err)
}
// check pattern
svc.Pattern = strings.Trim(svc.Pattern, " \t\r\n")
err = svc.isPatternValid()
err = svc.checkPattern()
if err != nil {
return fmt.Errorf("field 'path': %w", err)
}
// check description
if len(strings.Trim(svc.Description, " \t\r\n")) < 1 {
return fmt.Errorf("field 'description': %w", errMissingDescription)
return fmt.Errorf("field 'description': %w", ErrMissingDescription)
}
// check input parameters
err = svc.validateInput(datatypes)
err = svc.checkInput(datatypes)
if err != nil {
return fmt.Errorf("field 'in': %w", err)
}
// fail if a brace capture remains undefined
// fail when a brace capture remains undefined
for _, capture := range svc.Captures {
if capture.Ref == nil {
return fmt.Errorf("field 'in': %s: %w", capture.Name, errUndefinedBraceCapture)
return fmt.Errorf("field 'in': %s: %w", capture.Name, ErrUndefinedBraceCapture)
}
}
// check output
err = svc.validateOutput(datatypes)
err = svc.checkOutput(datatypes)
if err != nil {
return fmt.Errorf("field 'out': %w", err)
}
@ -138,27 +141,34 @@ func (svc *Service) validate(datatypes ...datatype.T) error {
return nil
}
func (svc *Service) isMethodAvailable() error {
func (svc *Service) checkMethod() error {
for _, available := range availableHTTPMethods {
if svc.Method == available {
return nil
}
}
return errUnknownMethod
return ErrUnknownMethod
}
func (svc *Service) isPatternValid() error {
// checkPattern checks for the validity of the pattern definition (i.e. the uri)
//
// Note that the uri can contain capture params e.g. `/a/{b}/c/{d}`, in this
// example, input parameters with names `{b}` and `{d}` are expected.
//
// This methods sets up the service state with adding capture params that are
// expected; checkInputs() will be able to check params agains pattern captures.
func (svc *Service) checkPattern() error {
length := len(svc.Pattern)
// empty pattern
if length < 1 {
return errInvalidPattern
return ErrInvalidPattern
}
if length > 1 {
// pattern not starting with '/' or ending with '/'
if svc.Pattern[0] != '/' || svc.Pattern[length-1] == '/' {
return errInvalidPattern
return ErrInvalidPattern
}
}
@ -166,11 +176,11 @@ func (svc *Service) isPatternValid() error {
parts := SplitURL(svc.Pattern)
for i, part := range parts {
if len(part) < 1 {
return errInvalidPattern
return ErrInvalidPattern
}
// if brace capture
if matches := braceRegex.FindAllStringSubmatch(part, -1); len(matches) > 0 && len(matches[0]) > 1 {
if matches := captureRegex.FindAllStringSubmatch(part, -1); len(matches) > 0 && len(matches[0]) > 1 {
braceName := matches[0][1]
// append
@ -187,149 +197,185 @@ func (svc *Service) isPatternValid() error {
// fail on invalid format
if strings.ContainsAny(part, "{}") {
return errInvalidPatternBraceCapture
return ErrInvalidPatternBraceCapture
}
}
return nil
}
func (svc *Service) validateInput(types []datatype.T) error {
// ignore no parameter
func (svc *Service) checkInput(types []validator.Type) error {
// no parameter
if svc.Input == nil || len(svc.Input) < 1 {
svc.Input = make(map[string]*Parameter, 0)
svc.Input = map[string]*Parameter{}
return nil
}
// for each parameter
for paramName, param := range svc.Input {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, errIllegalParamName)
for name, p := range svc.Input {
if len(name) < 1 {
return fmt.Errorf("%s: %w", name, ErrIllegalParamName)
}
// fail if brace capture does not exists in pattern
var iscapture, isquery bool
if matches := braceRegex.FindAllStringSubmatch(paramName, -1); len(matches) > 0 && len(matches[0]) > 1 {
braceName := matches[0][1]
found := false
for _, capture := range svc.Captures {
if capture.Name == braceName {
capture.Ref = param
found = true
break
}
}
if !found {
return fmt.Errorf("%s: %w", paramName, errUnspecifiedBraceCapture)
}
iscapture = true
} else if matches := queryRegex.FindAllStringSubmatch(paramName, -1); len(matches) > 0 && len(matches[0]) > 1 {
queryName := matches[0][1]
// init map
if svc.Query == nil {
svc.Query = make(map[string]*Parameter)
}
svc.Query[queryName] = param
isquery = true
} else {
if svc.Form == nil {
svc.Form = make(map[string]*Parameter)
}
svc.Form[paramName] = param
}
// fail if capture or query without rename
if len(param.Rename) < 1 && (iscapture || isquery) {
return fmt.Errorf("%s: %w", paramName, errMandatoryRename)
}
// use param name if no rename
if len(param.Rename) < 1 {
param.Rename = paramName
}
err := param.validate(types...)
// parse parameters: capture (uri), query or form and update the service
// attributes accordingly
ptype, err := svc.parseParam(name, p)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
return err
}
// Rename mandatory for capture and query
if len(p.Rename) < 1 && (ptype == captureParam || ptype == queryParam) {
return fmt.Errorf("%s: %w", name, ErrMandatoryRename)
}
// fallback to name when Rename is not provided
if len(p.Rename) < 1 {
p.Rename = name
}
err = p.validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
// capture parameter cannot be optional
if iscapture && param.Optional {
return fmt.Errorf("%s: %w", paramName, errIllegalOptionalURIParam)
if p.Optional && ptype == captureParam {
return fmt.Errorf("%s: %w", name, ErrIllegalOptionalURIParam)
}
// fail on name/rename conflict
for paramName2, param2 := range svc.Input {
// ignore self
if paramName == paramName2 {
continue
err = nameConflicts(name, p, svc.Input)
if err != nil {
return err
}
// 3.2.1. Same rename field
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if param.Rename == param2.Rename || paramName == param2.Rename || paramName2 == param.Rename {
return fmt.Errorf("%s: %w", paramName, errParamNameConflict)
}
}
}
return nil
}
func (svc *Service) validateOutput(types []datatype.T) error {
// ignore no parameter
func (svc *Service) checkOutput(types []validator.Type) error {
// no parameter
if svc.Output == nil || len(svc.Output) < 1 {
svc.Output = make(map[string]*Parameter, 0)
return nil
}
// for each parameter
for paramName, param := range svc.Output {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, errIllegalParamName)
for name, p := range svc.Output {
if len(name) < 1 {
return fmt.Errorf("%s: %w", name, ErrIllegalParamName)
}
// use param name if no rename
if len(param.Rename) < 1 {
param.Rename = paramName
// fallback to name when Rename is not provided
if len(p.Rename) < 1 {
p.Rename = name
}
err := param.validate(types...)
err := p.validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
return fmt.Errorf("%s: %w", name, err)
}
if param.Optional {
return fmt.Errorf("%s: %w", paramName, errOptionalOption)
if p.Optional {
return fmt.Errorf("%s: %w", name, ErrOptionalOption)
}
// fail on name/rename conflict
for paramName2, param2 := range svc.Output {
err = nameConflicts(name, p, svc.Output)
if err != nil {
return err
}
}
return nil
}
type paramType int
const (
captureParam paramType = iota
queryParam
formParam
)
// parseParam determines which param type it is from its name:
// - `{paramName}` is an capture; it captures a segment of the uri defined in
// the pattern definition, e.g. `/some/path/with/{paramName}/somewhere`
// - `GET@paramName` is an uri query that is received from the http query format
// in the uri, e.g. `http://domain.com/uri?paramName=paramValue&param2=value2`
// - any other name that contains valid characters is considered a Form
// parameter; it is extracted from the http request's body as: json, multipart
// or using the x-www-form-urlencoded format.
//
// Special notes:
// - capture params MUST be found in the pattern definition.
// - capture params MUST NOT be optional as they are in the pattern anyways.
// - capture and query params MUST be renamed because the `{param}` or
// `GET@param` name formats cannot be translated to a valid go exported name.
// c.f. the `dynfunc` package that creates a handler func() signature from
// the service definitions (i.e. input and output parameters).
func (svc *Service) parseParam(name string, p *Parameter) (paramType, error) {
var (
captureMatches = captureRegex.FindAllStringSubmatch(name, -1)
isCapture = len(captureMatches) > 0 && len(captureMatches[0]) > 1
)
// Parameter is a capture (uri/{param})
if isCapture {
captureName := captureMatches[0][1]
// fail if brace capture does not exists in pattern
found := false
for _, capture := range svc.Captures {
if capture.Name == captureName {
capture.Ref = p
found = true
break
}
}
if !found {
return captureParam, fmt.Errorf("%s: %w", name, ErrUnspecifiedBraceCapture)
}
return captureParam, nil
}
var (
queryMatches = queryRegex.FindAllStringSubmatch(name, -1)
isQuery = len(queryMatches) > 0 && len(queryMatches[0]) > 1
)
// Parameter is a query (uri?param)
if isQuery {
queryName := queryMatches[0][1]
// init map
if svc.Query == nil {
svc.Query = make(map[string]*Parameter)
}
svc.Query[queryName] = p
return queryParam, nil
}
// Parameter is a form param
if svc.Form == nil {
svc.Form = make(map[string]*Parameter)
}
svc.Form[name] = p
return formParam, nil
}
// nameConflicts returns whether ar given parameter has its name or Rename field
// in conflict with an existing parameter
func nameConflicts(name string, param *Parameter, others map[string]*Parameter) error {
for otherName, other := range others {
// ignore self
if paramName == paramName2 {
if otherName == name {
continue
}
// 3.2.1. Same rename field
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if param.Rename == param2.Rename || paramName == param2.Rename || paramName2 == param.Rename {
return fmt.Errorf("%s: %w", paramName, errParamNameConflict)
// 1. same rename field
// 2. original name matches a renamed field
// 3. renamed field matches an original name
if param.Rename == other.Rename || name == other.Rename || otherName == param.Rename {
return fmt.Errorf("%s: %w", otherName, ErrParamNameConflict)
}
}
}
return nil
}

View File

@ -1,50 +1,52 @@
package dynfunc
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Err allows you to create constant "const" error with type boxing.
type Err string
func (err cerr) Error() string {
func (err Err) Error() string {
return string(err)
}
// errHandlerNotFunc - handler is not a func
const errHandlerNotFunc = cerr("handler must be a func")
const (
// ErrHandlerNotFunc - handler is not a func
ErrHandlerNotFunc = Err("handler must be a func")
// errNoServiceForHandler - no service matching this handler
const errNoServiceForHandler = cerr("no service found for this handler")
// ErrNoServiceForHandler - no service matching this handler
ErrNoServiceForHandler = Err("no service found for this handler")
// errMissingHandlerArgumentParam - missing params arguments for handler
const errMissingHandlerContextArgument = cerr("missing handler first argument of type *api.Context")
ErrMissingHandlerContextArgument = Err("missing handler first argument of type context.Context")
// errMissingHandlerInputArgument - missing params arguments for handler
const errMissingHandlerInputArgument = cerr("missing handler argument: input struct")
// ErrInvalidHandlerContextArgument - missing handler output error
ErrInvalidHandlerContextArgument = Err("first input argument should be of type context.Context")
// errUnexpectedInput - input argument is not expected
const errUnexpectedInput = cerr("unexpected input struct")
// ErrMissingHandlerInputArgument - missing params arguments for handler
ErrMissingHandlerInputArgument = Err("missing handler argument: input struct")
// errMissingHandlerOutputArgument - missing output for handler
const errMissingHandlerOutputArgument = cerr("missing handler first output argument: output struct")
// ErrUnexpectedInput - input argument is not expected
ErrUnexpectedInput = Err("unexpected input struct")
// errMissingHandlerOutputError - missing error output for handler
const errMissingHandlerOutputError = cerr("missing handler last output argument of type api.Err")
// ErrMissingHandlerOutputArgument - missing output for handler
ErrMissingHandlerOutputArgument = Err("missing handler first output argument: output struct")
// errMissingRequestArgument - missing request argument for handler
const errMissingRequestArgument = cerr("handler first argument must be of type api.Request")
// ErrMissingHandlerErrorArgument - missing error output for handler
ErrMissingHandlerErrorArgument = Err("missing handler last output argument of type api.Err")
// errMissingParamArgument - missing parameters argument for handler
const errMissingParamArgument = cerr("handler second argument must be a struct")
// ErrInvalidHandlerErrorArgument - missing handler output error
ErrInvalidHandlerErrorArgument = Err("last output must be of type api.Err")
// errUnexportedName - argument is unexported in struct
const errUnexportedName = cerr("unexported name")
// ErrMissingParamArgument - missing parameters argument for handler
ErrMissingParamArgument = Err("handler second argument must be a struct")
// errWrongOutputArgumentType - wrong type for output first argument
const errWrongOutputArgumentType = cerr("handler first output argument must be a *struct")
// ErrUnexportedName - argument is unexported in struct
ErrUnexportedName = Err("unexported name")
// errMissingConfigArgument - missing an input/output argument in handler struct
const errMissingConfigArgument = cerr("missing an argument from the configuration")
// ErrWrongOutputArgumentType - wrong type for output first argument
ErrWrongOutputArgumentType = Err("handler first output argument must be a *struct")
// errWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct
const errWrongParamTypeFromConfig = cerr("invalid struct field type")
// ErrMissingConfigArgument - missing an input/output argument in handler struct
ErrMissingConfigArgument = Err("missing an argument from the configuration")
// errMissingHandlerErrorArgument - missing handler output error
const errMissingHandlerErrorArgument = cerr("last output must be of type api.Err")
// ErrWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct
ErrWrongParamTypeFromConfig = Err("invalid struct field type")
)

View File

@ -1,12 +1,13 @@
package dynfunc
import (
"context"
"fmt"
"log"
"reflect"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/config"
)
// Handler represents a dynamic aicra service handler
@ -19,14 +20,14 @@ type Handler struct {
// Build a handler from a dynamic function and checks its signature against a
// service configuration
//e
// `fn` must have as a signature : `func(*api.Context, in) (*out, api.Err)`
//
// `fn` must have as a signature : `func(context.Context, in) (*out, api.Err)`
// - `in` is a struct{} containing a field for each service input (with valid reflect.Type)
// - `out` is a struct{} containing a field for each service output (with valid reflect.Type)
//
// Special cases:
// - it there is no input, `in` MUST be omitted
// - it there is no output, `out` MUST be omitted
// - it there is no output, `out` CAN be omitted
func Build(fn interface{}, service config.Service) (*Handler, error) {
var (
h = &Handler{
@ -37,7 +38,7 @@ func Build(fn interface{}, service config.Service) (*Handler, error) {
)
if fnType.Kind() != reflect.Func {
return nil, errHandlerNotFunc
return nil, ErrHandlerNotFunc
}
if err := h.signature.ValidateInput(fnType); err != nil {
return nil, fmt.Errorf("input: %w", err)
@ -50,7 +51,7 @@ func Build(fn interface{}, service config.Service) (*Handler, error) {
}
// Handle binds input `data` into the dynamic function and returns an output map
func (h *Handler) Handle(ctx *api.Context, data map[string]interface{}) (map[string]interface{}, api.Err) {
func (h *Handler) Handle(ctx context.Context, data map[string]interface{}) (map[string]interface{}, api.Err) {
var (
ert = reflect.TypeOf(api.Err{})
fnv = reflect.ValueOf(h.fn)

View File

@ -1,11 +1,12 @@
package dynfunc
import (
"context"
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/aicra/api"
"github.com/xdrm-io/aicra/api"
)
type testsignature Signature
@ -52,7 +53,7 @@ func TestInput(t *testing.T) {
{
Name: "none required none provided",
Spec: (&testsignature{}).withArgs(),
Fn: func(*api.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
Fn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HasContext: false,
Input: []interface{}{},
ExpectedOutput: []interface{}{},
@ -61,7 +62,7 @@ func TestInput(t *testing.T) {
{
Name: "int proxy (0)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(int(0))),
Fn: func(ctx *api.Context, in intstruct) (*intstruct, api.Err) {
Fn: func(ctx context.Context, in intstruct) (*intstruct, api.Err) {
return &intstruct{P1: in.P1}, api.ErrSuccess
},
HasContext: false,
@ -72,7 +73,7 @@ func TestInput(t *testing.T) {
{
Name: "int proxy (11)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(int(0))),
Fn: func(ctx *api.Context, in intstruct) (*intstruct, api.Err) {
Fn: func(ctx context.Context, in intstruct) (*intstruct, api.Err) {
return &intstruct{P1: in.P1}, api.ErrSuccess
},
HasContext: false,
@ -83,7 +84,7 @@ func TestInput(t *testing.T) {
{
Name: "*int proxy (nil)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(new(int))),
Fn: func(ctx *api.Context, in intptrstruct) (*intptrstruct, api.Err) {
Fn: func(ctx context.Context, in intptrstruct) (*intptrstruct, api.Err) {
return &intptrstruct{P1: in.P1}, api.ErrSuccess
},
HasContext: false,
@ -94,7 +95,7 @@ func TestInput(t *testing.T) {
{
Name: "*int proxy (28)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(new(int))),
Fn: func(ctx *api.Context, in intptrstruct) (*intstruct, api.Err) {
Fn: func(ctx context.Context, in intptrstruct) (*intstruct, api.Err) {
return &intstruct{P1: *in.P1}, api.ErrSuccess
},
HasContext: false,
@ -105,7 +106,7 @@ func TestInput(t *testing.T) {
{
Name: "*int proxy (13)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(new(int))),
Fn: func(ctx *api.Context, in intptrstruct) (*intstruct, api.Err) {
Fn: func(ctx context.Context, in intptrstruct) (*intstruct, api.Err) {
return &intstruct{P1: *in.P1}, api.ErrSuccess
},
HasContext: false,
@ -131,7 +132,7 @@ func TestInput(t *testing.T) {
input[key] = val
}
var output, err = handler.Handle(&api.Context{}, input)
var output, err = handler.Handle(context.Background(), input)
if err != tcase.ExpectedErr {
t.Fatalf("expected api error <%v> got <%v>", tcase.ExpectedErr, err)
}

View File

@ -1,12 +1,13 @@
package dynfunc
import (
"context"
"fmt"
"reflect"
"strings"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/config"
)
// Signature represents input/output arguments for service from the aicra configuration
@ -30,17 +31,17 @@ func BuildSignature(service config.Service) *Signature {
}
// make a pointer if optional
if param.Optional {
s.Input[param.Rename] = reflect.PtrTo(param.ExtractType)
s.Input[param.Rename] = reflect.PtrTo(param.GoType)
continue
}
s.Input[param.Rename] = param.ExtractType
s.Input[param.Rename] = param.GoType
}
for _, param := range service.Output {
if len(param.Rename) < 1 {
continue
}
s.Output[param.Rename] = param.ExtractType
s.Output[param.Rename] = param.GoType
}
return s
@ -48,46 +49,51 @@ func BuildSignature(service config.Service) *Signature {
// ValidateInput validates a handler's input arguments against the service signature
func (s *Signature) ValidateInput(handlerType reflect.Type) error {
ctxType := reflect.TypeOf(api.Context{})
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
// missing or invalid first arg: api.Context
if handlerType.NumIn() < 1 || ctxType.AssignableTo(handlerType.In(0)) {
return errMissingHandlerContextArgument
// missing or invalid first arg: context.Context
if handlerType.NumIn() < 1 {
return ErrMissingHandlerContextArgument
}
firstArgType := handlerType.In(0)
if !firstArgType.Implements(ctxType) {
return ErrInvalidHandlerContextArgument
}
// no input required
if len(s.Input) == 0 {
// input struct provided
if handlerType.NumIn() > 1 {
return errUnexpectedInput
return ErrUnexpectedInput
}
return nil
}
// too much arguments
if handlerType.NumIn() > 2 {
return errMissingHandlerInputArgument
if handlerType.NumIn() != 2 {
return ErrMissingHandlerInputArgument
}
// arg must be a struct
inStruct := handlerType.In(1)
if inStruct.Kind() != reflect.Struct {
return errMissingParamArgument
return ErrMissingParamArgument
}
// check for invalid param
for name, ptype := range s.Input {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, errUnexportedName)
return fmt.Errorf("%s: %w", name, ErrUnexportedName)
}
field, exists := inStruct.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, errMissingConfigArgument)
return fmt.Errorf("%s: %w", name, ErrMissingConfigArgument)
}
if !ptype.AssignableTo(field.Type) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, errWrongParamTypeFromConfig, field.Type, ptype)
return fmt.Errorf("%s: %w (%s instead of %s)", name, ErrWrongParamTypeFromConfig, field.Type, ptype)
}
}
@ -99,44 +105,44 @@ func (s Signature) ValidateOutput(handlerType reflect.Type) error {
errType := reflect.TypeOf(api.ErrUnknown)
if handlerType.NumOut() < 1 {
return errMissingHandlerErrorArgument
return ErrMissingHandlerErrorArgument
}
// last output must be api.Err
lastArgType := handlerType.Out(handlerType.NumOut() - 1)
if !lastArgType.AssignableTo(errType) {
return errMissingHandlerErrorArgument
return ErrInvalidHandlerErrorArgument
}
// no output -> ok
// no output required -> ok
if len(s.Output) == 0 {
return nil
}
if handlerType.NumOut() < 2 {
return errMissingHandlerOutputArgument
return ErrMissingHandlerOutputArgument
}
// fail if first output is not a pointer to struct
outStructPtr := handlerType.Out(0)
if outStructPtr.Kind() != reflect.Ptr {
return errWrongOutputArgumentType
return ErrWrongOutputArgumentType
}
outStruct := outStructPtr.Elem()
if outStruct.Kind() != reflect.Struct {
return errWrongOutputArgumentType
return ErrWrongOutputArgumentType
}
// fail on invalid output
for name, ptype := range s.Output {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, errUnexportedName)
return fmt.Errorf("%s: %w", name, ErrUnexportedName)
}
field, exists := outStruct.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, errMissingConfigArgument)
return fmt.Errorf("%s: %w", name, ErrMissingConfigArgument)
}
// ignore types evalutating to nil
@ -145,7 +151,7 @@ func (s Signature) ValidateOutput(handlerType reflect.Type) error {
}
if !field.Type.ConvertibleTo(ptype) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, errWrongParamTypeFromConfig, field.Type, ptype)
return fmt.Errorf("%s: %w (%s instead of %s)", name, ErrWrongParamTypeFromConfig, field.Type, ptype)
}
}

View File

@ -1,388 +1,569 @@
package dynfunc
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/aicra/api"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/config"
)
func TestInputCheck(t *testing.T) {
tcases := []struct {
Name string
Input map[string]reflect.Type
Fn interface{}
FnCtx interface{}
Err error
func TestInputValidation(t *testing.T) {
tt := []struct {
name string
input map[string]reflect.Type
fn interface{}
err error
}{
{
Name: "no input 0 given",
Input: map[string]reflect.Type{},
Fn: func(*api.Context) {},
FnCtx: func(*api.Context) {},
Err: nil,
name: "missing context",
input: map[string]reflect.Type{},
fn: func() {},
err: ErrMissingHandlerContextArgument,
},
{
Name: "no input 1 given",
Input: map[string]reflect.Type{},
Fn: func(*api.Context, int) {},
FnCtx: func(*api.Context, int) {},
Err: errUnexpectedInput,
name: "invalid context",
input: map[string]reflect.Type{},
fn: func(int) {},
err: ErrInvalidHandlerContextArgument,
},
{
Name: "no input 2 given",
Input: map[string]reflect.Type{},
Fn: func(*api.Context, int, string) {},
FnCtx: func(*api.Context, int, string) {},
Err: errUnexpectedInput,
name: "no input 0 given",
input: map[string]reflect.Type{},
fn: func(context.Context) {},
err: nil,
},
{
Name: "1 input 0 given",
Input: map[string]reflect.Type{
name: "no input 1 given",
input: map[string]reflect.Type{},
fn: func(context.Context, int) {},
err: ErrUnexpectedInput,
},
{
name: "no input 2 given",
input: map[string]reflect.Type{},
fn: func(context.Context, int, string) {},
err: ErrUnexpectedInput,
},
{
name: "1 input 0 given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(*api.Context) {},
FnCtx: func(*api.Context) {},
Err: errMissingHandlerInputArgument,
fn: func(context.Context) {},
err: ErrMissingHandlerInputArgument,
},
{
Name: "1 input non-struct given",
Input: map[string]reflect.Type{
name: "1 input non-struct given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(*api.Context, int) {},
FnCtx: func(*api.Context, int) {},
Err: errMissingParamArgument,
fn: func(context.Context, int) {},
err: ErrMissingParamArgument,
},
{
Name: "unexported input",
Input: map[string]reflect.Type{
name: "unexported input",
input: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func(*api.Context, struct{}) {},
FnCtx: func(*api.Context, struct{}) {},
Err: errUnexportedName,
fn: func(context.Context, struct{}) {},
err: ErrUnexportedName,
},
{
Name: "1 input empty struct given",
Input: map[string]reflect.Type{
name: "1 input empty struct given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(*api.Context, struct{}) {},
FnCtx: func(*api.Context, struct{}) {},
Err: errMissingConfigArgument,
fn: func(context.Context, struct{}) {},
err: ErrMissingConfigArgument,
},
{
Name: "1 input invalid given",
Input: map[string]reflect.Type{
name: "1 input invalid given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(*api.Context, struct{ Test1 string }) {},
FnCtx: func(*api.Context, struct{ Test1 string }) {},
Err: errWrongParamTypeFromConfig,
fn: func(context.Context, struct{ Test1 string }) {},
err: ErrWrongParamTypeFromConfig,
},
{
Name: "1 input valid given",
Input: map[string]reflect.Type{
name: "1 input valid given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(*api.Context, struct{ Test1 int }) {},
FnCtx: func(*api.Context, struct{ Test1 int }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 int }) {},
err: nil,
},
{
Name: "1 input ptr empty struct given",
Input: map[string]reflect.Type{
name: "1 input ptr empty struct given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(*api.Context, struct{}) {},
FnCtx: func(*api.Context, struct{}) {},
Err: errMissingConfigArgument,
fn: func(context.Context, struct{}) {},
err: ErrMissingConfigArgument,
},
{
Name: "1 input ptr invalid given",
Input: map[string]reflect.Type{
name: "1 input ptr invalid given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(*api.Context, struct{ Test1 string }) {},
FnCtx: func(*api.Context, struct{ Test1 string }) {},
Err: errWrongParamTypeFromConfig,
fn: func(context.Context, struct{ Test1 string }) {},
err: ErrWrongParamTypeFromConfig,
},
{
Name: "1 input ptr invalid ptr type given",
Input: map[string]reflect.Type{
name: "1 input ptr invalid ptr type given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(*api.Context, struct{ Test1 *string }) {},
FnCtx: func(*api.Context, struct{ Test1 *string }) {},
Err: errWrongParamTypeFromConfig,
fn: func(context.Context, struct{ Test1 *string }) {},
err: ErrWrongParamTypeFromConfig,
},
{
Name: "1 input ptr valid given",
Input: map[string]reflect.Type{
name: "1 input ptr valid given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(*api.Context, struct{ Test1 *int }) {},
FnCtx: func(*api.Context, struct{ Test1 *int }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 *int }) {},
err: nil,
},
{
Name: "1 valid string",
Input: map[string]reflect.Type{
name: "1 valid string",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(string("")),
},
Fn: func(*api.Context, struct{ Test1 string }) {},
FnCtx: func(*api.Context, struct{ Test1 string }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 string }) {},
err: nil,
},
{
Name: "1 valid uint",
Input: map[string]reflect.Type{
name: "1 valid uint",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(uint(0)),
},
Fn: func(*api.Context, struct{ Test1 uint }) {},
FnCtx: func(*api.Context, struct{ Test1 uint }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 uint }) {},
err: nil,
},
{
Name: "1 valid float64",
Input: map[string]reflect.Type{
name: "1 valid float64",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(float64(0)),
},
Fn: func(*api.Context, struct{ Test1 float64 }) {},
FnCtx: func(*api.Context, struct{ Test1 float64 }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 float64 }) {},
err: nil,
},
{
Name: "1 valid []byte",
Input: map[string]reflect.Type{
name: "1 valid []byte",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf([]byte("")),
},
Fn: func(*api.Context, struct{ Test1 []byte }) {},
FnCtx: func(*api.Context, struct{ Test1 []byte }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 []byte }) {},
err: nil,
},
{
Name: "1 valid []rune",
Input: map[string]reflect.Type{
name: "1 valid []rune",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf([]rune("")),
},
Fn: func(*api.Context, struct{ Test1 []rune }) {},
FnCtx: func(*api.Context, struct{ Test1 []rune }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 []rune }) {},
err: nil,
},
{
Name: "1 valid *string",
Input: map[string]reflect.Type{
name: "1 valid *string",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(string)),
},
Fn: func(*api.Context, struct{ Test1 *string }) {},
FnCtx: func(*api.Context, struct{ Test1 *string }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 *string }) {},
err: nil,
},
{
Name: "1 valid *uint",
Input: map[string]reflect.Type{
name: "1 valid *uint",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(uint)),
},
Fn: func(*api.Context, struct{ Test1 *uint }) {},
FnCtx: func(*api.Context, struct{ Test1 *uint }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 *uint }) {},
err: nil,
},
{
Name: "1 valid *float64",
Input: map[string]reflect.Type{
name: "1 valid *float64",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(float64)),
},
Fn: func(*api.Context, struct{ Test1 *float64 }) {},
FnCtx: func(*api.Context, struct{ Test1 *float64 }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 *float64 }) {},
err: nil,
},
{
Name: "1 valid *[]byte",
Input: map[string]reflect.Type{
name: "1 valid *[]byte",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new([]byte)),
},
Fn: func(*api.Context, struct{ Test1 *[]byte }) {},
FnCtx: func(*api.Context, struct{ Test1 *[]byte }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 *[]byte }) {},
err: nil,
},
{
Name: "1 valid *[]rune",
Input: map[string]reflect.Type{
name: "1 valid *[]rune",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new([]rune)),
},
Fn: func(*api.Context, struct{ Test1 *[]rune }) {},
FnCtx: func(*api.Context, struct{ Test1 *[]rune }) {},
Err: nil,
fn: func(context.Context, struct{ Test1 *[]rune }) {},
err: nil,
},
}
for _, tcase := range tcases {
t.Run(tcase.Name, func(t *testing.T) {
t.Parallel()
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
// mock spec
s := Signature{
Input: tcase.Input,
Input: tc.input,
Output: nil,
}
err := s.ValidateInput(reflect.TypeOf(tcase.FnCtx))
if err == nil && tcase.Err != nil {
t.Errorf("expected an error: '%s'", tcase.Err.Error())
t.FailNow()
err := s.ValidateInput(reflect.TypeOf(tc.fn))
if err == nil && tc.err != nil {
t.Fatalf("expected an error: '%s'", tc.err.Error())
}
if err != nil && tcase.Err == nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
if err != nil && tc.err == nil {
t.Fatalf("unexpected error: '%s'", err.Error())
}
if err != nil && tcase.Err != nil {
if !errors.Is(err, tcase.Err) {
t.Errorf("expected the error <%s> got <%s>", tcase.Err, err)
t.FailNow()
if err != nil && tc.err != nil {
if !errors.Is(err, tc.err) {
t.Fatalf("expected the error <%s> got <%s>", tc.err, err)
}
}
})
}
}
func TestOutputCheck(t *testing.T) {
tcases := []struct {
Output map[string]reflect.Type
Fn interface{}
Err error
func TestOutputValidation(t *testing.T) {
tt := []struct {
name string
output map[string]reflect.Type
fn interface{}
err error
}{
// no input -> missing api.Err
{
Output: map[string]reflect.Type{},
Fn: func(*api.Context) {},
Err: errMissingHandlerOutputArgument,
name: "no output missing err",
output: map[string]reflect.Type{},
fn: func() {},
err: ErrMissingHandlerErrorArgument,
},
// no input -> with last type not api.Err
{
Output: map[string]reflect.Type{},
Fn: func(*api.Context) bool { return true },
Err: errMissingHandlerErrorArgument,
name: "no output invalid err",
output: map[string]reflect.Type{},
fn: func() bool { return true },
err: ErrInvalidHandlerErrorArgument,
},
// no input -> with api.Err
{
Output: map[string]reflect.Type{},
Fn: func(*api.Context) api.Err { return api.ErrSuccess },
Err: nil,
name: "1 output none required",
output: map[string]reflect.Type{},
fn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
err: nil,
},
// no input -> missing *api.Context
{
Output: map[string]reflect.Type{},
Fn: func(*api.Context) api.Err { return api.ErrSuccess },
Err: errMissingHandlerContextArgument,
},
// no input -> invlaid *api.Context type
{
Output: map[string]reflect.Type{},
Fn: func(*api.Context, int) api.Err { return api.ErrSuccess },
Err: errMissingHandlerContextArgument,
},
// func can have output if not specified
{
Output: map[string]reflect.Type{},
Fn: func(*api.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
// missing output struct in func
{
Output: map[string]reflect.Type{
name: "no output 1 required",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() api.Err { return api.ErrSuccess },
Err: errWrongOutputArgumentType,
fn: func() api.Err { return api.ErrSuccess },
err: ErrMissingHandlerOutputArgument,
},
// output not a pointer
{
Output: map[string]reflect.Type{
name: "invalid int output",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (int, api.Err) { return 0, api.ErrSuccess },
Err: errWrongOutputArgumentType,
fn: func() (int, api.Err) { return 0, api.ErrSuccess },
err: ErrWrongOutputArgumentType,
},
// output not a pointer to struct
{
Output: map[string]reflect.Type{
name: "invalid int ptr output",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*int, api.Err) { return nil, api.ErrSuccess },
Err: errWrongOutputArgumentType,
fn: func() (*int, api.Err) { return nil, api.ErrSuccess },
err: ErrWrongOutputArgumentType,
},
// unexported param name
{
Output: map[string]reflect.Type{
name: "invalid struct output",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func() (struct{ Test1 int }, api.Err) { return struct{ Test1 int }{Test1: 1}, api.ErrSuccess },
err: ErrWrongOutputArgumentType,
},
{
name: "unexported param",
output: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: errUnexportedName,
fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
err: ErrUnexportedName,
},
// output field missing
{
Output: map[string]reflect.Type{
name: "missing output param",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: errMissingConfigArgument,
fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
err: ErrMissingConfigArgument,
},
// output field invalid type
{
Output: map[string]reflect.Type{
name: "invalid output param",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 string }, api.Err) { return nil, api.ErrSuccess },
Err: errWrongParamTypeFromConfig,
fn: func() (*struct{ Test1 string }, api.Err) { return nil, api.ErrSuccess },
err: ErrWrongParamTypeFromConfig,
},
// output field valid type
{
Output: map[string]reflect.Type{
name: "valid param",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil,
fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
err: nil,
},
// ignore type check on nil type
{
Output: map[string]reflect.Type{
name: "2 valid params",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
"Test2": reflect.TypeOf(string("")),
},
fn: func() (*struct {
Test1 int
Test2 string
}, api.Err) {
return nil, api.ErrSuccess
},
err: nil,
},
{
name: "nil type ignore typecheck",
output: map[string]reflect.Type{
"Test1": nil,
},
Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil,
fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
err: nil,
},
}
for i, tcase := range tcases {
t.Run(fmt.Sprintf("case.%d", i), func(t *testing.T) {
t.Parallel()
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
// mock spec
s := Signature{
Input: nil,
Output: tcase.Output,
Output: tc.output,
}
err := s.ValidateOutput(reflect.TypeOf(tcase.Fn))
if err == nil && tcase.Err != nil {
t.Errorf("expected an error: '%s'", tcase.Err.Error())
t.FailNow()
}
if err != nil && tcase.Err == nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
}
if err != nil && tcase.Err != nil {
if !errors.Is(err, tcase.Err) {
t.Errorf("expected the error <%s> got <%s>", tcase.Err, err)
t.FailNow()
}
err := s.ValidateOutput(reflect.TypeOf(tc.fn))
if !errors.Is(err, tc.err) {
t.Fatalf("expected the error <%s> got <%s>", tc.err, err)
}
})
}
}
func TestServiceValidation(t *testing.T) {
tt := []struct {
name string
in []*config.Parameter
out []*config.Parameter
fn interface{}
err error
}{
{
name: "missing context",
fn: func() {},
err: ErrMissingHandlerContextArgument,
},
{
name: "invalid context",
fn: func(int) {},
err: ErrInvalidHandlerContextArgument,
},
{
name: "missing error",
fn: func(context.Context) {},
err: ErrMissingHandlerErrorArgument,
},
{
name: "invalid error",
fn: func(context.Context) int { return 1 },
err: ErrInvalidHandlerErrorArgument,
},
{
name: "no in no out",
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "unamed in",
in: []*config.Parameter{
{
Rename: "", // should be ignored
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "missing in",
in: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: ErrMissingHandlerInputArgument,
},
{
name: "valid in",
in: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context, struct{ Test1 int }) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "optional in not ptr",
in: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
Optional: true,
},
},
fn: func(context.Context, struct{ Test1 int }) api.Err { return api.ErrSuccess },
err: ErrWrongParamTypeFromConfig,
},
{
name: "valid optional in",
in: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
Optional: true,
},
},
fn: func(context.Context, struct{ Test1 *int }) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "unamed out",
out: []*config.Parameter{
{
Rename: "", // should be ignored
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "missing out struct",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: ErrMissingHandlerOutputArgument,
},
{
name: "invalid out struct type",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) (int, api.Err) { return 0, api.ErrSuccess },
err: ErrWrongOutputArgumentType,
},
{
name: "missing out",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
err: ErrMissingConfigArgument,
},
{
name: "valid out",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
err: nil,
},
{
name: "optional out not ptr",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
Optional: true,
},
},
fn: func(context.Context) (*struct{ Test1 *int }, api.Err) { return nil, api.ErrSuccess },
err: ErrWrongParamTypeFromConfig,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
service := config.Service{
Input: make(map[string]*config.Parameter),
Output: make(map[string]*config.Parameter),
}
// fill service with arguments
if tc.in != nil && len(tc.in) > 0 {
for i, in := range tc.in {
service.Input[fmt.Sprintf("%d", i)] = in
}
}
if tc.out != nil && len(tc.out) > 0 {
for i, out := range tc.out {
service.Output[fmt.Sprintf("%d", i)] = out
}
}
s := BuildSignature(service)
err := s.ValidateInput(reflect.TypeOf(tc.fn))
if err != nil {
if !errors.Is(err, tc.err) {
t.Fatalf("expected the error <%s> got <%s>", tc.err, err)
}
return
}
err = s.ValidateOutput(reflect.TypeOf(tc.fn))
if err != nil {
if !errors.Is(err, tc.err) {
t.Fatalf("expected the error <%s> got <%s>", tc.err, err)
}
return
}
// no error encountered but expected 1
if tc.err != nil {
t.Fatalf("expected an error <%v>", tc.err)
}
})
}

View File

@ -6,8 +6,8 @@ import (
"io"
"reflect"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/multipart"
"github.com/xdrm-io/aicra/internal/config"
"github.com/xdrm-io/aicra/internal/multipart"
"net/http"
"strings"
@ -66,17 +66,24 @@ func (i *T) GetQuery(req http.Request) error {
query := req.URL.Query()
for name, param := range i.service.Query {
value, exist := query[name]
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
values, exist := query[name]
if !exist {
if !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
continue
}
parsed := parseParameter(value)
var parsed interface{}
// consider element instead of slice or elements when only 1
if len(values) == 1 {
parsed = parseParameter(values[0])
} else { // consider slice
parsed = parseParameter(values)
}
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
@ -99,19 +106,34 @@ func (i *T) GetForm(req http.Request) error {
ct := req.Header.Get("Content-Type")
switch {
case strings.HasPrefix(ct, "application/json"):
return i.parseJSON(req)
err := i.parseJSON(req)
if err != nil {
return err
}
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"):
return i.parseUrlencoded(req)
err := i.parseUrlencoded(req)
if err != nil {
return err
}
case strings.HasPrefix(ct, "multipart/form-data; boundary="):
return i.parseMultipart(req)
default:
return nil
err := i.parseMultipart(req)
if err != nil {
return err
}
}
// fail on at least 1 mandatory form param when there is no body
for name, param := range i.service.Form {
_, exists := i.Data[param.Rename]
if !exists && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
}
return nil
}
// parseJSON parses JSON from the request body inside 'Form'
// and 'Set'
func (i *T) parseJSON(req http.Request) error {
@ -129,10 +151,6 @@ func (i *T) parseJSON(req http.Request) error {
for name, param := range i.service.Form {
value, exist := parsed[name]
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
if !exist {
continue
}
@ -155,17 +173,21 @@ func (i *T) parseUrlencoded(req http.Request) error {
}
for name, param := range i.service.Form {
value, exist := req.PostForm[name]
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
values, exist := req.PostForm[name]
if !exist {
continue
}
parsed := parseParameter(value)
var parsed interface{}
// consider element instead of slice or elements when only 1
if len(values) == 1 {
parsed = parseParameter(values[0])
} else { // consider slice
parsed = parseParameter(values)
}
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
@ -185,7 +207,7 @@ func (i *T) parseMultipart(req http.Request) error {
return nil
}
if err != nil {
return err
return fmt.Errorf("%s: %w", err, ErrInvalidMultipart)
}
err = mpr.Parse()
@ -196,10 +218,6 @@ func (i *T) parseMultipart(req http.Request) error {
for name, param := range i.service.Form {
component, exist := mpr.Data[name]
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
if !exist {
continue
}

View File

@ -10,7 +10,7 @@ import (
"strings"
"testing"
"git.xdrm.io/go/aicra/internal/config"
"github.com/xdrm-io/aicra/internal/config"
)
func getEmptyService() *config.Service {
@ -135,13 +135,11 @@ func TestStoreWithUri(t *testing.T) {
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
t.Fatalf("unexpected error <%s>", err)
}
if len(store.Data) != len(service.Input) {
@ -183,14 +181,14 @@ func TestExtractQuery(t *testing.T) {
Query: "a",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
ParamValues: [][]string{{""}},
},
{
ServiceParam: []string{"a"},
Query: "a&b",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
ParamValues: [][]string{{""}},
},
{
ServiceParam: []string{"a", "missing"},
@ -204,40 +202,40 @@ func TestExtractQuery(t *testing.T) {
Query: "a&b",
Err: nil,
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{""}},
ParamValues: [][]string{{""}, {""}},
},
{
ServiceParam: []string{"a"},
Err: nil,
Query: "a=",
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
ParamValues: [][]string{{""}},
},
{
ServiceParam: []string{"a", "b"},
Err: nil,
Query: "a=&b=x",
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{"x"}},
ParamValues: [][]string{{""}, {"x"}},
},
{
ServiceParam: []string{"a", "c"},
Err: nil,
Query: "a=b&c=d",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b"}, []string{"d"}},
ParamValues: [][]string{{"b"}, {"d"}},
},
{
ServiceParam: []string{"a", "c"},
Err: nil,
Query: "a=b&c=d&a=x",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}},
ParamValues: [][]string{{"b", "x"}, {"d"}},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
t.Run(fmt.Sprintf("request[%d]", i), func(t *testing.T) {
store := New(getServiceWithQuery(test.ServiceParam...))
@ -246,19 +244,16 @@ func TestExtractQuery(t *testing.T) {
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
t.Fatalf("unexpected error <%s>", err)
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Errorf("expected no GET parameters and got %d", len(store.Data))
t.FailNow()
t.Fatalf("expected no GET parameters and got %d", len(store.Data))
}
// no param to check
@ -266,8 +261,7 @@ func TestExtractQuery(t *testing.T) {
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
t.Fatalf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
}
for pi, pName := range test.ParamNames {
@ -276,29 +270,35 @@ func TestExtractQuery(t *testing.T) {
t.Run(pName, func(t *testing.T) {
param, isset := store.Data[pName]
if !isset {
t.Errorf("param does not exist")
t.FailNow()
t.Fatalf("param does not exist")
}
// single value, should return a single element
if len(values) == 1 {
cast, canCast := param.(string)
if !canCast {
t.Fatalf("should return a string (got '%v')", cast)
}
if values[0] != cast {
t.Fatalf("should return '%s' (got '%s')", values[0], cast)
}
return
}
// multiple values, should return a slice
cast, canCast := param.([]interface{})
if !canCast {
t.Errorf("should return a []string (got '%v')", cast)
t.FailNow()
t.Fatalf("should return a []string (got '%v')", cast)
}
if len(cast) != len(values) {
t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast))
t.FailNow()
t.Fatalf("should return %d string(s) (got '%d')", len(values), len(cast))
}
for vi, value := range values {
t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) {
if value != cast[vi] {
t.Errorf("should return '%s' (got '%s')", value, cast[vi])
t.FailNow()
t.Fatalf("should return '%s' (got '%s')", value, cast[vi])
}
})
}
})
@ -326,9 +326,7 @@ func TestStoreWithUrlEncodedFormParseError(t *testing.T) {
store := New(nil)
err := store.GetForm(*req)
if err == nil {
t.Errorf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data))
t.FailNow()
t.Fatalf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data))
}
}
func TestExtractFormUrlEncoded(t *testing.T) {
@ -359,14 +357,14 @@ func TestExtractFormUrlEncoded(t *testing.T) {
URLEncoded: "a",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
ParamValues: [][]string{{""}},
},
{
ServiceParams: []string{"a"},
URLEncoded: "a&b",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
ParamValues: [][]string{{""}},
},
{
ServiceParams: []string{"a", "missing"},
@ -380,35 +378,35 @@ func TestExtractFormUrlEncoded(t *testing.T) {
URLEncoded: "a&b",
Err: nil,
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{""}},
ParamValues: [][]string{{""}, {""}},
},
{
ServiceParams: []string{"a"},
Err: nil,
URLEncoded: "a=",
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
ParamValues: [][]string{{""}},
},
{
ServiceParams: []string{"a", "b"},
Err: nil,
URLEncoded: "a=&b=x",
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{"x"}},
ParamValues: [][]string{{""}, {"x"}},
},
{
ServiceParams: []string{"a", "c"},
Err: nil,
URLEncoded: "a=b&c=d",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b"}, []string{"d"}},
ParamValues: [][]string{{"b"}, {"d"}},
},
{
ServiceParams: []string{"a", "c"},
Err: nil,
URLEncoded: "a=b&c=d&a=x",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}},
ParamValues: [][]string{{"b", "x"}, {"d"}},
},
}
@ -424,19 +422,16 @@ func TestExtractFormUrlEncoded(t *testing.T) {
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
t.Fatalf("unexpected error <%s>", err)
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Errorf("expected no GET parameters and got %d", len(store.Data))
t.FailNow()
t.Fatalf("expected no GET parameters and got %d", len(store.Data))
}
// no param to check
@ -444,8 +439,7 @@ func TestExtractFormUrlEncoded(t *testing.T) {
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
t.Fatalf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
}
for pi, key := range test.ParamNames {
@ -454,29 +448,35 @@ func TestExtractFormUrlEncoded(t *testing.T) {
t.Run(key, func(t *testing.T) {
param, isset := store.Data[key]
if !isset {
t.Errorf("param does not exist")
t.FailNow()
t.Fatalf("param does not exist")
}
// single value, should return a single element
if len(values) == 1 {
cast, canCast := param.(string)
if !canCast {
t.Fatalf("should return a string (got '%v')", cast)
}
if values[0] != cast {
t.Fatalf("should return '%s' (got '%s')", values[0], cast)
}
return
}
// multiple values, should return a slice
cast, canCast := param.([]interface{})
if !canCast {
t.Errorf("should return a []interface{} (got '%v')", cast)
t.FailNow()
t.Fatalf("should return a []string (got '%v')", cast)
}
if len(cast) != len(values) {
t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast))
t.FailNow()
t.Fatalf("should return %d string(s) (got '%d')", len(values), len(cast))
}
for vi, value := range values {
t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) {
if value != cast[vi] {
t.Errorf("should return '%s' (got '%s')", value, cast[vi])
t.FailNow()
t.Fatalf("should return '%s' (got '%s')", value, cast[vi])
}
})
}
})
@ -567,19 +567,16 @@ func TestJsonParameters(t *testing.T) {
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
t.Fatalf("unexpected error <%s>", err)
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Errorf("expected no JSON parameters and got %d", len(store.Data))
t.FailNow()
t.Fatalf("expected no JSON parameters and got %d", len(store.Data))
}
// no param to check
@ -587,8 +584,7 @@ func TestJsonParameters(t *testing.T) {
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
t.Fatalf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
}
for pi, pName := range test.ParamNames {
@ -599,8 +595,7 @@ func TestJsonParameters(t *testing.T) {
param, isset := store.Data[key]
if !isset {
t.Errorf("store should contain element with key '%s'", key)
t.FailNow()
t.Fatalf("store should contain element with key '%s'", key)
return
}
@ -610,13 +605,11 @@ func TestJsonParameters(t *testing.T) {
paramValueType := reflect.TypeOf(param)
if valueType != paramValueType {
t.Errorf("should be of type %v (got '%v')", valueType, paramValueType)
t.FailNow()
t.Fatalf("should be of type %v (got '%v')", valueType, paramValueType)
}
if paramValue != value {
t.Errorf("should return %v (got '%v')", value, paramValue)
t.FailNow()
t.Fatalf("should return %v (got '%v')", value, paramValue)
}
})
@ -724,19 +717,16 @@ x
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
t.Fatalf("unexpected error <%s>", err)
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Errorf("expected no JSON parameters and got %d", len(store.Data))
t.FailNow()
t.Fatalf("expected no JSON parameters and got %d", len(store.Data))
}
// no param to check
@ -744,8 +734,7 @@ x
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
t.Fatalf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
}
for pi, key := range test.ParamNames {
@ -755,8 +744,7 @@ x
param, isset := store.Data[key]
if !isset {
t.Errorf("store should contain element with key '%s'", key)
t.FailNow()
t.Fatalf("store should contain element with key '%s'", key)
return
}
@ -766,13 +754,11 @@ x
paramValueType := reflect.TypeOf(param)
if valueType != paramValueType {
t.Errorf("should be of type %v (got '%v')", valueType, paramValueType)
t.FailNow()
t.Fatalf("should be of type %v (got '%v')", valueType, paramValueType)
}
if paramValue != value {
t.Errorf("should return %v (got '%v')", value, paramValue)
t.FailNow()
t.Fatalf("should return %v (got '%v')", value, paramValue)
}
})

60
response.go Normal file
View File

@ -0,0 +1,60 @@
package aicra
import (
"encoding/json"
"net/http"
"github.com/xdrm-io/aicra/api"
)
// response for an service call
type response struct {
Data map[string]interface{}
Status int
err api.Err
}
// newResponse creates an empty response.
func newResponse() *response {
return &response{
Status: http.StatusOK,
Data: make(map[string]interface{}),
err: api.ErrFailure,
}
}
// WithError sets the response error
func (r *response) WithError(err api.Err) *response {
r.err = err
return r
}
// WithValue sets a response value
func (r *response) WithValue(name string, value interface{}) *response {
r.Data[name] = value
return r
}
// MarshalJSON generates the JSON representation of the response
//
// implements json.Marshaler
func (r *response) MarshalJSON() ([]byte, error) {
fmt := make(map[string]interface{})
for k, v := range r.Data {
fmt[k] = v
}
fmt["error"] = r.err
return json.Marshal(fmt)
}
// ServeHTTP writes the response representation back to the http.ResponseWriter
//
// implements http.Handler
func (res *response) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(res.err.Status)
encoded, err := json.Marshal(res)
if err == nil {
w.Write(encoded)
}
return err
}

95
response_test.go Normal file
View File

@ -0,0 +1,95 @@
package aicra
import (
"encoding/json"
"strings"
"testing"
"github.com/xdrm-io/aicra/api"
)
func printEscaped(raw string) string {
raw = strings.ReplaceAll(raw, "\n", "\\n")
raw = strings.ReplaceAll(raw, "\r", "\\r")
return raw
}
func TestResponseJSON(t *testing.T) {
t.Parallel()
tt := []struct {
name string
err api.Err
data map[string]interface{}
json string
}{
{
name: "empty success response",
err: api.ErrSuccess,
data: map[string]interface{}{},
json: `{"error":{"code":0,"reason":"all right"}}`,
},
{
name: "empty failure response",
err: api.ErrFailure,
data: map[string]interface{}{},
json: `{"error":{"code":1,"reason":"it failed"}}`,
},
{
name: "empty unknown error response",
err: api.ErrUnknown,
data: map[string]interface{}{},
json: `{"error":{"code":-1,"reason":"unknown error"}}`,
},
{
name: "success with data before err",
err: api.ErrSuccess,
data: map[string]interface{}{"a": 12},
json: `{"a":12,"error":{"code":0,"reason":"all right"}}`,
},
{
name: "success with data right before err",
err: api.ErrSuccess,
data: map[string]interface{}{"e": 12},
json: `{"e":12,"error":{"code":0,"reason":"all right"}}`,
},
{
name: "success with data right after err",
err: api.ErrSuccess,
data: map[string]interface{}{"f": 12},
json: `{"error":{"code":0,"reason":"all right"},"f":12}`,
},
{
name: "success with data after err",
err: api.ErrSuccess,
data: map[string]interface{}{"z": 12},
json: `{"error":{"code":0,"reason":"all right"},"z":12}`,
},
{
name: "success with data around err",
err: api.ErrSuccess,
data: map[string]interface{}{"d": "before", "f": "after"},
json: `{"d":"before","error":{"code":0,"reason":"all right"},"f":"after"}`,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
res := newResponse().WithError(tc.err)
for k, v := range tc.data {
res.WithValue(k, v)
}
raw, err := json.Marshal(res)
if err != nil {
t.Fatalf("cannot marshal to json: %s", err)
}
if string(raw) != tc.json {
t.Fatalf("mismatching json:\nexpect: %v\nactual: %v", printEscaped(tc.json), printEscaped(string(raw)))
}
})
}
}

24
validator/any.go Normal file
View File

@ -0,0 +1,24 @@
package validator
import (
"reflect"
)
// AnyType makes the "any" type available in the aicra configuration
// It considers valid any value
type AnyType struct{}
// GoType returns the interface{} type
func (AnyType) GoType() reflect.Type {
return reflect.TypeOf(interface{}(nil))
}
// Validator that considers any value valid
func (AnyType) Validator(typename string, avail ...Type) ValidateFunc {
if typename != "any" {
return nil
}
return func(value interface{}) (interface{}, bool) {
return value, true
}
}

View File

@ -1,16 +1,29 @@
package builtin_test
package validator_test
import (
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra/validator"
)
func TestAny_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.AnyType{}
expected = reflect.TypeOf(interface{}(nil))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestAny_AvailableTypes(t *testing.T) {
t.Parallel()
dt := builtin.AnyDataType{}
dt := validator.AnyType{}
tests := []struct {
Type string
@ -26,7 +39,7 @@ func TestAny_AvailableTypes(t *testing.T) {
}
for _, test := range tests {
validator := dt.Build(test.Type)
validator := dt.Validator(test.Type)
if validator == nil {
if test.Handled {
@ -47,7 +60,7 @@ func TestAny_AlwaysTrue(t *testing.T) {
const typeName = "any"
validator := builtin.AnyDataType{}.Build(typeName)
validator := validator.AnyType{}.Validator(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()

View File

@ -1,23 +1,24 @@
package builtin
package validator
import (
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// BoolDataType is what its name tells
type BoolDataType struct{}
// BoolType makes the "bool" type available in the aicra configuration
// It considers valid:
// - booleans
// - strings containing "true" or "false"
// - []byte containing "true" or "false"
type BoolType struct{}
// Type returns the type of data
func (BoolDataType) Type() reflect.Type {
// GoType returns the `bool` type
func (BoolType) GoType() reflect.Type {
return reflect.TypeOf(true)
}
// Build returns the validator
func (BoolDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "bool" {
// Validator for bool values
func (BoolType) Validator(typename string, avail ...Type) ValidateFunc {
if typename != "bool" {
return nil
}

View File

@ -1,16 +1,29 @@
package builtin_test
package validator_test
import (
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra/validator"
)
func TestBool_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.BoolType{}
expected = reflect.TypeOf(true)
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestBool_AvailableTypes(t *testing.T) {
t.Parallel()
dt := builtin.BoolDataType{}
dt := validator.BoolType{}
tests := []struct {
Type string
@ -26,7 +39,7 @@ func TestBool_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Build(test.Type)
validator := dt.Validator(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
@ -49,7 +62,7 @@ func TestBool_Values(t *testing.T) {
const typeName = "bool"
validator := builtin.BoolDataType{}.Build(typeName)
validator := validator.BoolType{}.Validator(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()

View File

@ -1,24 +1,27 @@
package builtin
package validator
import (
"encoding/json"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// FloatDataType is what its name tells
type FloatDataType struct{}
// FloatType makes the "float" (or "float64") type available in the aicra configuration
// It considers valid:
// - float64
// - int (since it does not overflow)
// - uint (since it does not overflow)
// - strings containing json-compatible floats
// - []byte containing json-compatible floats
type FloatType struct{}
// Type returns the type of data
func (FloatDataType) Type() reflect.Type {
// GoType returns the `float64` type
func (FloatType) GoType() reflect.Type {
return reflect.TypeOf(float64(0))
}
// Build returns the validator
func (FloatDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "float64" && typeName != "float" {
// Validator for float64 values
func (FloatType) Validator(typename string, avail ...Type) ValidateFunc {
if typename != "float64" && typename != "float" {
return nil
}
return func(value interface{}) (interface{}, bool) {

View File

@ -1,17 +1,30 @@
package builtin_test
package validator_test
import (
"fmt"
"math"
"reflect"
"testing"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra/validator"
)
func TestFloat64_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.FloatType{}
expected = reflect.TypeOf(float64(0.0))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestFloat64_AvailableTypes(t *testing.T) {
t.Parallel()
dt := builtin.FloatDataType{}
dt := validator.FloatType{}
tests := []struct {
Type string
@ -33,7 +46,7 @@ func TestFloat64_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Build(test.Type)
validator := dt.Validator(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
@ -56,7 +69,7 @@ func TestFloat64_Values(t *testing.T) {
const typeName = "float"
validator := builtin.FloatDataType{}.Build(typeName)
validator := validator.FloatType{}.Validator(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()

View File

@ -1,25 +1,29 @@
package builtin
package validator
import (
"encoding/json"
"math"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// IntDataType is what its name tells
type IntDataType struct{}
// IntType makes the "int" type available in the aicra configuration
// It considers valid:
// - int
// - float64 (since it does not overflow)
// - uint (since it does not overflow)
// - strings containing json-compatible integers
// - []byte containing json-compatible integers
type IntType struct{}
// Type returns the type of data
func (IntDataType) Type() reflect.Type {
// GoType returns the `int` type
func (IntType) GoType() reflect.Type {
return reflect.TypeOf(int(0))
}
// Build returns the validator
func (IntDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// Validator for int values
func (IntType) Validator(typename string, avail ...Type) ValidateFunc {
// nothing if type not handled
if typeName != "int" {
if typename != "int" {
return nil
}

View File

@ -1,17 +1,30 @@
package builtin_test
package validator_test
import (
"fmt"
"math"
"reflect"
"testing"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra/validator"
)
func TestInt_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.IntType{}
expected = reflect.TypeOf(int(0))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestInt_AvailableTypes(t *testing.T) {
t.Parallel()
dt := builtin.IntDataType{}
dt := validator.IntType{}
tests := []struct {
Type string
@ -27,7 +40,7 @@ func TestInt_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Build(test.Type)
validator := dt.Validator(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
@ -50,7 +63,7 @@ func TestInt_Values(t *testing.T) {
const typeName = "int"
validator := builtin.IntDataType{}.Build(typeName)
validator := validator.IntType{}.Validator(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
@ -71,7 +84,7 @@ func TestInt_Values(t *testing.T) {
{uint(math.MaxInt64 + 1), false},
{float64(math.MinInt64), true},
// we cannot just substract 1 because of how precision works
// we cannot just subtract 1 because of how precision works
{float64(math.MinInt64 - 1024 - 1), false},
// WARNING : this is due to how floats are compared

View File

@ -1,32 +1,37 @@
package builtin
package validator
import (
"reflect"
"regexp"
"strconv"
"git.xdrm.io/go/aicra/datatype"
)
var fixedLengthRegex = regexp.MustCompile(`^string\((\d+)\)$`)
var variableLengthRegex = regexp.MustCompile(`^string\((\d+), ?(\d+)\)$`)
var (
fixedLengthRegex = regexp.MustCompile(`^string\((\d+)\)$`)
variableLengthRegex = regexp.MustCompile(`^string\((\d+), ?(\d+)\)$`)
)
// StringDataType is what its name tells
type StringDataType struct{}
// StringType makes the types beloz available in the aicra configuration:
// - "string" considers any string valid
// - "string(n)" considers any string with an exact size of `n` valid
// - "string(a,b)" considers any string with a size between `a` and `b` valid
// > for the last one, `a` and `b` are included in the valid sizes
type StringType struct{}
// Type returns the type of data
func (StringDataType) Type() reflect.Type {
// GoType returns the `string` type
func (StringType) GoType() reflect.Type {
return reflect.TypeOf(string(""))
}
// Build returns the validator.
// availables type names are : `string`, `string(length)` and `string(minLength, maxLength)`.
func (s StringDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
simple := typeName == "string"
fixedLengthMatches := fixedLengthRegex.FindStringSubmatch(typeName)
variableLengthMatches := variableLengthRegex.FindStringSubmatch(typeName)
// Validator for strings with any/fixed/bound sizes
func (s StringType) Validator(typename string, avail ...Type) ValidateFunc {
var (
simple = (typename == "string")
fixedLengthMatches = fixedLengthRegex.FindStringSubmatch(typename)
variableLengthMatches = variableLengthRegex.FindStringSubmatch(typename)
)
// nothing if type not handled
// ignore unknown typename
if !simple && fixedLengthMatches == nil && variableLengthMatches == nil {
return nil
}
@ -40,7 +45,7 @@ func (s StringDataType) Build(typeName string, registry ...datatype.T) datatype.
if fixedLengthMatches != nil {
exLen, ok := s.getFixedLength(fixedLengthMatches)
if !ok {
mustFail = true
return nil
}
min = exLen
max = exLen
@ -49,7 +54,7 @@ func (s StringDataType) Build(typeName string, registry ...datatype.T) datatype.
} else if variableLengthMatches != nil {
exMin, exMax, ok := s.getVariableLength(variableLengthMatches)
if !ok {
mustFail = true
return nil
}
min = exMin
max = exMax
@ -84,7 +89,7 @@ func (s StringDataType) Build(typeName string, registry ...datatype.T) datatype.
}
// getFixedLength returns the fixed length from regex matches and a success state.
func (StringDataType) getFixedLength(regexMatches []string) (int, bool) {
func (StringType) getFixedLength(regexMatches []string) (int, bool) {
// incoherence error
if regexMatches == nil || len(regexMatches) < 2 {
return 0, false
@ -100,7 +105,7 @@ func (StringDataType) getFixedLength(regexMatches []string) (int, bool) {
}
// getVariableLength returns the length min and max from regex matches and a success state.
func (StringDataType) getVariableLength(regexMatches []string) (int, int, bool) {
func (StringType) getVariableLength(regexMatches []string) (int, int, bool) {
// incoherence error
if regexMatches == nil || len(regexMatches) < 3 {
return 0, 0, false

View File

@ -1,16 +1,29 @@
package builtin_test
package validator_test
import (
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra/validator"
)
func TestString_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.StringType{}
expected = reflect.TypeOf(string("abc"))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestString_AvailableTypes(t *testing.T) {
t.Parallel()
dt := builtin.StringDataType{}
dt := validator.StringType{}
tests := []struct {
Type string
@ -53,7 +66,7 @@ func TestString_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Build(test.Type)
validator := dt.Validator(test.Type)
if validator == nil {
if test.Handled {
@ -75,7 +88,7 @@ func TestString_AnyLength(t *testing.T) {
const typeName = "string"
validator := builtin.StringDataType{}.Build(typeName)
validator := validator.StringType{}.Validator(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
@ -133,7 +146,7 @@ func TestString_FixedLength(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
validator := builtin.StringDataType{}.Build(test.Type)
validator := validator.StringType{}.Validator(test.Type)
if validator == nil {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()
@ -194,7 +207,7 @@ func TestString_VariableLength(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
validator := builtin.StringDataType{}.Build(test.Type)
validator := validator.StringType{}.Validator(test.Type)
if validator == nil {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()

View File

@ -1,25 +1,28 @@
package builtin
package validator
import (
"encoding/json"
"math"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// UintDataType is what its name tells
type UintDataType struct{}
// UintType makes the "uint" type available in the aicra configuration
// It considers valid:
// - uint
// - int (since it does not overflow)
// - float64 (since it does not overflow)
// - strings containing json-compatible integers
// - []byte containing json-compatible integers
type UintType struct{}
// Type returns the type of data
func (UintDataType) Type() reflect.Type {
// GoType returns the `uint` type
func (UintType) GoType() reflect.Type {
return reflect.TypeOf(uint(0))
}
// Build returns the validator
func (UintDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "uint" {
// Validator for uint values
func (UintType) Validator(other string, avail ...Type) ValidateFunc {
if other != "uint" {
return nil
}

View File

@ -1,17 +1,30 @@
package builtin_test
package validator_test
import (
"fmt"
"math"
"reflect"
"testing"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra/validator"
)
func TestUint_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.UintType{}
expected = reflect.TypeOf(uint(0))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestUint_AvailableTypes(t *testing.T) {
t.Parallel()
dt := builtin.UintDataType{}
dt := validator.UintType{}
tests := []struct {
Type string
@ -27,7 +40,7 @@ func TestUint_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Build(test.Type)
validator := dt.Validator(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
@ -50,7 +63,7 @@ func TestUint_Values(t *testing.T) {
const typeName = "uint"
validator := builtin.UintDataType{}.Build(typeName)
validator := validator.UintType{}.Validator(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()

55
validator/validator.go Normal file
View File

@ -0,0 +1,55 @@
package validator
import (
"reflect"
)
// ValidateFunc returns whether a given value fulfills the datatype and casts
// the value into a go type.
//
// for example, if a validator checks for upper case strings, whether the value
// is a []byte, a string or a []rune, if the value matches is all upper-case, it
// will be cast into a go type, say, string.
type ValidateFunc func(value interface{}) (cast interface{}, valid bool)
// Type defines an available in/out parameter "type" for the aicra configuration
//
// A Type maps to a go type in order to generate the handler signature from the
// aicra configuration
//
// A Type returns a custom validator when the typename matches
type Type interface {
// Validator function when the typename matches. It must return nil when the
// typename does not match
//
// The `typename` argument has to match types used in your aicra configuration
// in parameter definitions ("in", "out") and in the "type" json field.
//
// basic example:
// - `IntType.Validator("string")`` should return nil
// - `IntType.Validator("int")`` should return its ValidateFunc
//
// The `typename` is not returned by a simple method i.e. `TypeName() string`
// because it allows for validation relative to the typename, for instance:
// - `VarcharType.Validator("varchar")` valides any string
// - `VarcharType.Validator("varchar(2)")` validates any string of 2
// characters
// - `VarcharType.Validator("varchar(1,3)")` validates any string
// with a length between 1 and 3
//
// The `avail` argument represents all other available Types. It allows a
// Type to use other available Types internally.
//
// recursive example: slices
// - `SliceType.Validator("[]int", avail...)` validates a slice containing
// values that are valide to the `IntType`
// - `SliceType.Validator("[]varchar", avail...)` validates a slice containing
// values that are valid to the `VarcharType`
//
// and so on.. this works for maps, structs, etc
Validator(typename string, avail ...Type) ValidateFunc
// GoType must return the go type associated with the output type of ValidateFunc.
// It is used to define handlers' signature from the configuration file.
GoType() reflect.Type
}