Compare commits

..

No commits in common. "fb69dbb903dc1202651c5702c9a89cd6b3df1271" and "6319761731cfeccf1415dba64c34659226db30b7" have entirely different histories.

29 changed files with 884 additions and 706 deletions

173
README.md
View File

@ -7,34 +7,40 @@
[![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra)
Aicra is a *configuration-driven* REST API engine written in Go.
**Aicra** is a *configuration-driven* **web framework** written in Go that allows you to create a fully featured REST API.
Most of the management is done for you using a configuration file describing your API. you're left with implementing :
The whole management is done for you from a configuration file describing your API, you're left with implementing :
- handlers
- optionnally middle-wares (_e.g. authentication, csrf_)
- and optionnally your custom type checkers to check input parameters
> A example project is available [here](https://git.xdrm.io/go/articles-api)
## Table of contents
The aicra server fulfills the `net/http` [Server interface](https://golang.org/pkg/net/http/#Server).
> A example project is available [here](https://git.xdrm.io/go/tiny-url-ex)
### Table of contents
<!-- toc -->
- [I/ Installation](#i-installation)
- [II/ Usage](#ii-usage)
* [1) Build a server](#1-build-a-server)
* [2) API Configuration](#2-api-configuration)
- [Definition](#definition)
+ [Input Arguments](#input-arguments)
- [1. Input types](#1-input-types)
- [2. Global Format](#2-global-format)
- [II/ Development](#ii-development)
* [1) Main executable](#1-main-executable)
* [2) API Configuration](#2-api-configuration)
- [Definition](#definition)
+ [Input Arguments](#input-arguments)
- [1. Input types](#1-input-types)
- [2. Global Format](#2-global-format)
- [III/ Change Log](#iii-change-log)
<!-- tocstop -->
## I/ Installation
### I/ Installation
You need a recent machine with `go` [installed](https://golang.org/doc/install). This package has not been tested under the version **1.14**.
You need a recent machine with `go` [installed](https://golang.org/doc/install). This package has not been tested under the version **1.10**.
```bash
@ -44,112 +50,95 @@ go get -u git.xdrm.io/go/aicra/cmd/aicra
The library should now be available as `git.xdrm.io/go/aicra` in your imports.
## II/ Usage
### II/ Development
### 1) Build a server
#### 1) Main executable
Here is some sample code that builds and sets up an aicra server using your api configuration file.
Your main executable will declare and run the aicra server, it might look quite like the code below.
```go
package main
import (
"log"
"net/http"
"os"
"log"
"net/http"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func main() {
builder := &aicra.Builder{}
// 1. select your datatypes (builtin, custom)
var dtypes []datatype.T
dtypes = append(dtypes, builtin.AnyDataType{})
dtypes = append(dtypes, builtin.BoolDataType{})
dtypes = append(dtypes, builtin.UintDataType{})
dtypes = append(dtypes, builtin.StringDataType{})
// add datatypes your api uses
builder.AddType(builtin.BoolDataType{})
builder.AddType(builtin.UintDataType{})
builder.AddType(builtin.StringDataType{})
config, err := os.Open("./api.json")
// 2. create the server from the configuration file
server, err := aicra.New("path/to/your/api/definition.json", dtypes...)
if err != nil {
log.Fatalf("cannot open config: %s", err)
log.Fatalf("cannot built aicra server: %s\n", err)
}
// pass your configuration
err = builder.Setup(config)
config.Close()
// 3. bind your implementations
server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){
// ... process stuff ...
res.SetError(api.ErrorSuccess());
})
// 4. extract to http server
httpServer, err := server.ToHTTPServer()
if err != nil {
log.Fatalf("invalid config: %s", err)
log.Fatalf("cannot get to http server: %s", err)
}
// bind your handlers
builder.Bind(http.MethodGet, "/user/{id}", getUserById)
builder.Bind(http.MethodGet, "/user/{id}/username", getUsernameByID)
// build the server and start listening
server, err := builder.Build()
if err != nil {
log.Fatalf("cannot build server: %s", err)
}
http.ListenAndServe("localhost:8080", server)
// 4. launch server
log.Fatal( http.ListenAndServe("localhost:8080", server) )
}
```
Here is an example handler
```go
type req struct{
Param1 int
Param3 *string // optional are pointers
}
type res struct{
Output1 string
Output2 bool
}
func myHandler(r req) (*res, api.Error) {
err := doSomething()
if err != nil {
return nil, api.ErrorFailure
}
return &res{}, api.ErrorSuccess
}
```
#### 2) API Configuration
### 2) API Configuration
The whole api behavior is described inside a json file (_e.g. usually api.json_). For a better understanding of the format, take a look at this working [template](https://git.xdrm.io/go/articles-api/src/master/api.json). This file defines :
The whole project behavior is described inside a json file (_e.g. usually api.json_). For a better understanding of the format, take a look at this working [template](https://git.xdrm.io/go/tiny-url-ex/src/master/api.json). This file defines :
- routes and their methods
- every input for each method (called *argument*)
- every output for each method
- scope permissions (list of permissions needed by clients)
- input policy :
- type of argument (_c.f. data types_)
- type of argument (_i.e. for data types_)
- required/optional
- variable renaming
#### Format
The root of the json file must be an array containing your requests definitions. For each, you will have to create fields described in the table above.
###### Definition
The root of the json file must be an array containing your requests definitions.
For each, you will have to create fields described in the table above.
| field path | description | example |
| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| `info` | A short human-readable description of what the method does | `create a new user` |
| `scope` | A 2-dimensional array of permissions. The first dimension can be translated to a **or** operator, the second dimension as a **and**. It allows you to combine permissions in complex ways. | `[["A", "B"], ["C", "D"]]` can be translated to : this method needs users to have permissions (A **and** B) **or** (C **and** D) |
| `in` | The list of arguments that the clients will have to provide. [Read more](#input-arguments). | |
| `out` | The list of output data that will be returned by your controllers. It has the same syntax as the `in` field but optional parameters are not allowed |
| `in` | The list of arguments that the clients will have to provide. See [here](#input-arguments) for details. | |
| `out` | The list of output data that will be returned by your controllers. It has the same syntax as the `in` field but is only use for readability purpose and documentation. | |
### Input Arguments
##### Input Arguments
###### 1. Input types
Input arguments defines what data from the HTTP request the method needs. Aicra is able to extract 3 types of data :
- **URI** - data from inside the request path. For instance, if your controller is bound to the `/user/{id}` URI, you can set the input argument `{id}` matching this uri part.
- **URI** - Curly Braces enclosed strings inside the request path. For instance, if your controller is bound to the `/user/{id}` URI, you can set the input argument `{id}` matching this uri part.
- **Query** - data formatted at the end of the URL following the standard [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **URL encoded** - data send inside the body of the request but following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **Multipart** - data send inside the body of the request with a dedicated [format](https://tools.ietf.org/html/rfc2388#section-3). This format is not very lightweight but allows you to receive data as well as files.
@ -157,7 +146,7 @@ Input arguments defines what data from the HTTP request the method needs. Aicra
#### Format
###### 2. Global Format
The `in` field in each method contains as list of arguments where the key is the argument name, and the value defines how to manage the variable.
@ -170,6 +159,10 @@ The `in` field in each method contains as list of arguments where the key is the
In this example we want 3 arguments :
- the 1^st^ one is send at the end of the URI and is a number compliant with the `int` type checker. It is renamed `article_id`, this new name will be sent to the handler.
- the 2^nd^ one is send in the query (_e.g. [http://host/uri?get-var=value](http://host/uri?get-var=value)_). It must be a valid `string` or not given at all (the `?` at the beginning of the type tells that the argument is **optional**) ; it will be named `title`.
- the 3^rd^ can be send with a **JSON** body, in **multipart** or **URL encoded** it makes no difference and only give clients a choice over the technology to use. If not renamed, the variable will be given to the handler with the name `content`.
```json
[
{
@ -191,6 +184,32 @@ In this example we want 3 arguments :
]
```
- the 1^st^ one is send at the end of the URI and is a number compliant with the `int` type checker. It is renamed `article_id`, this new name will be sent to the handler.
- the 2^nd^ one is send in the query (_e.g. [http://host/uri?get-var=value](http://host/uri?get-var=value)_). It must be a valid `string` or not given at all (the `?` at the beginning of the type tells that the argument is **optional**) ; it will be named `title`.
- the 3^rd^ can be send with a **JSON** body, in **multipart** or **URL encoded** it makes no difference and only give clients a choice over the technology to use. If not renamed, the variable will be given to the handler with the name `content`.
### III/ Change Log
- [x] human-readable json configuration
- [x] nested routes (*i.e. `/user/:id:` and `/user/post/:id:`*)
- [x] nested URL arguments (*i.e. `/user/:id:` and `/user/:id:/post/:id:`*)
- [x] useful http methods: GET, POST, PUT, DELETE
- [x] manage URL, query and body arguments:
- [x] multipart/form-data (variables and file uploads)
- [x] application/x-www-form-urlencoded
- [x] application/json
- [x] required vs. optional parameters with a default value
- [x] parameter renaming
- [x] generic type check (*i.e. implement custom types alongside built-in ones*)
- [ ] built-in types
- [x] `any` - wildcard matching all values
- [x] `int` - see go types
- [x] `uint` - see go types
- [x] `float` - see go types
- [x] `string` - any text
- [x] `string(min, max)` - any string with a length between `min` and `max`
- [ ] `[a]` - array containing **only** elements matching `a` type
- [ ] `[a:b]` - map containing **only** keys of type `a` and values of type `b` (*a or b can be ommited*)
- [x] generic controllers implementation (shared objects)
- [x] response interface
- [x] log bound resources when building the aicra server
- [x] fail on check for unimplemented resources at server boot.
- [x] fail on check for unavailable types in api.json at server boot.

View File

@ -1,7 +1,5 @@
package api
import "net/http"
var (
// ErrorUnknown represents any error which cause is unknown.
// It might also be used for debug purposes as this error
@ -21,17 +19,20 @@ var (
// unique fields already exists
ErrorAlreadyExists Error = 3
// ErrorConfig has to be set when there is a configuration error
ErrorConfig Error = 4
// ErrorCreation has to be set when there is a creation/insert error
ErrorCreation Error = 4
ErrorCreation Error = 5
// ErrorModification has to be set when there is an update/modification error
ErrorModification Error = 5
ErrorModification Error = 6
// ErrorDeletion has to be set when there is a deletion/removal error
ErrorDeletion Error = 6
ErrorDeletion Error = 7
// ErrorTransaction has to be set when there is a transactional error
ErrorTransaction Error = 7
ErrorTransaction Error = 8
// ErrorUpload has to be set when a file upload failed
ErrorUpload Error = 100
@ -89,6 +90,7 @@ var errorReasons = map[Error]string{
ErrorFailure: "it failed",
ErrorNoMatchFound: "resource not found",
ErrorAlreadyExists: "already exists",
ErrorConfig: "configuration error",
ErrorCreation: "create error",
ErrorModification: "update error",
ErrorDeletion: "delete error",
@ -106,26 +108,3 @@ var errorReasons = map[Error]string{
ErrorInvalidParam: "invalid parameter",
ErrorInvalidDefaultParam: "invalid default param",
}
var errorStatus = map[Error]int{
ErrorUnknown: http.StatusOK,
ErrorSuccess: http.StatusOK,
ErrorFailure: http.StatusInternalServerError,
ErrorNoMatchFound: http.StatusOK,
ErrorAlreadyExists: http.StatusOK,
ErrorCreation: http.StatusOK,
ErrorModification: http.StatusOK,
ErrorDeletion: http.StatusOK,
ErrorTransaction: http.StatusOK,
ErrorUpload: http.StatusInternalServerError,
ErrorDownload: http.StatusInternalServerError,
MissingDownloadHeaders: http.StatusBadRequest,
ErrorMissingDownloadBody: http.StatusBadRequest,
ErrorUnknownService: http.StatusServiceUnavailable,
ErrorUncallableService: http.StatusServiceUnavailable,
ErrorNotImplemented: http.StatusNotImplemented,
ErrorPermission: http.StatusUnauthorized,
ErrorToken: http.StatusForbidden,
ErrorMissingParam: http.StatusBadRequest,
ErrorInvalidParam: http.StatusBadRequest,
ErrorInvalidDefaultParam: http.StatusBadRequest,
}

View File

@ -3,7 +3,6 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
)
// Error represents an http response error following the api format.
@ -11,21 +10,15 @@ import (
// directly into the response as JSON alongside response output fields.
type Error int
// Error implements the error interface
func (e Error) Error() string {
// use unknown error if no reason
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.Error()
}
return fmt.Sprintf("[%d] %s", e, reason)
}
// Status returns the associated HTTP status code
func (e Error) Status() int {
status, ok := errorStatus[e]
if !ok {
return http.StatusOK
}
return status
return fmt.Sprintf("[%d] %s", e, reason)
}
// MarshalJSON implements encoding/json.Marshaler interface

54
api/request.go Normal file
View File

@ -0,0 +1,54 @@
package api
import (
"net/http"
"strings"
)
// Request represents an API request i.e. HTTP
type Request struct {
// corresponds to the list of uri components
// featured in the request URI
URI []string
// Scope from the configuration file of the current service
Scope [][]string
// original HTTP request
Request *http.Request
// input parameters
Param RequestParam
}
// NewRequest builds an interface request from a http.Request
func NewRequest(req *http.Request) *Request {
uri := normaliseURI(req.URL.Path)
uriparts := strings.Split(uri, "/")
return &Request{
URI: uriparts,
Scope: nil,
Request: req,
Param: make(RequestParam),
}
}
// normaliseURI removes the trailing '/' to always
// have the same Uri format for later processing
func normaliseURI(uri string) string {
if len(uri) < 1 {
return uri
}
if uri[0] == '/' {
uri = uri[1:]
}
if len(uri) > 1 && uri[len(uri)-1] == '/' {
uri = uri[0 : len(uri)-1]
}
return uri
}

162
api/request.param.go Normal file
View File

@ -0,0 +1,162 @@
package api
import (
"fmt"
)
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// ErrReqParamNotFound is thrown when a request parameter is not found
const ErrReqParamNotFound = cerr("request parameter not found")
// ErrReqParamNotType is thrown when a request parameter is not asked with the right type
const ErrReqParamNotType = cerr("request parameter does not fulfills type")
// RequestParam defines input parameters of an api request
type RequestParam map[string]interface{}
// Get returns the raw value (not typed) and an error if not found
func (rp RequestParam) Get(key string) (interface{}, error) {
rawValue, found := rp[key]
if !found {
return "", ErrReqParamNotFound
}
return rawValue, nil
}
// GetString returns a string and an error if not found or invalid type
func (rp RequestParam) GetString(key string) (string, error) {
rawValue, err := rp.Get(key)
if err != nil {
return "", err
}
switch cast := rawValue.(type) {
case fmt.Stringer:
return cast.String(), nil
case []byte:
return string(cast), nil
case string:
return cast, nil
default:
return "", ErrReqParamNotType
}
}
// GetFloat returns a float64 and an error if not found or invalid type
func (rp RequestParam) GetFloat(key string) (float64, error) {
rawValue, err := rp.Get(key)
if err != nil {
return 0, err
}
switch cast := rawValue.(type) {
case float32:
return float64(cast), nil
case float64:
return cast, nil
case int, int8, int16, int32, int64:
intVal, ok := cast.(int)
if !ok || intVal != int(float64(intVal)) {
return 0, ErrReqParamNotType
}
return float64(intVal), nil
case uint, uint8, uint16, uint32, uint64:
uintVal, ok := cast.(uint)
if !ok || uintVal != uint(float64(uintVal)) {
return 0, ErrReqParamNotType
}
return float64(uintVal), nil
default:
return 0, ErrReqParamNotType
}
}
// GetInt returns an int and an error if not found or invalid type
func (rp RequestParam) GetInt(key string) (int, error) {
rawValue, err := rp.Get(key)
if err != nil {
return 0, err
}
switch cast := rawValue.(type) {
case float32, float64:
floatVal, ok := cast.(float64)
if !ok || floatVal < 0 || floatVal != float64(int(floatVal)) {
return 0, ErrReqParamNotType
}
return int(floatVal), nil
case int, int8, int16, int32, int64:
intVal, ok := cast.(int)
if !ok || intVal != int(int(intVal)) {
return 0, ErrReqParamNotType
}
return int(intVal), nil
default:
return 0, ErrReqParamNotType
}
}
// GetUint returns an uint and an error if not found or invalid type
func (rp RequestParam) GetUint(key string) (uint, error) {
rawValue, err := rp.Get(key)
if err != nil {
return 0, err
}
switch cast := rawValue.(type) {
case float32, float64:
floatVal, ok := cast.(float64)
if !ok || floatVal < 0 || floatVal != float64(uint(floatVal)) {
return 0, ErrReqParamNotType
}
return uint(floatVal), nil
case int, int8, int16, int32, int64:
intVal, ok := cast.(int)
if !ok || intVal != int(uint(intVal)) {
return 0, ErrReqParamNotType
}
return uint(intVal), nil
case uint, uint8, uint16, uint32, uint64:
uintVal, ok := cast.(uint)
if !ok {
return 0, ErrReqParamNotType
}
return uintVal, nil
default:
return 0, ErrReqParamNotType
}
}
// GetStrings returns an []slice and an error if not found or invalid type
func (rp RequestParam) GetStrings(key string) ([]string, error) {
rawValue, err := rp.Get(key)
if err != nil {
return nil, err
}
switch cast := rawValue.(type) {
case []fmt.Stringer:
strings := make([]string, len(cast))
for i, stringer := range cast {
strings[i] = stringer.String()
}
return strings, nil
case [][]byte:
strings := make([]string, len(cast))
for i, bytes := range cast {
strings[i] = string(bytes)
}
return strings, nil
case []string:
return cast, nil
default:
return nil, ErrReqParamNotType
}
}

View File

@ -26,12 +26,13 @@ func EmptyResponse() *Response {
}
}
// WithError sets the error
// WithError sets the error from a base error with error arguments.
func (res *Response) WithError(err Error) *Response {
res.err = err
return res
}
// Error implements the error interface and dispatches to internal error.
func (res *Response) Error() string {
return res.err.Error()
}
@ -41,23 +42,36 @@ func (res *Response) SetData(name string, value interface{}) {
res.Data[name] = value
}
// GetData gets a response field
func (res *Response) GetData(name string) interface{} {
value, _ := res.Data[name]
return 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)
}
// ServeHTTP implements http.Handler and writes the API response.
func (res *Response) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(res.err.Status())
w.WriteHeader(res.Status)
encoded, err := json.Marshal(res)
if err != nil {
return err
}
w.Write(encoded)
return nil
}

View File

@ -29,7 +29,7 @@ func (b *Builder) AddType(t datatype.T) {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
panic(errLateType)
panic(ErrLateType)
}
b.conf.Types = append(b.conf.Types, t)
}
@ -41,7 +41,7 @@ func (b *Builder) Setup(r io.Reader) error {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
panic(errAlreadySetup)
panic(ErrAlreadySetup)
}
return b.conf.Parse(r)
}
@ -49,7 +49,7 @@ func (b *Builder) Setup(r io.Reader) error {
// 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
return ErrNotSetup
}
// find associated service
@ -62,7 +62,7 @@ func (b *Builder) Bind(method, path string, fn interface{}) error {
}
if service == nil {
return fmt.Errorf("%s '%s': %w", method, path, errUnknownService)
return fmt.Errorf("%s '%s': %w", method, path, ErrUnknownService)
}
dyn, err := dynfunc.Build(fn, *service)
@ -91,7 +91,7 @@ func (b Builder) Build() (http.Handler, error) {
}
}
if !hasAssociatedHandler {
return nil, fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, errMissingHandler)
return nil, fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, ErrMissingHandler)
}
}

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
}

15
datatype/types.go Normal file
View File

@ -0,0 +1,15 @@
package datatype
import "reflect"
// Validator returns whether a given value fulfills a datatype
// and casts the value into a compatible type
type Validator func(value interface{}) (cast interface{}, valid bool)
// T builds a T from the type definition (from the configuration field "type") and returns NIL if the type
// definition does not match this T ; the registry is passed for recursive datatypes (e.g. slices, structs, etc)
// to be able to access other datatypes
type T interface {
Type() reflect.Type
Build(typeDefinition string, registry ...T) Validator
}

View File

@ -3,21 +3,22 @@ package aicra
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// errLateType - cannot add datatype after setting up the definition
const errLateType = cerr("types cannot be added after Setup")
// ErrLateType - cannot add datatype after setting up the definition
const ErrLateType = cerr("types cannot be added after Setup")
// errNotSetup - not set up yet
const errNotSetup = cerr("not set up")
// ErrNotSetup - not set up yet
const ErrNotSetup = cerr("not set up")
// errAlreadySetup - already set up
const errAlreadySetup = cerr("already set up")
// ErrAlreadySetup - already set up
const ErrAlreadySetup = cerr("already set up")
// errUnknownService - no service matching this handler
const errUnknownService = cerr("unknown service")
// ErrUnknownService - no service matching this handler
const ErrUnknownService = cerr("unknown service")
// errMissingHandler - missing handler
const errMissingHandler = cerr("missing handler")
// ErrMissingHandler - missing handler
const ErrMissingHandler = cerr("missing handler")

View File

@ -1,182 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"git.xdrm.io/go/aicra/datatype"
)
// Server definition
type Server struct {
Types []datatype.T
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)
if err != nil {
return fmt.Errorf("%s: %w", errRead, err)
}
err = srv.validate()
if 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 {
err := service.validate(server.Types...)
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)
}
return nil
}
// Find a service matching an incoming HTTP request
func (server Server) Find(r *http.Request) *Service {
for _, service := range server.Services {
if matches := service.Match(r); matches {
return service
}
}
return nil
}
// collide returns if there is collision between any service for the same method and colliding paths.
// Note that service path collision detection relies on datatypes:
// - 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)
// 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]
if aService.Method != bService.Method {
continue
}
aURIParts := SplitURL(aService.Pattern)
bURIParts := SplitURL(bService.Pattern)
if len(aURIParts) != len(bURIParts) {
continue
}
err := checkURICollision(aURIParts, bURIParts, aService.Input, bService.Input)
if err != nil {
return fmt.Errorf("(%s '%s') vs (%s '%s'): %w", aService.Method, aService.Pattern, bService.Method, bService.Pattern, err)
}
}
}
return nil
}
// check if uri of services A and B collide
func checkURICollision(uriA, uriB []string, inputA, inputB map[string]*Parameter) error {
var errors = []error{}
// for each part
for pi, aPart := range uriA {
bPart := uriB[pi]
// no need for further check as it has been done earlier in the validation process
aIsCapture := len(aPart) > 1 && aPart[0] == '{'
bIsCapture := len(bPart) > 1 && bPart[0] == '{'
// 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))
continue
}
// no capture -> check strict equality
if !aIsCapture && !bIsCapture {
if aPart == bPart {
errors = append(errors, fmt.Errorf("%w (same path '%s')", errPatternCollision, aPart))
continue
}
}
// A captures B -> check type (B is A ?)
if aIsCapture {
input, exists := inputA[aPart]
// fail if no type or no validator
if !exists || input.Validator == nil {
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))
continue
}
// B captures A -> check type (A is B ?)
} else if bIsCapture {
input, exists := inputB[bPart]
// fail if no type or no validator
if !exists || input.Validator == nil {
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))
continue
}
}
errors = append(errors, nil)
}
// at least 1 URI part not matching -> no collision
var firstError error
for _, err := range errors {
if err != nil && firstError == nil {
firstError = err
}
if err == nil {
return nil
}
}
return firstError
}
// SplitURL without empty sets
func SplitURL(url string) []string {
trimmed := strings.Trim(url, " /\t\r\n")
split := strings.Split(trimmed, "/")
// remove empty set when empty url
if len(split) == 1 && len(split[0]) == 0 {
return []string{}
}
return split
}

View File

@ -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()
}
})
@ -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()
}
})
@ -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,
},
}
@ -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,
},
{
`[
@ -877,36 +877,6 @@ func TestMatchSimple(t *testing.T) {
"/a",
false,
},
{ // root url
`[ {
"method": "GET",
"path": "/a",
"info": "info",
"in": {}
} ]`,
"/",
false,
},
{
`[ {
"method": "GET",
"path": "/a",
"info": "info",
"in": {}
} ]`,
"/",
false,
},
{
`[ {
"method": "GET",
"path": "/",
"info": "info",
"in": {}
} ]`,
"/",
true,
},
{
`[ {
"method": "GET",
@ -1027,80 +997,3 @@ func TestMatchSimple(t *testing.T) {
}
}
func TestFindPriority(t *testing.T) {
t.Parallel()
tests := []struct {
Config string
URL string
MatchingDesc string
}{
{
`[
{ "method": "GET", "path": "/a", "info": "s1" },
{ "method": "GET", "path": "/", "info": "s2" }
]`,
"/",
"s2",
},
{
`[
{ "method": "GET", "path": "/", "info": "s2" },
{ "method": "GET", "path": "/a", "info": "s1" }
]`,
"/",
"s2",
},
{
`[
{ "method": "GET", "path": "/a", "info": "s1" },
{ "method": "GET", "path": "/", "info": "s2" }
]`,
"/a",
"s1",
},
{
`[
{ "method": "GET", "path": "/a/b/c", "info": "s1" },
{ "method": "GET", "path": "/a/b", "info": "s2" }
]`,
"/a/b/c",
"s1",
},
{
`[
{ "method": "GET", "path": "/a/b/c", "info": "s1" },
{ "method": "GET", "path": "/a/b", "info": "s2" }
]`,
"/a/b/",
"s2",
},
}
for i, test := range tests {
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{})
err := srv.Parse(strings.NewReader(test.Config))
if err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
}
req := httptest.NewRequest(http.MethodGet, test.URL, nil)
service := srv.Find(req)
if service == nil {
t.Errorf("expected to find a service")
t.FailNow()
}
if service.Description != test.MatchingDesc {
t.Errorf("expected description '%s', got '%s'", test.MatchingDesc, service.Description)
t.FailNow()
}
})
}
}

View File

@ -3,57 +3,58 @@ package config
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// errRead - read error
const errRead = cerr("cannot read config")
// ErrRead - a problem ocurred when trying to read the configuration file
const ErrRead = cerr("cannot read config")
// errUnknownMethod - unknown http method
const errUnknownMethod = cerr("unknown HTTP method")
// ErrUnknownMethod - invalid http method
const ErrUnknownMethod = cerr("unknown HTTP method")
// errFormat - invalid format
const errFormat = cerr("invalid config format")
// ErrFormat - a invalid format has been detected
const ErrFormat = cerr("invalid config format")
// errPatternCollision - collision between 2 services' patterns
const errPatternCollision = cerr("pattern collision")
// ErrPatternCollision - there is a collision between 2 services' patterns (same method)
const ErrPatternCollision = cerr("pattern collision")
// errInvalidPattern - malformed service pattern
const errInvalidPattern = cerr("malformed service path: must begin with a '/' and not end with")
// ErrInvalidPattern - a service pattern is malformed
const ErrInvalidPattern = cerr("must begin with a '/' and not end with")
// errInvalidPatternBraceCapture - invalid brace capture
const errInvalidPatternBraceCapture = cerr("invalid uri parameter")
// ErrInvalidPatternBraceCapture - a service pattern brace capture is invalid
const ErrInvalidPatternBraceCapture = cerr("invalid uri capturing braces")
// errUnspecifiedBraceCapture - missing path brace capture
const errUnspecifiedBraceCapture = cerr("missing uri parameter")
// ErrUnspecifiedBraceCapture - a parameter brace capture is not specified in the pattern
const ErrUnspecifiedBraceCapture = cerr("capturing brace missing in the path")
// errUndefinedBraceCapture - missing capturing brace definition
const errUndefinedBraceCapture = cerr("missing uri parameter definition")
// ErrMandatoryRename - capture/query parameters must have a rename
const ErrMandatoryRename = cerr("capture and query parameters must have a 'name'")
// errMandatoryRename - capture/query parameters must be renamed
const errMandatoryRename = cerr("uri and query parameters must be renamed")
// ErrUndefinedBraceCapture - a parameter brace capture in the pattern is not defined in parameters
const ErrUndefinedBraceCapture = cerr("capturing brace missing input definition")
// errMissingDescription - a service is missing its description
const errMissingDescription = cerr("missing description")
// ErrMissingDescription - a service is missing its description
const ErrMissingDescription = cerr("missing description")
// errIllegalOptionalURIParam - uri parameter cannot optional
const errIllegalOptionalURIParam = cerr("uri parameter cannot be optional")
// ErrIllegalOptionalURIParam - an URI parameter cannot be optional
const ErrIllegalOptionalURIParam = cerr("URI parameter cannot be optional")
// errOptionalOption - cannot have optional output
const errOptionalOption = cerr("output cannot be optional")
// ErrOptionalOption - an output is optional
const ErrOptionalOption = cerr("output cannot be optional")
// errMissingParamDesc - missing parameter description
const errMissingParamDesc = cerr("missing parameter description")
// ErrMissingParamDesc - a parameter is missing its description
const ErrMissingParamDesc = cerr("missing parameter description")
// errUnknownDataType - unknown parameter datatype
const errUnknownDataType = cerr("unknown parameter datatype")
// ErrUnknownDataType - a parameter has an unknown datatype name
const ErrUnknownDataType = cerr("unknown data type")
// errIllegalParamName - illegal parameter name
const errIllegalParamName = cerr("illegal parameter name")
// ErrIllegalParamName - a parameter has an illegal name
const ErrIllegalParamName = cerr("illegal parameter name")
// errMissingParamType - missing parameter type
const errMissingParamType = cerr("missing parameter type")
// ErrMissingParamType - a parameter has an illegal type
const ErrMissingParamType = cerr("missing parameter type")
// errParamNameConflict - name/rename conflict
const errParamNameConflict = cerr("parameter name conflict")
// ErrParamNameConflict - a parameter has a conflict with its name/rename field
const ErrParamNameConflict = cerr("name conflict for parameter")

15
internal/config/func.go Normal file
View File

@ -0,0 +1,15 @@
package config
import "strings"
// SplitURL without empty sets
func SplitURL(url string) []string {
trimmed := strings.Trim(url, " /\t\r\n")
split := strings.Split(trimmed, "/")
// remove empty set when empty url
if len(split) == 1 && len(split[0]) == 0 {
return []string{}
}
return split
}

View File

@ -11,29 +11,33 @@ type Parameter struct {
Description string `json:"info"`
Type string `json:"type"`
Rename string `json:"name,omitempty"`
Optional bool
// ExtractType is the type the Validator will cast into
// ExtractType is the type of data the datatype returns
ExtractType reflect.Type
// Validator is inferred from the "type" property
// 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
if len(param.Description) < 1 {
return errMissingParamDesc
return ErrMissingParamDesc
}
// invalid type
if len(param.Type) < 1 || param.Type == "?" {
return errMissingParamType
return ErrMissingParamType
}
// optional type
// optional type transform
if param.Type[0] == '?' {
param.Optional = true
param.Type = param.Type[1:]
}
// find validator
// assign the datatype
for _, dtype := range datatypes {
param.Validator = dtype.Build(param.Type, datatypes...)
param.ExtractType = dtype.Type()
@ -42,7 +46,8 @@ func (param *Parameter) validate(datatypes ...datatype.T) error {
}
}
if param.Validator == nil {
return errUnknownDataType
return ErrUnknownDataType
}
return nil
}

165
internal/config/server.go Normal file
View File

@ -0,0 +1,165 @@
package config
import (
"encoding/json"
"fmt"
"io"
"net/http"
"git.xdrm.io/go/aicra/datatype"
)
// Server definition
type Server struct {
Types []datatype.T
Services []*Service
}
// Parse a reader 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 {
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 {
err := service.validate(server.Types...)
if err != nil {
return fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, err)
}
}
// check for collisions
if err := server.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 {
if matches := service.Match(r); matches {
return service
}
}
return nil
}
// collide returns if there is collision between services
func (server *Server) collide() error {
length := len(server.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]
// ignore different method
if aService.Method != bService.Method {
continue
}
aParts := SplitURL(aService.Pattern)
bParts := SplitURL(bService.Pattern)
// not same size
if len(aParts) != len(bParts) {
continue
}
partErrors := make([]error, 0)
// for each part
for pi, aPart := range aParts {
bPart := bParts[pi]
aIsCapture := len(aPart) > 1 && aPart[0] == '{'
bIsCapture := len(bPart) > 1 && bPart[0] == '{'
// both captures -> as we cannot check, consider a collision
if aIsCapture && bIsCapture {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (path %s and %s)", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, aPart, bPart))
continue
}
// no capture -> check equal
if !aIsCapture && !bIsCapture {
if aPart == bPart {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (same path '%s')", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, aPart))
continue
}
}
// A captures B -> check type (B is A ?)
if aIsCapture {
input, exists := aService.Input[aPart]
// fail if no type or no validator
if !exists || input.Validator == nil {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (invalid type for %s)", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, aPart))
continue
}
// fail if not valid
if _, valid := input.Validator(bPart); valid {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (%s captures '%s')", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, aPart, bPart))
continue
}
// B captures A -> check type (A is B ?)
} else if bIsCapture {
input, exists := bService.Input[bPart]
// fail if no type or no validator
if !exists || input.Validator == nil {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (invalid type for %s)", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, bPart))
continue
}
// fail if not valid
if _, valid := input.Validator(aPart); valid {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (%s captures '%s')", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, bPart, aPart))
continue
}
}
partErrors = append(partErrors, nil)
}
// if at least 1 url part does not match -> ok
var firstError error
oneMismatch := false
for _, err := range partErrors {
if err != nil && firstError == nil {
firstError = err
}
if err == nil {
oneMismatch = true
continue
}
}
if !oneMismatch {
return firstError
}
}
}
return nil
}

View File

@ -22,15 +22,15 @@ type Service struct {
Input map[string]*Parameter `json:"in"`
Output map[string]*Parameter `json:"out"`
// Captures contains references to URI parameters from the `Input` map. The format
// of these parameter names is "{paramName}"
// references to url parameters
// format: '/uri/{param}'
Captures []*BraceCapture
// Query contains references to HTTP Query parameters from the `Input` map.
// Query parameters names are "GET@paramName", this map contains escaped names (e.g. "paramName")
// references to Query parameters
// format: 'GET@paranName'
Query map[string]*Parameter
// Form references form parameters from the `Input` map (all but Captures and Query).
// references for form parameters (all but Captures and Query)
Form map[string]*Parameter
}
@ -43,12 +43,16 @@ type BraceCapture struct {
// Match returns if this service would handle this HTTP request
func (svc *Service) Match(req *http.Request) bool {
// method
if req.Method != svc.Method {
return false
}
// check path
if !svc.matchPattern(req.RequestURI) {
return false
}
return true
}
@ -57,12 +61,13 @@ func (svc *Service) matchPattern(uri string) bool {
uriparts := SplitURL(uri)
parts := SplitURL(svc.Pattern)
// fail if size differ
if len(uriparts) != len(parts) {
return false
}
// root url '/'
if len(parts) == 0 && len(uriparts) == 0 {
if len(parts) == 0 {
return true
}
@ -113,7 +118,7 @@ func (svc *Service) validate(datatypes ...datatype.T) error {
// 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
@ -125,7 +130,7 @@ func (svc *Service) validate(datatypes ...datatype.T) error {
// fail if 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)
}
}
@ -144,7 +149,7 @@ func (svc *Service) isMethodAvailable() error {
return nil
}
}
return errUnknownMethod
return ErrUnknownMethod
}
func (svc *Service) isPatternValid() error {
@ -152,13 +157,13 @@ func (svc *Service) isPatternValid() error {
// 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,7 +171,7 @@ 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
@ -187,7 +192,7 @@ func (svc *Service) isPatternValid() error {
// fail on invalid format
if strings.ContainsAny(part, "{}") {
return errInvalidPatternBraceCapture
return ErrInvalidPatternBraceCapture
}
}
@ -206,7 +211,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// for each parameter
for paramName, param := range svc.Input {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, errIllegalParamName)
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
}
// fail if brace capture does not exists in pattern
@ -223,7 +228,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
}
}
if !found {
return fmt.Errorf("%s: %w", paramName, errUnspecifiedBraceCapture)
return fmt.Errorf("%s: %w", paramName, ErrUnspecifiedBraceCapture)
}
iscapture = true
@ -246,7 +251,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// fail if capture or query without rename
if len(param.Rename) < 1 && (iscapture || isquery) {
return fmt.Errorf("%s: %w", paramName, errMandatoryRename)
return fmt.Errorf("%s: %w", paramName, ErrMandatoryRename)
}
// use param name if no rename
@ -261,7 +266,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// capture parameter cannot be optional
if iscapture && param.Optional {
return fmt.Errorf("%s: %w", paramName, errIllegalOptionalURIParam)
return fmt.Errorf("%s: %w", paramName, ErrIllegalOptionalURIParam)
}
// fail on name/rename conflict
@ -275,7 +280,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// 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 fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
}
}
@ -296,7 +301,7 @@ func (svc *Service) validateOutput(types []datatype.T) error {
// for each parameter
for paramName, param := range svc.Output {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, errIllegalParamName)
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
}
// use param name if no rename
@ -310,7 +315,7 @@ func (svc *Service) validateOutput(types []datatype.T) error {
}
if param.Optional {
return fmt.Errorf("%s: %w", paramName, errOptionalOption)
return fmt.Errorf("%s: %w", paramName, ErrOptionalOption)
}
// fail on name/rename conflict
@ -324,7 +329,7 @@ func (svc *Service) validateOutput(types []datatype.T) error {
// 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 fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
}
}

View File

@ -3,48 +3,49 @@ package dynfunc
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// errHandlerNotFunc - handler is not a func
const errHandlerNotFunc = cerr("handler must be a func")
// ErrHandlerNotFunc - handler is not a func
const ErrHandlerNotFunc = cerr("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
const ErrNoServiceForHandler = cerr("no service found for this handler")
// errMissingHandlerArgumentParam - missing params arguments for handler
const errMissingHandlerArgumentParam = cerr("missing handler argument : parameter struct")
// ErrMissingHandlerArgumentParam - missing params arguments for handler
const ErrMissingHandlerArgumentParam = cerr("missing handler argument : parameter struct")
// errUnexpectedInput - input argument is not expected
const errUnexpectedInput = cerr("unexpected input struct")
// ErrUnexpectedInput - input argument is not expected
const ErrUnexpectedInput = cerr("unexpected input struct")
// errMissingHandlerOutput - missing output for handler
const errMissingHandlerOutput = cerr("handler must have at least 1 output")
// ErrMissingHandlerOutput - missing output for handler
const ErrMissingHandlerOutput = cerr("handler must have at least 1 output")
// errMissingHandlerOutputError - missing error output for handler
const errMissingHandlerOutputError = cerr("handler must have its last output of type api.Error")
// ErrMissingHandlerOutputError - missing error output for handler
const ErrMissingHandlerOutputError = cerr("handler must have its last output of type api.Error")
// errMissingRequestArgument - missing request argument for handler
const errMissingRequestArgument = cerr("handler first argument must be of type api.Request")
// ErrMissingRequestArgument - missing request argument for handler
const ErrMissingRequestArgument = cerr("handler first argument must be of type api.Request")
// errMissingParamArgument - missing parameters argument for handler
const errMissingParamArgument = cerr("handler second argument must be a struct")
// ErrMissingParamArgument - missing parameters argument for handler
const ErrMissingParamArgument = cerr("handler second argument must be a struct")
// errUnexportedName - argument is unexported in struct
const errUnexportedName = cerr("unexported name")
// ErrUnexportedName - argument is unexported in struct
const ErrUnexportedName = cerr("unexported name")
// errMissingParamOutput - missing output argument for handler
const errMissingParamOutput = cerr("handler first output must be a *struct")
// ErrMissingParamOutput - missing output argument for handler
const ErrMissingParamOutput = cerr("handler first output must be a *struct")
// errMissingParamFromConfig - missing a parameter in handler struct
const errMissingParamFromConfig = cerr("missing a parameter from configuration")
// ErrMissingParamFromConfig - missing a parameter in handler struct
const ErrMissingParamFromConfig = cerr("missing a parameter from configuration")
// errMissingOutputFromConfig - missing a parameter in handler struct
const errMissingOutputFromConfig = cerr("missing a parameter from configuration")
// ErrMissingOutputFromConfig - missing a parameter in handler struct
const ErrMissingOutputFromConfig = cerr("missing a parameter from configuration")
// errWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct
const errWrongParamTypeFromConfig = cerr("invalid struct field type")
// ErrWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct
const ErrWrongParamTypeFromConfig = cerr("invalid struct field type")
// errMissingHandlerErrorOutput - missing handler output error
const errMissingHandlerErrorOutput = cerr("last output must be of type api.Error")
// ErrMissingHandlerErrorOutput - missing handler output error
const ErrMissingHandlerErrorOutput = cerr("last output must be of type api.Error")

View File

@ -8,12 +8,6 @@ import (
"git.xdrm.io/go/aicra/internal/config"
)
// Handler represents a dynamic api handler
type Handler struct {
spec spec
fn interface{}
}
// Build a handler from a service configuration and a dynamic function
//
// @fn must have as a signature : `func(inputStruct) (*outputStruct, api.Error)`
@ -32,7 +26,7 @@ func Build(fn interface{}, service config.Service) (*Handler, error) {
fnv := reflect.ValueOf(fn)
if fnv.Type().Kind() != reflect.Func {
return nil, errHandlerNotFunc
return nil, ErrHandlerNotFunc
}
if err := h.spec.checkInput(fnv); err != nil {

View File

@ -9,11 +9,6 @@ import (
"git.xdrm.io/go/aicra/internal/config"
)
type spec struct {
Input map[string]reflect.Type
Output map[string]reflect.Type
}
// builds a spec from the configuration service
func makeSpec(service config.Service) spec {
spec := spec{
@ -50,34 +45,34 @@ func (s spec) checkInput(fnv reflect.Value) error {
// no input -> ok
if len(s.Input) == 0 {
if fnt.NumIn() > 0 {
return errUnexpectedInput
return ErrUnexpectedInput
}
return nil
}
if fnt.NumIn() != 1 {
return errMissingHandlerArgumentParam
return ErrMissingHandlerArgumentParam
}
// arg must be a struct
structArg := fnt.In(0)
if structArg.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 := structArg.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, errMissingParamFromConfig)
return fmt.Errorf("%s: %w", name, ErrMissingParamFromConfig)
}
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)
}
}
@ -88,13 +83,13 @@ func (s spec) checkInput(fnv reflect.Value) error {
func (s spec) checkOutput(fnv reflect.Value) error {
fnt := fnv.Type()
if fnt.NumOut() < 1 {
return errMissingHandlerOutput
return ErrMissingHandlerOutput
}
// last output must be api.Error
errOutput := fnt.Out(fnt.NumOut() - 1)
if !errOutput.AssignableTo(reflect.TypeOf(api.ErrorUnknown)) {
return errMissingHandlerErrorOutput
return ErrMissingHandlerErrorOutput
}
// no output -> ok
@ -103,29 +98,29 @@ func (s spec) checkOutput(fnv reflect.Value) error {
}
if fnt.NumOut() != 2 {
return errMissingParamOutput
return ErrMissingParamOutput
}
// fail if first output is not a pointer to struct
structOutputPtr := fnt.Out(0)
if structOutputPtr.Kind() != reflect.Ptr {
return errMissingParamOutput
return ErrMissingParamOutput
}
structOutput := structOutputPtr.Elem()
if structOutput.Kind() != reflect.Struct {
return errMissingParamOutput
return ErrMissingParamOutput
}
// 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 := structOutput.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, errMissingOutputFromConfig)
return fmt.Errorf("%s: %w", name, ErrMissingOutputFromConfig)
}
// ignore types evalutating to nil
@ -134,7 +129,7 @@ func (s spec) checkOutput(fnv reflect.Value) 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

@ -25,7 +25,7 @@ func TestInputCheck(t *testing.T) {
{
Input: map[string]reflect.Type{},
Fn: func(int, string) {},
Err: errUnexpectedInput,
Err: ErrUnexpectedInput,
},
// missing input struct in func
{
@ -33,7 +33,7 @@ func TestInputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() {},
Err: errMissingHandlerArgumentParam,
Err: ErrMissingHandlerArgumentParam,
},
// input not a struct
{
@ -41,7 +41,7 @@ func TestInputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(int) {},
Err: errMissingParamArgument,
Err: ErrMissingParamArgument,
},
// unexported param name
{
@ -49,7 +49,7 @@ func TestInputCheck(t *testing.T) {
"test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{}) {},
Err: errUnexportedName,
Err: ErrUnexportedName,
},
// input field missing
{
@ -57,7 +57,7 @@ func TestInputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{}) {},
Err: errMissingParamFromConfig,
Err: ErrMissingParamFromConfig,
},
// input field invalid type
{
@ -65,7 +65,7 @@ func TestInputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{ Test1 string }) {},
Err: errWrongParamTypeFromConfig,
Err: ErrWrongParamTypeFromConfig,
},
// input field valid type
{
@ -115,13 +115,13 @@ func TestOutputCheck(t *testing.T) {
{
Output: map[string]reflect.Type{},
Fn: func() {},
Err: errMissingHandlerOutput,
Err: ErrMissingHandlerOutput,
},
// no input -> with last type not api.Error
{
Output: map[string]reflect.Type{},
Fn: func() bool { return true },
Err: errMissingHandlerErrorOutput,
Err: ErrMissingHandlerErrorOutput,
},
// no input -> with api.Error
{
@ -141,7 +141,7 @@ func TestOutputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() api.Error { return api.ErrorSuccess },
Err: errMissingParamOutput,
Err: ErrMissingParamOutput,
},
// output not a pointer
{
@ -149,7 +149,7 @@ func TestOutputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (int, api.Error) { return 0, api.ErrorSuccess },
Err: errMissingParamOutput,
Err: ErrMissingParamOutput,
},
// output not a pointer to struct
{
@ -157,7 +157,7 @@ func TestOutputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*int, api.Error) { return nil, api.ErrorSuccess },
Err: errMissingParamOutput,
Err: ErrMissingParamOutput,
},
// unexported param name
{
@ -165,7 +165,7 @@ func TestOutputCheck(t *testing.T) {
"test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Error) { return nil, api.ErrorSuccess },
Err: errUnexportedName,
Err: ErrUnexportedName,
},
// output field missing
{
@ -173,7 +173,7 @@ func TestOutputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Error) { return nil, api.ErrorSuccess },
Err: errMissingParamFromConfig,
Err: ErrMissingParamFromConfig,
},
// output field invalid type
{
@ -181,7 +181,7 @@ func TestOutputCheck(t *testing.T) {
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 string }, api.Error) { return nil, api.ErrorSuccess },
Err: errWrongParamTypeFromConfig,
Err: ErrWrongParamTypeFromConfig,
},
// output field valid type
{

14
internal/dynfunc/types.go Normal file
View File

@ -0,0 +1,14 @@
package dynfunc
import "reflect"
// Handler represents a dynamic api handler
type Handler struct {
spec spec
fn interface{}
}
type spec struct {
Input map[string]reflect.Type
Output map[string]reflect.Type
}

View File

@ -13,19 +13,19 @@ func (comp *Component) parseHeaders(_raw []byte) error {
// 1. Extract lines
_lines := strings.Split(string(_raw), "\n")
if len(_lines) < 2 {
return errNoHeader
return ErrNoHeader
}
// 2. trim each line + remove 'Content-Disposition' prefix
header := strings.Trim(_lines[0], " \t\r")
if !strings.HasPrefix(header, "Content-Disposition: form-data;") {
return errNoHeader
return ErrNoHeader
}
header = strings.Trim(header[len("Content-Disposition: form-data;"):], " \t\r")
if len(header) < 1 {
return errNoHeader
return ErrNoHeader
}
// 3. Extract each key-value pair

View File

@ -71,11 +71,11 @@ func (reader *Reader) Parse() error {
name := comp.GetHeader("name")
if len(name) < 1 {
return errMissingDataName
return ErrMissingDataName
}
if _, nameUsed := reader.Data[name]; nameUsed {
return errDataNameConflict
return ErrDataNameConflict
}
reader.Data[name] = comp

View File

@ -196,8 +196,8 @@ func TestNoName(t *testing.T) {
return
}
if err = mpr.Parse(); err != errMissingDataName {
t.Errorf("expected the error <%s>, got <%s>", errMissingDataName, err)
if err = mpr.Parse(); err != ErrMissingDataName {
t.Errorf("expected the error <%s>, got <%s>", ErrMissingDataName, err)
return
}
})
@ -238,8 +238,8 @@ func TestNoHeader(t *testing.T) {
return
}
if err = mpr.Parse(); err != errNoHeader {
t.Errorf("expected the error <%s>, got <%s>", errNoHeader, err)
if err = mpr.Parse(); err != ErrNoHeader {
t.Errorf("expected the error <%s>, got <%s>", ErrNoHeader, err)
return
}
})
@ -274,8 +274,8 @@ facebook.com
t.Fatalf("unexpected error <%s>", err)
}
if err = mpr.Parse(); err != errDataNameConflict {
t.Fatalf("expected the error <%s>, got <%s>", errDataNameConflict, err)
if err = mpr.Parse(); err != ErrDataNameConflict {
t.Fatalf("expected the error <%s>, got <%s>", ErrDataNameConflict, err)
}
}

View File

@ -3,18 +3,19 @@ package multipart
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// errMissingDataName is set when a multipart variable/file has no name="..."
const errMissingDataName = cerr("data has no name")
// ErrMissingDataName is set when a multipart variable/file has no name="..."
const ErrMissingDataName = cerr("data has no name")
// errDataNameConflict is set when a multipart variable/file name is already used
const errDataNameConflict = cerr("data name conflict")
// ErrDataNameConflict is set when a multipart variable/file name is already used
const ErrDataNameConflict = cerr("data name conflict")
// errNoHeader is set when a multipart variable/file has no (valid) header
const errNoHeader = cerr("data has no header")
// ErrNoHeader is set when a multipart variable/file has no (valid) header
const ErrNoHeader = cerr("data has no header")
// Component represents a multipart variable/file
type Component struct {

View File

@ -13,29 +13,33 @@ import (
"strings"
)
// T represents all data that can be caught from an http request for a specific
// configuration Service; it features:
// Set represents all data that can be caught:
// - URI (from the URI)
// - GET (standard url data)
// - GET (default url data)
// - POST (from json, form-data, url-encoded)
// - 'application/json' => key-value pair is parsed as json into the map
// - 'application/x-www-form-urlencoded' => standard parameters as QUERY parameters
// - 'multipart/form-data' => parse form-data format
type T struct {
type Set struct {
service *config.Service
Data map[string]interface{}
// contains URL+GET+FORM data with prefixes:
// - FORM: no prefix
// - URL: '{uri_var}'
// - GET: 'GET@' followed by the key in GET
Data map[string]interface{}
}
// New creates a new empty store.
func New(service *config.Service) *T {
return &T{
func New(service *config.Service) *Set {
return &Set{
service: service,
Data: map[string]interface{}{},
Data: make(map[string]interface{}),
}
}
// GetURI parameters
func (i *T) GetURI(req http.Request) error {
// ExtractURI fills 'Set' with creating pointers inside 'Url'
func (i *Set) ExtractURI(req http.Request) error {
uriparts := config.SplitURL(req.URL.RequestURI())
for _, capture := range i.service.Captures {
@ -50,97 +54,122 @@ func (i *T) GetURI(req http.Request) error {
return fmt.Errorf("%s: %w", capture.Name, ErrUnknownType)
}
// parse parameter
parsed := parseParameter(value)
// check type
cast, valid := capture.Ref.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", capture.Name, ErrInvalidType)
}
// store cast value in 'Set'
i.Data[capture.Ref.Rename] = cast
}
return nil
}
// GetQuery data from the url query parameters
func (i *T) GetQuery(req http.Request) error {
// ExtractQuery data from the url query parameters
func (i *Set) ExtractQuery(req http.Request) error {
query := req.URL.Query()
for name, param := range i.service.Query {
value, exist := query[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// parse parameter
parsed := parseParameter(value)
// check type
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
return nil
}
// GetForm parameters the from request
// ExtractForm data from request
//
// - parse 'form-data' if not supported for non-POST requests
// - parse 'x-www-form-urlencoded'
// - parse 'application/json'
func (i *T) GetForm(req http.Request) error {
func (i *Set) ExtractForm(req http.Request) error {
// ignore GET method
if req.Method == http.MethodGet {
return nil
}
ct := req.Header.Get("Content-Type")
switch {
case strings.HasPrefix(ct, "application/json"):
contentType := req.Header.Get("Content-Type")
// parse json
if strings.HasPrefix(contentType, "application/json") {
return i.parseJSON(req)
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"):
return i.parseUrlencoded(req)
case strings.HasPrefix(ct, "multipart/form-data; boundary="):
return i.parseMultipart(req)
default:
return nil
}
// parse urlencoded
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
return i.parseUrlencoded(req)
}
// parse multipart
if strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
return i.parseMultipart(req)
}
// nothing to parse
return nil
}
// parseJSON parses JSON from the request body inside 'Form'
// and 'Set'
func (i *T) parseJSON(req http.Request) error {
var parsed map[string]interface{}
func (i *Set) parseJSON(req http.Request) error {
parsed := make(map[string]interface{}, 0)
decoder := json.NewDecoder(req.Body)
err := decoder.Decode(&parsed)
if err == io.EOF {
return nil
}
if err != nil {
if err := decoder.Decode(&parsed); err != nil {
if err == io.EOF {
return nil
}
return fmt.Errorf("%s: %w", err, ErrInvalidJSON)
}
for name, param := range i.service.Form {
value, exist := parsed[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// fail on invalid type
cast, valid := param.Validator(value)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
@ -149,7 +178,8 @@ func (i *T) parseJSON(req http.Request) error {
// parseUrlencoded parses urlencoded from the request body inside 'Form'
// and 'Set'
func (i *T) parseUrlencoded(req http.Request) error {
func (i *Set) parseUrlencoded(req http.Request) error {
// use http.Request interface
if err := req.ParseForm(); err != nil {
return err
}
@ -157,19 +187,26 @@ func (i *T) parseUrlencoded(req http.Request) error {
for name, param := range i.service.Form {
value, exist := req.PostForm[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// parse parameter
parsed := parseParameter(value)
// check type
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
@ -178,37 +215,46 @@ func (i *T) parseUrlencoded(req http.Request) error {
// parseMultipart parses multi-part from the request body inside 'Form'
// and 'Set'
func (i *T) parseMultipart(req http.Request) error {
func (i *Set) parseMultipart(req http.Request) error {
// 1. create reader
boundary := req.Header.Get("Content-Type")[len("multipart/form-data; boundary="):]
mpr, err := multipart.NewReader(req.Body, boundary)
if err == io.EOF {
return nil
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
err = mpr.Parse()
if err != nil {
// 2. parse multipart
if err = mpr.Parse(); err != nil {
return fmt.Errorf("%s: %w", err, ErrInvalidMultipart)
}
for name, param := range i.service.Form {
component, exist := mpr.Data[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// parse parameter
parsed := parseParameter(string(component.Data))
// fail on invalid type
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
@ -220,47 +266,58 @@ func (i *T) parseMultipart(req http.Request) error {
// - []string : return array of json elements
// - string : return json if valid, else return raw string
func parseParameter(data interface{}) interface{} {
rt := reflect.TypeOf(data)
rv := reflect.ValueOf(data)
dtype := reflect.TypeOf(data)
dvalue := reflect.ValueOf(data)
switch rt.Kind() {
switch dtype.Kind() {
// []string -> recursive
/* (1) []string -> recursive */
case reflect.Slice:
if rv.Len() == 0 {
// 1. ignore empty
if dvalue.Len() == 0 {
return data
}
slice := make([]interface{}, rv.Len())
for i, l := 0, rv.Len(); i < l; i++ {
element := rv.Index(i)
slice[i] = parseParameter(element.Interface())
}
return slice
// 2. parse each element recursively
result := make([]interface{}, dvalue.Len())
// string -> parse as json
// keep as string if invalid json
for i, l := 0, dvalue.Len(); i < l; i++ {
element := dvalue.Index(i)
result[i] = parseParameter(element.Interface())
}
return result
/* (2) string -> parse */
case reflect.String:
var cast interface{}
wrapper := fmt.Sprintf("{\"wrapped\":%s}", rv.String())
err := json.Unmarshal([]byte(wrapper), &cast)
// build json wrapper
wrapper := fmt.Sprintf("{\"wrapped\":%s}", dvalue.String())
// try to parse as json
var result interface{}
err := json.Unmarshal([]byte(wrapper), &result)
// return if success
if err != nil {
return rv.String()
return dvalue.String()
}
mapval, ok := cast.(map[string]interface{})
mapval, ok := result.(map[string]interface{})
if !ok {
return rv.String()
return dvalue.String()
}
wrapped, ok := mapval["wrapped"]
if !ok {
return rv.String()
return dvalue.String()
}
return wrapped
// any type -> unchanged
default:
return rv.Interface()
}
/* (3) NIL if unknown type */
return dvalue.Interface()
}

View File

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

View File

@ -18,14 +18,14 @@ func (server Server) ServeHTTP(res http.ResponseWriter, req *http.Request) {
// 1. find a matching service in the config
service := server.conf.Find(req)
if service == nil {
errorHandler(api.ErrorUnknownService).ServeHTTP(res, req)
errorHandler(api.ErrorUnknownService)
return
}
// 2. extract request data
dataset, err := extractRequestData(service, *req)
if err != nil {
errorHandler(api.ErrorMissingParam).ServeHTTP(res, req)
errorHandler(api.ErrorMissingParam)
return
}
@ -39,7 +39,7 @@ func (server Server) ServeHTTP(res http.ResponseWriter, req *http.Request) {
// 4. fail if found no handler
if handler == nil {
errorHandler(api.ErrorUncallableService).ServeHTTP(res, req)
errorHandler(api.ErrorUncallableService)
return
}
@ -76,23 +76,23 @@ func errorHandler(err api.Error) http.HandlerFunc {
}
}
func extractRequestData(service *config.Service, req http.Request) (*reqdata.T, error) {
func extractRequestData(service *config.Service, req http.Request) (*reqdata.Set, error) {
dataset := reqdata.New(service)
// 3. extract URI data
err := dataset.GetURI(req)
err := dataset.ExtractURI(req)
if err != nil {
return nil, err
}
// 4. extract query data
err = dataset.GetQuery(req)
err = dataset.ExtractQuery(req)
if err != nil {
return nil, err
}
// 5. extract form/json data
err = dataset.GetForm(req)
err = dataset.ExtractForm(req)
if err != nil {
return nil, err
}