Compare commits

...

20 Commits

Author SHA1 Message Date
Adrien Marquès 6319761731 Merge branch 'test/dynamic' of go/aicra into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
test dynfunc package; standardize and refactor api
2020-04-04 10:09:19 +00:00
Adrien Marquès 92da498d49
remove server logs and util file
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-04-04 12:06:31 +02:00
Adrien Marquès 60ef4717a8
clarity: aicra server request management 2020-04-04 12:05:17 +02:00
Adrien Marquès 5cc3d2d455
use http.Request instead of pointer 2020-04-04 12:03:29 +02:00
Adrien Marquès c5cdba8007
move aicra builder and server into their own files 2020-04-04 11:50:01 +02:00
Adrien Marquès 09362aad83
make 'dynfunc' internal 2020-04-04 11:49:33 +02:00
Adrien Marquès d69dd2508c
refactor aicra: meaningful defaults, stage renaming Builder.Build() -> Server 2020-04-04 11:46:37 +02:00
Adrien Marquès 1e0fb77d61
standardize and simplify the config package 2020-04-04 11:45:49 +02:00
Adrien Marquès b0e25b431c
ToHTTPServer now returns the exported field http.Handler instead of an unexported type 2020-04-04 10:39:02 +02:00
Adrien Marquès b1498e59c1
clarity rename: dynamic package to dynfunc 2020-04-04 10:36:52 +02:00
Adrien Marquès eb690cf862
add api errors for storage 2020-04-04 10:10:24 +02:00
Adrien Marquès e1606273dd
remove useless func type 2020-04-04 10:10:24 +02:00
Adrien Marquès 8fa18cd61b
enforce dynamic signature check: no input struct allowed when no input is specified 2020-04-04 10:02:48 +02:00
Adrien Marquès db4429b329
ignore empty param renames when creating the spec, not after 2020-03-29 19:33:26 +02:00
Adrien Marquès b48c1d07bf
test: spec add checkOutput() tests for : nil type (ignore type check) ; invalid last output (not api.Error)
continuous-integration/drone/push Build is passing Details
2020-03-29 19:31:08 +02:00
Adrien Marquès 307021bc88
test: spec checkOutput() method
continuous-integration/drone/push Build is passing Details
2020-03-29 19:23:13 +02:00
Adrien Marquès 261e25c127
fix: invert conversion check 2020-03-29 19:23:02 +02:00
Adrien Marquès 438e308f71
merge duplicate errors 2020-03-29 19:22:43 +02:00
Adrien Marquès 7e42c1b6d9
test: spec checkInput() method 2020-03-29 19:14:12 +02:00
Adrien Marquès 66985dfbd0
forbid unexported input/output name 2020-03-29 19:13:07 +02:00
20 changed files with 588 additions and 381 deletions

View File

@ -22,6 +22,18 @@ var (
// ErrorConfig has to be set when there is a configuration error // ErrorConfig has to be set when there is a configuration error
ErrorConfig Error = 4 ErrorConfig Error = 4
// ErrorCreation has to be set when there is a creation/insert error
ErrorCreation Error = 5
// ErrorModification has to be set when there is an update/modification error
ErrorModification Error = 6
// ErrorDeletion has to be set when there is a deletion/removal error
ErrorDeletion Error = 7
// ErrorTransaction has to be set when there is a transactional error
ErrorTransaction Error = 8
// ErrorUpload has to be set when a file upload failed // ErrorUpload has to be set when a file upload failed
ErrorUpload Error = 100 ErrorUpload Error = 100
@ -79,6 +91,10 @@ var errorReasons = map[Error]string{
ErrorNoMatchFound: "resource not found", ErrorNoMatchFound: "resource not found",
ErrorAlreadyExists: "already exists", ErrorAlreadyExists: "already exists",
ErrorConfig: "configuration error", ErrorConfig: "configuration error",
ErrorCreation: "create error",
ErrorModification: "update error",
ErrorDeletion: "delete error",
ErrorTransaction: "transactional error",
ErrorUpload: "upload failed", ErrorUpload: "upload failed",
ErrorDownload: "download failed", ErrorDownload: "download failed",
MissingDownloadHeaders: "download headers are missing", MissingDownloadHeaders: "download headers are missing",

View File

@ -22,21 +22,16 @@ type Request struct {
} }
// NewRequest builds an interface request from a http.Request // NewRequest builds an interface request from a http.Request
func NewRequest(req *http.Request) (*Request, error) { func NewRequest(req *http.Request) *Request {
// 1. get useful data
uri := normaliseURI(req.URL.Path) uri := normaliseURI(req.URL.Path)
uriparts := strings.Split(uri, "/") uriparts := strings.Split(uri, "/")
// 3. Init request return &Request{
inst := &Request{
URI: uriparts, URI: uriparts,
Scope: nil, Scope: nil,
Request: req, Request: req,
Param: make(RequestParam), Param: make(RequestParam),
} }
return inst, nil
} }
// normaliseURI removes the trailing '/' to always // normaliseURI removes the trailing '/' to always

99
builder.go Normal file
View File

@ -0,0 +1,99 @@
package aicra
import (
"fmt"
"io"
"net/http"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/dynfunc"
)
// Builder for an aicra server
type Builder struct {
conf *config.Server
handlers []*apiHandler
}
// represents an server handler
type apiHandler struct {
Method string
Path string
dyn *dynfunc.Handler
}
// AddType adds an available datatype to the api definition
func (b *Builder) AddType(t datatype.T) {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
panic(ErrLateType)
}
b.conf.Types = append(b.conf.Types, t)
}
// Setup the builder with its api definition
// panics if already setup
func (b *Builder) Setup(r io.Reader) error {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
panic(ErrAlreadySetup)
}
return b.conf.Parse(r)
}
// Bind a dynamic handler to a REST service
func (b *Builder) Bind(method, path string, fn interface{}) error {
if b.conf.Services == nil {
return ErrNotSetup
}
// find associated service
var service *config.Service
for _, s := range b.conf.Services {
if method == s.Method && path == s.Pattern {
service = s
break
}
}
if service == nil {
return fmt.Errorf("%s '%s': %w", method, path, ErrUnknownService)
}
dyn, err := dynfunc.Build(fn, *service)
if err != nil {
return fmt.Errorf("%s '%s' handler: %w", method, path, err)
}
b.handlers = append(b.handlers, &apiHandler{
Path: path,
Method: method,
dyn: dyn,
})
return nil
}
// Build a fully-featured HTTP server
func (b Builder) Build() (http.Handler, error) {
for _, service := range b.conf.Services {
var hasAssociatedHandler bool
for _, handler := range b.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
hasAssociatedHandler = true
break
}
}
if !hasAssociatedHandler {
return nil, fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, ErrMissingHandler)
}
}
return Server(b), nil
}

View File

@ -8,8 +8,17 @@ func (err cerr) Error() string {
return string(err) return string(err)
} }
// ErrNoServiceForHandler - no service matching this handler // ErrLateType - cannot add datatype after setting up the definition
const ErrNoServiceForHandler = cerr("no service found for this handler") const ErrLateType = cerr("types cannot be added after Setup")
// ErrNoHandlerForService - no handler matching this service // ErrNotSetup - not set up yet
const ErrNoHandlerForService = cerr("no handler found for this service") const ErrNotSetup = cerr("not set up")
// ErrAlreadySetup - already set up
const ErrAlreadySetup = cerr("already set up")
// ErrUnknownService - no service matching this handler
const ErrUnknownService = cerr("unknown service")
// ErrMissingHandler - missing handler
const ErrMissingHandler = cerr("missing handler")

View File

@ -1,32 +0,0 @@
package aicra
import (
"fmt"
"strings"
"git.xdrm.io/go/aicra/dynamic"
"git.xdrm.io/go/aicra/internal/config"
)
type handler struct {
Method string
Path string
dynHandler *dynamic.Handler
}
// createHandler builds a handler from its http method and path
// also it checks whether the function signature is valid
func createHandler(method, path string, service config.Service, fn dynamic.HandlerFn) (*handler, error) {
method = strings.ToUpper(method)
dynHandler, err := dynamic.Build(fn, service)
if err != nil {
return nil, fmt.Errorf("%s '%s' handler: %w", method, path, err)
}
return &handler{
Path: path,
Method: method,
dynHandler: dynHandler,
}, nil
}

116
http.go
View File

@ -1,116 +0,0 @@
package aicra
import (
"log"
"net/http"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/reqdata"
)
// httpServer wraps the aicra server to allow handling http requests
type httpServer Server
// ServeHTTP implements http.Handler and has to be called on each request
func (server httpServer) ServeHTTP(res http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
// 1. find a matching service in the config
service := server.config.Find(req)
if service == nil {
response := api.EmptyResponse().WithError(api.ErrorUnknownService)
response.ServeHTTP(res, req)
logError(response)
return
}
// 2. build input parameter receiver
dataset := reqdata.New(service)
// 3. extract URI data
err := dataset.ExtractURI(req)
if err != nil {
response := api.EmptyResponse().WithError(api.ErrorMissingParam)
response.ServeHTTP(res, req)
logError(response)
return
}
// 4. extract query data
err = dataset.ExtractQuery(req)
if err != nil {
response := api.EmptyResponse().WithError(api.ErrorMissingParam)
response.ServeHTTP(res, req)
logError(response)
return
}
// 5. extract form/json data
err = dataset.ExtractForm(req)
if err != nil {
response := api.EmptyResponse().WithError(api.ErrorMissingParam)
response.ServeHTTP(res, req)
logError(response)
return
}
// 6. find a matching handler
var foundHandler *handler
var found bool
for _, handler := range server.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
foundHandler = handler
found = true
}
}
// 7. fail if found no handler
if foundHandler == nil {
if found {
r := api.EmptyResponse().WithError(api.ErrorUncallableService)
r.ServeHTTP(res, req)
logError(r)
return
}
r := api.EmptyResponse().WithError(api.ErrorUnknownService)
r.ServeHTTP(res, req)
logError(r)
return
}
// 8. build api.Request from http.Request
apireq, err := api.NewRequest(req)
if err != nil {
log.Fatal(err)
}
// 9. feed request with scope & parameters
apireq.Scope = service.Scope
apireq.Param = dataset.Data
// 10. execute
returned, apiErr := foundHandler.dynHandler.Handle(dataset.Data)
response := api.EmptyResponse().WithError(apiErr)
for key, value := range returned {
// find original name from rename
for name, param := range service.Output {
if param.Rename == key {
response.SetData(name, value)
}
}
}
// 11. apply headers
res.Header().Set("Content-Type", "application/json; charset=utf-8")
for key, values := range response.Headers {
for _, value := range values {
res.Header().Add(key, value)
}
}
// 12. write to response
response.ServeHTTP(res, req)
}

View File

@ -80,7 +80,8 @@ func TestLegalServiceName(t *testing.T) {
for i, test := range tests { for i, test := range tests {
t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) { t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Raw)) srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
if err == nil && test.Error != nil { if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error()) t.Errorf("expected an error: '%s'", test.Error.Error())
@ -134,7 +135,8 @@ func TestAvailableMethods(t *testing.T) {
for i, test := range tests { for i, test := range tests {
t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) { t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Raw)) srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
if test.ValidMethod && err != nil { if test.ValidMethod && err != nil {
t.Errorf("unexpected error: '%s'", err.Error()) t.Errorf("unexpected error: '%s'", err.Error())
@ -150,20 +152,22 @@ func TestAvailableMethods(t *testing.T) {
} }
func TestParseEmpty(t *testing.T) { func TestParseEmpty(t *testing.T) {
t.Parallel() t.Parallel()
reader := strings.NewReader(`[]`) r := strings.NewReader(`[]`)
_, err := Parse(reader) srv := &Server{}
err := srv.Parse(r)
if err != nil { if err != nil {
t.Errorf("unexpected error (got '%s')", err) t.Errorf("unexpected error (got '%s')", err)
t.FailNow() t.FailNow()
} }
} }
func TestParseJsonError(t *testing.T) { func TestParseJsonError(t *testing.T) {
reader := strings.NewReader(`{ r := strings.NewReader(`{
"GET": { "GET": {
"info": "info "info": "info
}, },
}`) // trailing ',' is invalid JSON }`) // trailing ',' is invalid JSON
_, err := Parse(reader) srv := &Server{}
err := srv.Parse(r)
if err == nil { if err == nil {
t.Errorf("expected error") t.Errorf("expected error")
t.FailNow() t.FailNow()
@ -205,7 +209,8 @@ func TestParseMissingMethodDescription(t *testing.T) {
for i, test := range tests { for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Raw)) srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
if test.ValidDescription && err != nil { if test.ValidDescription && err != nil {
t.Errorf("unexpected error: '%s'", err) t.Errorf("unexpected error: '%s'", err)
@ -223,7 +228,7 @@ func TestParseMissingMethodDescription(t *testing.T) {
func TestParamEmptyRenameNoRename(t *testing.T) { func TestParamEmptyRenameNoRename(t *testing.T) {
t.Parallel() t.Parallel()
reader := strings.NewReader(`[ r := strings.NewReader(`[
{ {
"method": "GET", "method": "GET",
"path": "/", "path": "/",
@ -233,7 +238,9 @@ func TestParamEmptyRenameNoRename(t *testing.T) {
} }
} }
]`) ]`)
srv, err := Parse(reader, builtin.AnyDataType{}) srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
err := srv.Parse(r)
if err != nil { if err != nil {
t.Errorf("unexpected error: '%s'", err) t.Errorf("unexpected error: '%s'", err)
t.FailNow() t.FailNow()
@ -254,7 +261,7 @@ func TestParamEmptyRenameNoRename(t *testing.T) {
} }
func TestOptionalParam(t *testing.T) { func TestOptionalParam(t *testing.T) {
t.Parallel() t.Parallel()
reader := strings.NewReader(`[ r := strings.NewReader(`[
{ {
"method": "GET", "method": "GET",
"path": "/", "path": "/",
@ -267,7 +274,10 @@ func TestOptionalParam(t *testing.T) {
} }
} }
]`) ]`)
srv, err := Parse(reader, builtin.AnyDataType{}, builtin.BoolDataType{}) srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
err := srv.Parse(r)
if err != nil { if err != nil {
t.Errorf("unexpected error: '%s'", err) t.Errorf("unexpected error: '%s'", err)
t.FailNow() t.FailNow()
@ -577,7 +587,9 @@ func TestParseParameters(t *testing.T) {
for i, test := range tests { for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Raw), builtin.AnyDataType{}) srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
err := srv.Parse(strings.NewReader(test.Raw))
if err == nil && test.Error != nil { if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error()) t.Errorf("expected an error: '%s'", test.Error.Error())
@ -814,7 +826,10 @@ func TestServiceCollision(t *testing.T) {
for i, test := range tests { for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Config), builtin.StringDataType{}, builtin.UintDataType{}) srv := &Server{}
srv.Types = append(srv.Types, builtin.StringDataType{})
srv.Types = append(srv.Types, builtin.UintDataType{})
err := srv.Parse(strings.NewReader(test.Config))
if err == nil && test.Error != nil { if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error()) t.Errorf("expected an error: '%s'", test.Error.Error())
@ -951,7 +966,11 @@ func TestMatchSimple(t *testing.T) {
for i, test := range tests { for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv, err := Parse(strings.NewReader(test.Config), builtin.AnyDataType{}, builtin.IntDataType{}, builtin.BoolDataType{}) srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.IntDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
err := srv.Parse(strings.NewReader(test.Config))
if err != nil { if err != nil {
t.Errorf("unexpected error: '%s'", err) t.Errorf("unexpected error: '%s'", err)

View File

@ -1,11 +1,26 @@
package config package config
import ( import (
"reflect"
"git.xdrm.io/go/aicra/datatype" "git.xdrm.io/go/aicra/datatype"
) )
// Validate implements the validator interface // Parameter represents a parameter definition (from api.json)
func (param *Parameter) Validate(datatypes ...datatype.T) error { type Parameter struct {
Description string `json:"info"`
Type string `json:"type"`
Rename string `json:"name,omitempty"`
// ExtractType is the type of data the datatype returns
ExtractType reflect.Type
// Optional is set to true when the type is prefixed with '?'
Optional bool
// Validator is inferred from @Type
Validator datatype.Validator
}
func (param *Parameter) validate(datatypes ...datatype.T) error {
// missing description // missing description
if len(param.Description) < 1 { if len(param.Description) < 1 {
return ErrMissingParamDesc return ErrMissingParamDesc

View File

@ -9,34 +9,30 @@ import (
"git.xdrm.io/go/aicra/datatype" "git.xdrm.io/go/aicra/datatype"
) )
// Parse builds a server configuration from a json reader and checks for most format errors. // Server definition
// you can provide additional DataTypes as variadic arguments type Server struct {
func Parse(r io.Reader, dtypes ...datatype.T) (*Server, error) { Types []datatype.T
server := &Server{ Services []*Service
Types: make([]datatype.T, 0),
Services: make([]*Service, 0),
}
// add data types
for _, dtype := range dtypes {
server.Types = append(server.Types, dtype)
}
if err := json.NewDecoder(r).Decode(&server.Services); err != nil {
return nil, fmt.Errorf("%s: %w", ErrRead, err)
}
if err := server.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", ErrFormat, err)
}
return server, nil
} }
// Validate implements the validator interface // Parse a reader into a server. Server.Types must be set beforehand to
func (server Server) Validate(datatypes ...datatype.T) error { // make datatypes available when checking and formatting the read configuration.
func (srv *Server) Parse(r io.Reader) error {
if err := json.NewDecoder(r).Decode(&srv.Services); err != nil {
return fmt.Errorf("%s: %w", ErrRead, err)
}
if err := srv.validate(); err != nil {
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 { for _, service := range server.Services {
err := service.Validate(server.Types...) err := service.validate(server.Types...)
if err != nil { if err != nil {
return fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, err) return fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, err)
} }

View File

@ -11,6 +11,35 @@ import (
var braceRegex = regexp.MustCompile(`^{([a-z_-]+)}$`) var braceRegex = regexp.MustCompile(`^{([a-z_-]+)}$`)
var queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`) var queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`)
var availableHTTPMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
// Service definition
type Service struct {
Method string `json:"method"`
Pattern string `json:"path"`
Scope [][]string `json:"scope"`
Description string `json:"info"`
Input map[string]*Parameter `json:"in"`
Output map[string]*Parameter `json:"out"`
// references to url parameters
// format: '/uri/{param}'
Captures []*BraceCapture
// references to Query parameters
// format: 'GET@paranName'
Query map[string]*Parameter
// references for form parameters (all but Captures and Query)
Form map[string]*Parameter
}
// BraceCapture links to the related URI parameter
type BraceCapture struct {
Name string
Index int
Ref *Parameter
}
// Match returns if this service would handle this HTTP request // Match returns if this service would handle this HTTP request
func (svc *Service) Match(req *http.Request) bool { func (svc *Service) Match(req *http.Request) bool {
@ -24,9 +53,6 @@ func (svc *Service) Match(req *http.Request) bool {
return false return false
} }
// check and extract input
// todo: check if input match and extract models
return true return true
} }
@ -76,7 +102,7 @@ func (svc *Service) matchPattern(uri string) bool {
} }
// Validate implements the validator interface // Validate implements the validator interface
func (svc *Service) Validate(datatypes ...datatype.T) error { func (svc *Service) validate(datatypes ...datatype.T) error {
// check method // check method
err := svc.isMethodAvailable() err := svc.isMethodAvailable()
if err != nil { if err != nil {
@ -233,7 +259,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
param.Rename = paramName param.Rename = paramName
} }
err := param.Validate(types...) err := param.validate(types...)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", paramName, err) return fmt.Errorf("%s: %w", paramName, err)
} }
@ -283,7 +309,7 @@ func (svc *Service) validateOutput(types []datatype.T) error {
param.Rename = paramName param.Rename = paramName
} }
err := param.Validate(types...) err := param.validate(types...)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", paramName, err) return fmt.Errorf("%s: %w", paramName, err)
} }

View File

@ -1,63 +0,0 @@
package config
import (
"net/http"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
var availableHTTPMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
// validator unifies the check and format routine
type validator interface {
Validate(...datatype.T) error
}
// Server represents a full server configuration
type Server struct {
Types []datatype.T
Services []*Service
}
// Service represents a service definition (from api.json)
type Service struct {
Method string `json:"method"`
Pattern string `json:"path"`
Scope [][]string `json:"scope"`
Description string `json:"info"`
Input map[string]*Parameter `json:"in"`
Output map[string]*Parameter `json:"out"`
// references to url parameters
// format: '/uri/{param}'
Captures []*BraceCapture
// references to Query parameters
// format: 'GET@paranName'
Query map[string]*Parameter
// references for form parameters (all but Captures and Query)
Form map[string]*Parameter
}
// Parameter represents a parameter definition (from api.json)
type Parameter struct {
Description string `json:"info"`
Type string `json:"type"`
Rename string `json:"name,omitempty"`
// ExtractType is the type of data the datatype returns
ExtractType reflect.Type
// Optional is set to true when the type is prefixed with '?'
Optional bool
// Validator is inferred from @Type
Validator datatype.Validator
}
// BraceCapture links to the related URI parameter
type BraceCapture struct {
Name string
Index int
Ref *Parameter
}

View File

@ -1,4 +1,4 @@
package dynamic package dynfunc
// cerr allows you to create constant "const" error with type boxing. // cerr allows you to create constant "const" error with type boxing.
type cerr string type cerr string
@ -17,6 +17,9 @@ const ErrNoServiceForHandler = cerr("no service found for this handler")
// ErrMissingHandlerArgumentParam - missing params arguments for handler // ErrMissingHandlerArgumentParam - missing params arguments for handler
const ErrMissingHandlerArgumentParam = cerr("missing handler argument : parameter struct") const ErrMissingHandlerArgumentParam = cerr("missing handler argument : parameter struct")
// ErrUnexpectedInput - input argument is not expected
const ErrUnexpectedInput = cerr("unexpected input struct")
// ErrMissingHandlerOutput - missing output for handler // ErrMissingHandlerOutput - missing output for handler
const ErrMissingHandlerOutput = cerr("handler must have at least 1 output") const ErrMissingHandlerOutput = cerr("handler must have at least 1 output")
@ -29,6 +32,9 @@ const ErrMissingRequestArgument = cerr("handler first argument must be of type a
// ErrMissingParamArgument - missing parameters argument for handler // ErrMissingParamArgument - missing parameters argument for handler
const ErrMissingParamArgument = cerr("handler second argument must be a struct") const ErrMissingParamArgument = cerr("handler second argument must be a struct")
// ErrUnexportedName - argument is unexported in struct
const ErrUnexportedName = cerr("unexported name")
// ErrMissingParamOutput - missing output argument for handler // ErrMissingParamOutput - missing output argument for handler
const ErrMissingParamOutput = cerr("handler first output must be a *struct") const ErrMissingParamOutput = cerr("handler first output must be a *struct")
@ -41,8 +47,5 @@ const ErrMissingOutputFromConfig = cerr("missing a parameter from configuration"
// ErrWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct // ErrWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct
const ErrWrongParamTypeFromConfig = cerr("invalid struct field type") const ErrWrongParamTypeFromConfig = cerr("invalid struct field type")
// ErrWrongOutputTypeFromConfig - a configuration output type is invalid in the handler output struct
const ErrWrongOutputTypeFromConfig = cerr("invalid struct field type")
// ErrMissingHandlerErrorOutput - missing handler output error // ErrMissingHandlerErrorOutput - missing handler output error
const ErrMissingHandlerErrorOutput = cerr("last output must be of type api.Error") const ErrMissingHandlerErrorOutput = cerr("last output must be of type api.Error")

View File

@ -1,4 +1,4 @@
package dynamic package dynfunc
import ( import (
"fmt" "fmt"
@ -8,16 +8,16 @@ import (
"git.xdrm.io/go/aicra/internal/config" "git.xdrm.io/go/aicra/internal/config"
) )
// Build a handler from a service configuration and a HandlerFn // Build a handler from a service configuration and a dynamic function
// //
// a HandlerFn must have as a signature : `func(api.Request, inputStruct) (outputStruct, api.Error)` // @fn must have as a signature : `func(inputStruct) (*outputStruct, api.Error)`
// - `inputStruct` is a struct{} containing a field for each service input (with valid reflect.Type) // - `inputStruct` is a struct{} containing a field for each service input (with valid reflect.Type)
// - `outputStruct` is a struct{} containing a field for each service output (with valid reflect.Type) // - `outputStruct` is a struct{} containing a field for each service output (with valid reflect.Type)
// //
// Special cases: // Special cases:
// - it there is no input, `inputStruct` can be omitted // - it there is no input, `inputStruct` must be omitted
// - it there is no output, `outputStruct` can be omitted // - it there is no output, `outputStruct` must be omitted
func Build(fn HandlerFn, service config.Service) (*Handler, error) { func Build(fn interface{}, service config.Service) (*Handler, error) {
h := &Handler{ h := &Handler{
spec: makeSpec(service), spec: makeSpec(service),
fn: fn, fn: fn,
@ -39,7 +39,7 @@ func Build(fn HandlerFn, service config.Service) (*Handler, error) {
return h, nil return h, nil
} }
// Handle binds input @data into HandleFn and returns map output // Handle binds input @data into the dynamic function and returns map output
func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Error) { func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Error) {
fnv := reflect.ValueOf(h.fn) fnv := reflect.ValueOf(h.fn)

View File

@ -1,8 +1,9 @@
package dynamic package dynfunc
import ( import (
"fmt" "fmt"
"reflect" "reflect"
"strings"
"git.xdrm.io/go/aicra/api" "git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config" "git.xdrm.io/go/aicra/internal/config"
@ -16,6 +17,9 @@ func makeSpec(service config.Service) spec {
} }
for _, param := range service.Input { for _, param := range service.Input {
if len(param.Rename) < 1 {
continue
}
// make a pointer if optional // make a pointer if optional
if param.Optional { if param.Optional {
spec.Input[param.Rename] = reflect.PtrTo(param.ExtractType) spec.Input[param.Rename] = reflect.PtrTo(param.ExtractType)
@ -25,6 +29,9 @@ func makeSpec(service config.Service) spec {
} }
for _, param := range service.Output { for _, param := range service.Output {
if len(param.Rename) < 1 {
continue
}
spec.Output[param.Rename] = param.ExtractType spec.Output[param.Rename] = param.ExtractType
} }
@ -37,6 +44,9 @@ func (s spec) checkInput(fnv reflect.Value) error {
// no input -> ok // no input -> ok
if len(s.Input) == 0 { if len(s.Input) == 0 {
if fnt.NumIn() > 0 {
return ErrUnexpectedInput
}
return nil return nil
} }
@ -50,8 +60,12 @@ func (s spec) checkInput(fnv reflect.Value) error {
return ErrMissingParamArgument return ErrMissingParamArgument
} }
// check for invlaid param // check for invalid param
for name, ptype := range s.Input { for name, ptype := range s.Input {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, ErrUnexportedName)
}
field, exists := structArg.FieldByName(name) field, exists := structArg.FieldByName(name)
if !exists { if !exists {
return fmt.Errorf("%s: %w", name, ErrMissingParamFromConfig) return fmt.Errorf("%s: %w", name, ErrMissingParamFromConfig)
@ -100,6 +114,10 @@ func (s spec) checkOutput(fnv reflect.Value) error {
// fail on invalid output // fail on invalid output
for name, ptype := range s.Output { for name, ptype := range s.Output {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, ErrUnexportedName)
}
field, exists := structOutput.FieldByName(name) field, exists := structOutput.FieldByName(name)
if !exists { if !exists {
return fmt.Errorf("%s: %w", name, ErrMissingOutputFromConfig) return fmt.Errorf("%s: %w", name, ErrMissingOutputFromConfig)
@ -110,8 +128,8 @@ func (s spec) checkOutput(fnv reflect.Value) error {
continue continue
} }
if !ptype.ConvertibleTo(field.Type) { if !field.Type.ConvertibleTo(ptype) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, ErrWrongOutputTypeFromConfig, field.Type, ptype) return fmt.Errorf("%s: %w (%s instead of %s)", name, ErrWrongParamTypeFromConfig, field.Type, ptype)
} }
} }

View File

@ -0,0 +1,230 @@
package dynfunc
import (
"errors"
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/aicra/api"
)
func TestInputCheck(t *testing.T) {
tcases := []struct {
Input map[string]reflect.Type
Fn interface{}
Err error
}{
// no input
{
Input: map[string]reflect.Type{},
Fn: func() {},
Err: nil,
},
// func must have noarguments if none specified
{
Input: map[string]reflect.Type{},
Fn: func(int, string) {},
Err: ErrUnexpectedInput,
},
// missing input struct in func
{
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() {},
Err: ErrMissingHandlerArgumentParam,
},
// input not a struct
{
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(int) {},
Err: ErrMissingParamArgument,
},
// unexported param name
{
Input: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{}) {},
Err: ErrUnexportedName,
},
// input field missing
{
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{}) {},
Err: ErrMissingParamFromConfig,
},
// input field invalid type
{
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{ Test1 string }) {},
Err: ErrWrongParamTypeFromConfig,
},
// input field valid type
{
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{ Test1 int }) {},
Err: nil,
},
}
for i, tcase := range tcases {
t.Run(fmt.Sprintf("case.%d", i), func(t *testing.T) {
// mock spec
s := spec{
Input: tcase.Input,
Output: nil,
}
err := s.checkInput(reflect.ValueOf(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()
}
}
})
}
}
func TestOutputCheck(t *testing.T) {
tcases := []struct {
Output map[string]reflect.Type
Fn interface{}
Err error
}{
// no input -> missing api.Error
{
Output: map[string]reflect.Type{},
Fn: func() {},
Err: ErrMissingHandlerOutput,
},
// no input -> with last type not api.Error
{
Output: map[string]reflect.Type{},
Fn: func() bool { return true },
Err: ErrMissingHandlerErrorOutput,
},
// no input -> with api.Error
{
Output: map[string]reflect.Type{},
Fn: func() api.Error { return api.ErrorSuccess },
Err: nil,
},
// func can have output if not specified
{
Output: map[string]reflect.Type{},
Fn: func() (*struct{}, api.Error) { return nil, api.ErrorSuccess },
Err: nil,
},
// missing output struct in func
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() api.Error { return api.ErrorSuccess },
Err: ErrMissingParamOutput,
},
// output not a pointer
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (int, api.Error) { return 0, api.ErrorSuccess },
Err: ErrMissingParamOutput,
},
// output not a pointer to struct
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*int, api.Error) { return nil, api.ErrorSuccess },
Err: ErrMissingParamOutput,
},
// unexported param name
{
Output: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Error) { return nil, api.ErrorSuccess },
Err: ErrUnexportedName,
},
// output field missing
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Error) { return nil, api.ErrorSuccess },
Err: ErrMissingParamFromConfig,
},
// output field invalid type
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 string }, api.Error) { return nil, api.ErrorSuccess },
Err: ErrWrongParamTypeFromConfig,
},
// output field valid type
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 int }, api.Error) { return nil, api.ErrorSuccess },
Err: nil,
},
// ignore type check on nil type
{
Output: map[string]reflect.Type{
"Test1": nil,
},
Fn: func() (*struct{ Test1 int }, api.Error) { return nil, api.ErrorSuccess },
Err: nil,
},
}
for i, tcase := range tcases {
t.Run(fmt.Sprintf("case.%d", i), func(t *testing.T) {
// mock spec
s := spec{
Input: nil,
Output: tcase.Output,
}
err := s.checkOutput(reflect.ValueOf(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()
}
}
})
}
}

View File

@ -1,14 +1,11 @@
package dynamic package dynfunc
import "reflect" import "reflect"
// HandlerFn defines a dynamic handler function
type HandlerFn interface{}
// Handler represents a dynamic api handler // Handler represents a dynamic api handler
type Handler struct { type Handler struct {
spec spec spec spec
fn HandlerFn fn interface{}
} }
type spec struct { type spec struct {

View File

@ -39,7 +39,7 @@ func New(service *config.Service) *Set {
} }
// ExtractURI fills 'Set' with creating pointers inside 'Url' // ExtractURI fills 'Set' with creating pointers inside 'Url'
func (i *Set) ExtractURI(req *http.Request) error { func (i *Set) ExtractURI(req http.Request) error {
uriparts := config.SplitURL(req.URL.RequestURI()) uriparts := config.SplitURL(req.URL.RequestURI())
for _, capture := range i.service.Captures { for _, capture := range i.service.Captures {
@ -71,7 +71,7 @@ func (i *Set) ExtractURI(req *http.Request) error {
} }
// ExtractQuery data from the url query parameters // ExtractQuery data from the url query parameters
func (i *Set) ExtractQuery(req *http.Request) error { func (i *Set) ExtractQuery(req http.Request) error {
query := req.URL.Query() query := req.URL.Query()
for name, param := range i.service.Query { for name, param := range i.service.Query {
@ -108,7 +108,7 @@ func (i *Set) ExtractQuery(req *http.Request) error {
// - parse 'form-data' if not supported for non-POST requests // - parse 'form-data' if not supported for non-POST requests
// - parse 'x-www-form-urlencoded' // - parse 'x-www-form-urlencoded'
// - parse 'application/json' // - parse 'application/json'
func (i *Set) ExtractForm(req *http.Request) error { func (i *Set) ExtractForm(req http.Request) error {
// ignore GET method // ignore GET method
if req.Method == http.MethodGet { if req.Method == http.MethodGet {
@ -138,7 +138,7 @@ func (i *Set) ExtractForm(req *http.Request) error {
// parseJSON parses JSON from the request body inside 'Form' // parseJSON parses JSON from the request body inside 'Form'
// and 'Set' // and 'Set'
func (i *Set) parseJSON(req *http.Request) error { func (i *Set) parseJSON(req http.Request) error {
parsed := make(map[string]interface{}, 0) parsed := make(map[string]interface{}, 0)
@ -178,7 +178,7 @@ func (i *Set) parseJSON(req *http.Request) error {
// parseUrlencoded parses urlencoded from the request body inside 'Form' // parseUrlencoded parses urlencoded from the request body inside 'Form'
// and 'Set' // and 'Set'
func (i *Set) parseUrlencoded(req *http.Request) error { func (i *Set) parseUrlencoded(req http.Request) error {
// use http.Request interface // use http.Request interface
if err := req.ParseForm(); err != nil { if err := req.ParseForm(); err != nil {
return err return err
@ -215,7 +215,7 @@ func (i *Set) parseUrlencoded(req *http.Request) error {
// parseMultipart parses multi-part from the request body inside 'Form' // parseMultipart parses multi-part from the request body inside 'Form'
// and 'Set' // and 'Set'
func (i *Set) parseMultipart(req *http.Request) error { func (i *Set) parseMultipart(req http.Request) error {
// 1. create reader // 1. create reader
boundary := req.Header.Get("Content-Type")[len("multipart/form-data; boundary="):] boundary := req.Header.Get("Content-Type")[len("multipart/form-data; boundary="):]

View File

@ -131,7 +131,7 @@ func TestStoreWithUri(t *testing.T) {
store := New(service) store := New(service)
req := httptest.NewRequest(http.MethodGet, "http://host.com"+test.URI, nil) req := httptest.NewRequest(http.MethodGet, "http://host.com"+test.URI, nil)
err := store.ExtractURI(req) err := store.ExtractURI(*req)
if err != nil { if err != nil {
if test.Err != nil { if test.Err != nil {
if !errors.Is(err, test.Err) { if !errors.Is(err, test.Err) {
@ -242,7 +242,7 @@ func TestExtractQuery(t *testing.T) {
store := New(getServiceWithQuery(test.ServiceParam...)) store := New(getServiceWithQuery(test.ServiceParam...))
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://host.com?%s", test.Query), nil) req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://host.com?%s", test.Query), nil)
err := store.ExtractQuery(req) err := store.ExtractQuery(*req)
if err != nil { if err != nil {
if test.Err != nil { if test.Err != nil {
if !errors.Is(err, test.Err) { if !errors.Is(err, test.Err) {
@ -324,7 +324,7 @@ func TestStoreWithUrlEncodedFormParseError(t *testing.T) {
// defer req.Body.Close() // defer req.Body.Close()
store := New(nil) store := New(nil)
err := store.ExtractForm(req) err := store.ExtractForm(*req)
if err == nil { if err == nil {
t.Errorf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data)) t.Errorf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data))
t.FailNow() t.FailNow()
@ -420,7 +420,7 @@ func TestExtractFormUrlEncoded(t *testing.T) {
defer req.Body.Close() defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...)) store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req) err := store.ExtractForm(*req)
if err != nil { if err != nil {
if test.Err != nil { if test.Err != nil {
if !errors.Is(err, test.Err) { if !errors.Is(err, test.Err) {
@ -563,7 +563,7 @@ func TestJsonParameters(t *testing.T) {
defer req.Body.Close() defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...)) store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req) err := store.ExtractForm(*req)
if err != nil { if err != nil {
if test.Err != nil { if test.Err != nil {
if !errors.Is(err, test.Err) { if !errors.Is(err, test.Err) {
@ -720,7 +720,7 @@ x
defer req.Body.Close() defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...)) store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req) err := store.ExtractForm(*req)
if err != nil { if err != nil {
if test.Err != nil { if test.Err != nil {
if !errors.Is(err, test.Err) { if !errors.Is(err, test.Err) {

154
server.go
View File

@ -1,91 +1,101 @@
package aicra package aicra
import ( import (
"fmt" "net/http"
"io"
"os"
"git.xdrm.io/go/aicra/datatype" "git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/dynamic"
"git.xdrm.io/go/aicra/internal/config" "git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/reqdata"
) )
// Server represents an AICRA instance featuring: type checkers, services // Server hides the builder and allows handling http requests
type Server struct { type Server Builder
config *config.Server
handlers []*handler // ServeHTTP implements http.Handler and is called on each request
func (server Server) ServeHTTP(res http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
// 1. find a matching service in the config
service := server.conf.Find(req)
if service == nil {
errorHandler(api.ErrorUnknownService)
return
}
// 2. extract request data
dataset, err := extractRequestData(service, *req)
if err != nil {
errorHandler(api.ErrorMissingParam)
return
}
// 3. find a matching handler
var handler *apiHandler
for _, h := range server.handlers {
if h.Method == service.Method && h.Path == service.Pattern {
handler = h
}
}
// 4. fail if found no handler
if handler == nil {
errorHandler(api.ErrorUncallableService)
return
}
// 5. execute
returned, apiErr := handler.dyn.Handle(dataset.Data)
// 6. build response from returned data
response := api.EmptyResponse().WithError(apiErr)
for key, value := range returned {
// find original name from rename
for name, param := range service.Output {
if param.Rename == key {
response.SetData(name, value)
}
}
}
// 7. apply headers
res.Header().Set("Content-Type", "application/json; charset=utf-8")
for key, values := range response.Headers {
for _, value := range values {
res.Header().Add(key, value)
}
}
response.ServeHTTP(res, req)
} }
// New creates a framework instance from a configuration file func errorHandler(err api.Error) http.HandlerFunc {
func New(configPath string, dtypes ...datatype.T) (*Server, error) { return func(res http.ResponseWriter, req *http.Request) {
var ( r := api.EmptyResponse().WithError(err)
err error r.ServeHTTP(res, req)
configFile io.ReadCloser
)
// 1. init instance
var i = &Server{
config: nil,
handlers: make([]*handler, 0),
} }
}
// 2. open config file func extractRequestData(service *config.Service, req http.Request) (*reqdata.Set, error) {
configFile, err = os.Open(configPath) dataset := reqdata.New(service)
if err != nil {
return nil, err
}
defer configFile.Close()
// 3. load configuration // 3. extract URI data
i.config, err = config.Parse(configFile, dtypes...) err := dataset.ExtractURI(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return i, nil // 4. extract query data
err = dataset.ExtractQuery(req)
}
// Handle sets a new handler for an HTTP method to a path
func (s *Server) Handle(method, path string, fn dynamic.HandlerFn) error {
// find associated service
var found *config.Service = nil
for _, service := range s.config.Services {
if method == service.Method && path == service.Pattern {
found = service
break
}
}
if found == nil {
return fmt.Errorf("%s '%s': %w", method, path, ErrNoServiceForHandler)
}
handler, err := createHandler(method, path, *found, fn)
if err != nil { if err != nil {
return err return nil, err
} }
s.handlers = append(s.handlers, handler)
return nil // 5. extract form/json data
} err = dataset.ExtractForm(req)
if err != nil {
// ToHTTPServer converts the server to a http server return nil, err
func (s Server) ToHTTPServer() (*httpServer, error) { }
// check if handlers are missing return dataset, nil
for _, service := range s.config.Services {
found := false
for _, handler := range s.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, ErrNoHandlerForService)
}
}
// 2. cast to http server
httpServer := httpServer(s)
return &httpServer, nil
} }

15
util.go
View File

@ -1,15 +0,0 @@
package aicra
import (
"log"
"net/http"
"git.xdrm.io/go/aicra/api"
)
var handledMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
// Prints an error as HTTP response
func logError(res *api.Response) {
log.Printf("[http.fail] %v\n", res)
}