Compare commits

...

8 Commits

Author SHA1 Message Date
Adrien Marquès f3127edde1 Merge pull request 'improvements, fixes, update to go 1.16' (#16) from refactor/go1.16 into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2021-03-28 17:44:58 +00:00
Adrien Marquès 546130cfd0
update: readme
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-03-28 19:41:25 +02:00
Adrien Marquès 11aa9f0a0f
fix: global handler recoverer 2021-03-28 19:05:43 +02:00
Adrien Marquès 468a09be8d
update: rename 'Server' into 'Handler' 2021-03-28 19:03:16 +02:00
Adrien Marquès 10e59acdae
fix: test string-int concatenation warnings 2021-03-28 18:50:25 +02:00
Adrien Marquès 334f1fba21
feat: add builder helpers Get(), Post(), Put(), Delete() that proxies to Bind() 2021-03-28 18:50:04 +02:00
Adrien Marquès 6039fbb41f
update: api.Err system
- rename 'Error' to 'Err'
 - use struct instead of int as underlying type ; remove dependency on 2 maps for reason and HTTP status codes
 - remove useless json implementation
2021-03-28 18:49:23 +02:00
Adrien Marquès a9acfca089
update go.mod to go 1.16 2021-03-28 18:06:09 +02:00
13 changed files with 447 additions and 393 deletions

243
README.md
View File

@ -6,50 +6,59 @@
[![Go doc](https://godoc.org/git.xdrm.io/go/aicra?status.svg)](https://godoc.org/git.xdrm.io/go/aicra)
[![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra)
----
Aicra is a *configuration-driven* REST API engine written in Go.
`aicra` is a lightweight and idiomatic API engine for building Go services. It's especially good at helping you write large REST API services that remain maintainable as your project grows.
Most of the management is done for you using 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
The focus of the project is to allow you to build a fully featured REST API in an elegant, comfortable and inexpensive way. This is achieved by using a configuration file to drive the server. The configuration format describes the whole API: routes, input arguments, expected output, permissions, etc.
> A example project is available [here](https://git.xdrm.io/go/articles-api)
TL;DR: `aicra` is a fast configuration-driven REST API engine.
## Table of contents
Repetitive tasks is automatically processed by `aicra` from your configuration file, you just have to implement your handlers.
The engine automates :
- catching input data (_url, query, form-data, json, url-encoded_)
- handling missing input data (_required arguments_)
- handling input data validation
- checking for mandatory output parameters
- checking for missing method implementations
- checking for handler signature (input and output arguments)
> An example project is available [here](https://git.xdrm.io/go/articles-api)
### 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)
- [III/ Change Log](#iii-change-log)
* [Installation](#installation)
- [Usage](#usage)
- [Create a server](#create-a-server)
- [Create a handler](#create-a-handler)
- [Configuration](#configuration)
- [Global format](#global-format)
* [Input section](#input-section)
+ [Format](#format)
- [Example](#example)
- [Changelog](#changelog)
<!-- tocstop -->
## I/ Installation
## 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). The package has not been tested under **go1.14**.
```bash
go get -u git.xdrm.io/go/aicra/cmd/aicra
go get -u git.xdrm.io/go/aicra
```
The library should now be available as `git.xdrm.io/go/aicra` in your imports.
# Usage
## II/ Usage
#### Create a server
### 1) Build a server
Here is some sample code that builds and sets up an aicra server using your api configuration file.
The code below sets up and creates an HTTP server from the `api.json` configuration.
```go
package main
@ -65,22 +74,20 @@ import (
)
func main() {
builder := &aicra.Builder{}
// add datatypes your api uses
// register available validators
builder.AddType(builtin.BoolDataType{})
builder.AddType(builtin.UintDataType{})
builder.AddType(builtin.StringDataType{})
// load your configuration
config, err := os.Open("./api.json")
if err != nil {
log.Fatalf("cannot open config: %s", err)
}
// pass your configuration
err = builder.Setup(config)
config.Close()
config.Close() // free config file
if err != nil {
log.Fatalf("invalid config: %s", err)
}
@ -89,87 +96,123 @@ func main() {
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()
// build the handler and start listening
handler, err := builder.Build()
if err != nil {
log.Fatalf("cannot build server: %s", err)
log.Fatalf("cannot build handler: %s", err)
}
http.ListenAndServe("localhost:8080", server)
http.ListenAndServe("localhost:8080", handler)
}
```
If you want to use HTTPS, you can configure your own `http.Server`.
```go
func main() {
server := &http.Server{
Addr: "localhost:8080",
TLSConfig: tls.Config{},
// aicra handler
Handler: handler,
}
server.ListenAndServe()
}
```
Here is an example handler
#### Create a handler
The code below implements a simple handler.
```go
// "in": {
// "Input1": { "info": "...", "type": "int" },
// "Input2": { "info": "...", "type": "?string" }
// },
type req struct{
Param1 int
Param3 *string // optional are pointers
Input1 int
Input2 *string // optional are pointers
}
// "out": {
// "Output1": { "info": "...", "type": "string" },
// "Output2": { "info": "...", "type": "bool" }
// }
type res struct{
Output1 string
Output2 bool
}
func myHandler(r req) (*res, api.Error) {
func myHandler(r req) (*res, api.Err) {
err := doSomething()
if err != nil {
return nil, api.ErrorFailure
return nil, api.ErrFailure
}
return &res{"out1", true}, api.ErrSuccess
}
```
If your handler signature does not match the configuration exactly, the server will print out the error and will not start.
The `api.Err` type automatically maps to HTTP status codes and error descriptions that will be sent to the client as json; client will then always have to manage the same format.
```json
{
"error": {
"code": 0,
"reason": "all right"
}
return &res{}, api.ErrorSuccess
}
```
### 2) API Configuration
# 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 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 [configuration](https://git.xdrm.io/go/articles-api/src/master/api.json).
The configuration file defines :
- routes and their methods
- every input for each method (called *argument*)
- every input argument for each method
- every output for each method
- scope permissions (list of permissions needed by clients)
- scope permissions (list of permissions required by clients)
- input policy :
- type of argument (_c.f. data types_)
- required/optional
- variable renaming
#### Format
#### Global 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.
The root of the json file must feature 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 |
- `info`: Short description of the method
- `in`: List of arguments that the clients will have to provide. [Read more](#input-arguments).
- `out`: List of output data that your controllers will output. It has the same syntax as the `in` field but optional parameters are not allowed.
- `scope`: A 2-dimensional array of permissions. The first level means **or**, the second means **and**. It allows to combine permissions in complex ways.
- Example: `[["A", "B"], ["C", "D"]]` translates to : this method requires users to have permissions (A **and** B) **or** (C **and** D)
### Input Arguments
##### Input section
Input arguments defines what data from the HTTP request the method needs. Aicra is able to extract 3 types of data :
Input arguments defines what data from the HTTP request the method requires. `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.
- **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.
- **JSON** - data send inside the body as a json object ; each key being a variable name, each value its content. Note that the HTTP header '**Content-Type**' must be set to `application/json` for the API to use it.
- **Query** - data at the end of the URL following the standard [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **Form** - data send from the body of the request ; it can be extracted in 3 ways:
- _URL encoded_: data send in the body following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- _Multipart_: data send in the body with a dedicated [format](https://tools.ietf.org/html/rfc2388#section-3). This format can be quite heavy but allows to transmit data as well as files.
- _JSON_: data send in the body as a json object ; each key being a variable name, each value its content. Note that the 'Content-Type' header must be set to `application/json` for the API to use it.
> For Form data, the 3 methods can be used at once for different arguments; for instance if you need to send a file to an aicra server as well as other parameters, you can use JSON for parameters and Multipart for the file.
###### Format
The `in` field describes as list of arguments where the key is the argument name, and the value defines how to manage the variable.
Variable names from **URI** or **Query** must be named accordingly :
- an **URI** variable `{var}` from your request route must be named `{var}` in the `in` section
- a variable `var` in the **Query** has to be named `GET@var` in the `in` section
#### 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.
> Variable names from **URI** or **Query** must be named accordingly :
>
> - the **URI** variable `{id}` from your request route must be named `{id}`.
> - the variable `somevar` in the **Query** has to be names `GET@somevar`.
**Example**
In this example we want 3 arguments :
#### Example
```json
[
{
@ -178,9 +221,9 @@ In this example we want 3 arguments :
"scope": [["author"]],
"info": "updates an article",
"in": {
"{id}": { "info": "article id", "type": "int", "name": "article_id" },
"GET@title": { "info": "new article title", "type": "?string", "name": "title" },
"content": { "info": "new article content", "type": "string" }
"{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" }
},
"out": {
"id": { "info": "updated article id", "type": "uint" },
@ -191,6 +234,54 @@ 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`.
1. `{id}` is extracted from the end of the URI and is a number compliant with the `int` type checker. It is renamed `id`, this new name will be sent to the handler.
2. `GET@title` is extracted from 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`.
3. `content` can be extracted from json, multipart or url-encoded data; 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 its original name `content`.
# Changelog
- [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/{uid}/post/{id}`*)
- [x] useful http methods: GET, POST, PUT, DELETE
- [ ] add support for PATCH method
- [ ] add support for OPTIONS method
- [ ] it might be interesting to generate the list of allowed methods from the configuration
- [ ] add CORS support
- [x] manage request data extraction:
- [x] URL slash-separated strings
- [x] HTTP Query named parameters
- [x] manage array format
- [x] body parameters
- [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. you can add custom types alongside built-in ones*)
- [x] built-in types
- [x] `any` - matches any value
- [x] `int` - see go types
- [x] `uint` - see go types
- [x] `float` - see go types
- [x] `string` - any text
- [x] `string(len)` - any string with a length of exactly `len` characters
- [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 handler implementation
- [x] response interface
- [x] generic errors that automatically formats into response
- [x] builtin errors
- [x] possibility to add custom errors
- [x] check for missing handlers when building the handler
- [x] check handlers not matching a route in the configuration at server boot
- [x] specific configuration format errors qt server boot
- [x] statically typed handlers - avoids having to check every input and its type (_which is used by context.Context for instance_)
- [x] using reflection to use structs as input and output arguments to match the configuration
- [x] check for input and output arguments structs at server boot
- [x] check for unavailable types in configuration at server boot
- [x] recover panics from handlers
- [ ] improve tests and coverage

View File

@ -3,129 +3,82 @@ package api
import "net/http"
var (
// ErrorUnknown represents any error which cause is unknown.
// ErrUnknown represents any error which cause is unknown.
// It might also be used for debug purposes as this error
// has to be used the less possible
ErrorUnknown Error = -1
ErrUnknown = Err{-1, "unknown error", http.StatusOK}
// ErrorSuccess represents a generic successful service execution
ErrorSuccess Error = 0
// ErrSuccess represents a generic successful service execution
ErrSuccess = Err{0, "all right", http.StatusOK}
// ErrorFailure is the most generic error
ErrorFailure Error = 1
// ErrFailure is the most generic error
ErrFailure = Err{1, "it failed", http.StatusInternalServerError}
// ErrorNoMatchFound has to be set when trying to fetch data and there is no result
ErrorNoMatchFound Error = 2
// ErrNoMatchFound is set when trying to fetch data and there is no result
ErrNoMatchFound = Err{2, "resource not found", http.StatusOK}
// ErrorAlreadyExists has to be set when trying to insert data, but identifiers or
// ErrAlreadyExists is set when trying to insert data, but identifiers or
// unique fields already exists
ErrorAlreadyExists Error = 3
ErrAlreadyExists = Err{3, "already exists", http.StatusOK}
// ErrorCreation has to be set when there is a creation/insert error
ErrorCreation Error = 4
// ErrCreation is set when there is a creation/insert error
ErrCreation = Err{4, "create error", http.StatusOK}
// ErrorModification has to be set when there is an update/modification error
ErrorModification Error = 5
// ErrModification is set when there is an update/modification error
ErrModification = Err{5, "update error", http.StatusOK}
// ErrorDeletion has to be set when there is a deletion/removal error
ErrorDeletion Error = 6
// ErrDeletion is set when there is a deletion/removal error
ErrDeletion = Err{6, "delete error", http.StatusOK}
// ErrorTransaction has to be set when there is a transactional error
ErrorTransaction Error = 7
// ErrTransaction is set when there is a transactional error
ErrTransaction = Err{7, "transactional error", http.StatusOK}
// ErrorUpload has to be set when a file upload failed
ErrorUpload Error = 100
// ErrUpload is set when a file upload failed
ErrUpload = Err{100, "upload failed", http.StatusInternalServerError}
// ErrorDownload has to be set when a file download failed
ErrorDownload Error = 101
// ErrDownload is set when a file download failed
ErrDownload = Err{101, "download failed", http.StatusInternalServerError}
// MissingDownloadHeaders has to be set when the implementation
// MissingDownloadHeaders is set when the implementation
// of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its HEADER field
MissingDownloadHeaders Error = 102
MissingDownloadHeaders = Err{102, "download headers are missing", http.StatusBadRequest}
// ErrorMissingDownloadBody has to be set when the implementation
// ErrMissingDownloadBody is set when the implementation
// of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its BODY field
ErrorMissingDownloadBody Error = 103
ErrMissingDownloadBody = Err{103, "download body is missing", http.StatusBadRequest}
// ErrorUnknownService is set when there is no service matching
// ErrUnknownService is set when there is no service matching
// the http request URI.
ErrorUnknownService Error = 200
ErrUnknownService = Err{200, "unknown service", http.StatusServiceUnavailable}
// ErrorUncallableService is set when there the requested service's
// ErrUncallableService is set when there the requested service's
// implementation (plugin file) is not found/callable
ErrorUncallableService Error = 202
ErrUncallableService = Err{202, "uncallable service", http.StatusServiceUnavailable}
// ErrorNotImplemented is set when a handler is not implemented yet
ErrorNotImplemented Error = 203
// ErrNotImplemented is set when a handler is not implemented yet
ErrNotImplemented = Err{203, "not implemented", http.StatusNotImplemented}
// ErrorPermission is set when there is a permission error by default
// ErrPermission is set when there is a permission error by default
// the api returns a permission error when the current scope (built
// by middlewares) does not match the scope required in the config.
// You can add your own permission policy and use this error
ErrorPermission Error = 300
ErrPermission = Err{300, "permission error", http.StatusUnauthorized}
// ErrorToken has to be set (usually in authentication middleware) to tell
// ErrToken is set (usually in authentication middleware) to tell
// the user that this authentication token is expired or invalid
ErrorToken Error = 301
ErrToken = Err{301, "token error", http.StatusForbidden}
// ErrorMissingParam is set when a *required* parameter is missing from the
// ErrMissingParam is set when a *required* parameter is missing from the
// http request
ErrorMissingParam Error = 400
ErrMissingParam = Err{400, "missing parameter", http.StatusBadRequest}
// ErrorInvalidParam is set when a given parameter fails its type check as
// ErrInvalidParam is set when a given parameter fails its type check as
// defined in the config file.
ErrorInvalidParam Error = 401
ErrInvalidParam = Err{401, "invalid parameter", http.StatusBadRequest}
// ErrorInvalidDefaultParam is set when an optional parameter's default value
// ErrInvalidDefaultParam is set when an optional parameter's default value
// does not match its type.
ErrorInvalidDefaultParam Error = 402
ErrInvalidDefaultParam = Err{402, "invalid default param", http.StatusBadRequest}
)
var errorReasons = map[Error]string{
ErrorUnknown: "unknown error",
ErrorSuccess: "all right",
ErrorFailure: "it failed",
ErrorNoMatchFound: "resource not found",
ErrorAlreadyExists: "already exists",
ErrorCreation: "create error",
ErrorModification: "update error",
ErrorDeletion: "delete error",
ErrorTransaction: "transactional error",
ErrorUpload: "upload failed",
ErrorDownload: "download failed",
MissingDownloadHeaders: "download headers are missing",
ErrorMissingDownloadBody: "download body is missing",
ErrorUnknownService: "unknown service",
ErrorUncallableService: "uncallable service",
ErrorNotImplemented: "not implemented",
ErrorPermission: "permission error",
ErrorToken: "token error",
ErrorMissingParam: "missing parameter",
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

@ -1,49 +1,21 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
)
// Error represents an http response error following the api format.
// Err represents an http response error following the api format.
// These are used by the services to set the *execution status*
// directly into the response as JSON alongside response output fields.
type Error int
func (e Error) Error() string {
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
}
// MarshalJSON implements encoding/json.Marshaler interface
func (e Error) MarshalJSON() ([]byte, error) {
// use unknown error if no reason
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.MarshalJSON()
}
// format to proper struct
formatted := struct {
type Err struct {
// error code (unique)
Code int `json:"code"`
// error small description
Reason string `json:"reason"`
}{
Code: int(e),
Reason: reason,
// associated HTTP status
Status int
}
return json.Marshal(formatted)
func (e Err) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Reason)
}

View File

@ -13,7 +13,7 @@ type Response struct {
Data ResponseData
Status int
Headers http.Header
err Error
err Err
}
// EmptyResponse creates an empty response.
@ -21,13 +21,13 @@ func EmptyResponse() *Response {
return &Response{
Status: http.StatusOK,
Data: make(ResponseData),
err: ErrorFailure,
err: ErrFailure,
Headers: make(http.Header),
}
}
// WithError sets the error
func (res *Response) WithError(err Error) *Response {
func (res *Response) WithError(err Err) *Response {
res.err = err
return res
}
@ -53,7 +53,7 @@ func (res *Response) MarshalJSON() ([]byte, error) {
}
func (res *Response) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(res.err.Status())
w.WriteHeader(res.err.Status)
encoded, err := json.Marshal(res)
if err != nil {
return err

View File

@ -16,7 +16,7 @@ type Builder struct {
handlers []*apiHandler
}
// represents an server handler
// represents an api handler (method-pattern combination)
type apiHandler struct {
Method string
Path string
@ -34,7 +34,7 @@ func (b *Builder) AddType(t datatype.T) {
b.conf.Types = append(b.conf.Types, t)
}
// Setup the builder with its api definition
// Setup the builder with its api definition file
// panics if already setup
func (b *Builder) Setup(r io.Reader) error {
if b.conf == nil {
@ -46,13 +46,13 @@ func (b *Builder) Setup(r io.Reader) error {
return b.conf.Parse(r)
}
// Bind a dynamic handler to a REST service
// Bind a dynamic handler to a REST service (method and pattern)
func (b *Builder) Bind(method, path string, fn interface{}) error {
if b.conf.Services == nil {
return errNotSetup
}
// find associated service
// find associated service from config
var service *config.Service
for _, s := range b.conf.Services {
if method == s.Method && path == s.Pattern {
@ -65,7 +65,7 @@ func (b *Builder) Bind(method, path string, fn interface{}) error {
return fmt.Errorf("%s '%s': %w", method, path, errUnknownService)
}
dyn, err := dynfunc.Build(fn, *service)
var dyn, err = dynfunc.Build(fn, *service)
if err != nil {
return fmt.Errorf("%s '%s' handler: %w", method, path, err)
}
@ -79,21 +79,41 @@ func (b *Builder) Bind(method, path string, fn interface{}) error {
return nil
}
// Get is equivalent to Bind(http.MethodGet)
func (b *Builder) Get(path string, fn interface{}) error {
return b.Bind(http.MethodGet, path, fn)
}
// Post is equivalent to Bind(http.MethodPost)
func (b *Builder) Post(path string, fn interface{}) error {
return b.Bind(http.MethodPost, path, fn)
}
// Put is equivalent to Bind(http.MethodPut)
func (b *Builder) Put(path string, fn interface{}) error {
return b.Bind(http.MethodPut, path, fn)
}
// Delete is equivalent to Bind(http.MethodDelete)
func (b *Builder) Delete(path string, fn interface{}) error {
return b.Bind(http.MethodDelete, path, fn)
}
// Build a fully-featured HTTP server
func (b Builder) Build() (http.Handler, error) {
for _, service := range b.conf.Services {
var hasAssociatedHandler bool
var isHandled bool
for _, handler := range b.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
hasAssociatedHandler = true
isHandled = true
break
}
}
if !hasAssociatedHandler {
if !isHandled {
return nil, fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, errMissingHandler)
}
}
return Server(b), nil
return Handler(b), nil
}

2
go.mod
View File

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

107
handler.go Normal file
View File

@ -0,0 +1,107 @@
package aicra
import (
"log"
"net/http"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/reqdata"
)
// Handler wraps the builder to handle requests
type Handler Builder
// ServeHTTP implements http.Handler
func (s Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
if rc := recover(); rc != nil {
log.Printf("recovering request: %s\n", rc)
// try to send error response
api.EmptyResponse().WithError(api.ErrUncallableService).ServeHTTP(w, r)
}
}()
defer r.Body.Close()
// 1. find a matching service from config
var service = s.conf.Find(r)
if service == nil {
handleError(api.ErrUnknownService, w, r)
return
}
// 2. extract request data
var input, err = extractInput(service, *r)
if err != nil {
handleError(api.ErrMissingParam, w, r)
return
}
// 3. find a matching handler
var handler *apiHandler
for _, h := range s.handlers {
if h.Method == service.Method && h.Path == service.Pattern {
handler = h
}
}
// 4. fail on no matching handler
if handler == nil {
handleError(api.ErrUncallableService, w, r)
return
}
// 5. pass execution to the handler
var outData, outErr = handler.dyn.Handle(input.Data)
// 6. build res from returned data
var res = api.EmptyResponse().WithError(outErr)
for key, value := range outData {
// find original name from 'rename' field
for name, param := range service.Output {
if param.Rename == key {
res.SetData(name, value)
}
}
}
// 7. apply headers
w.Header().Set("Content-Type", "application/json; charset=utf-8")
for key, values := range res.Headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
res.ServeHTTP(w, r)
}
func handleError(err api.Err, w http.ResponseWriter, r *http.Request) {
var response = api.EmptyResponse().WithError(err)
response.ServeHTTP(w, r)
}
func extractInput(service *config.Service, req http.Request) (*reqdata.T, error) {
var dataset = reqdata.New(service)
// URI data
var err = dataset.GetURI(req)
if err != nil {
return nil, err
}
// query data
err = dataset.GetQuery(req)
if err != nil {
return nil, err
}
// form/json data
err = dataset.GetForm(req)
if err != nil {
return nil, err
}
return dataset, nil
}

View File

@ -23,7 +23,7 @@ const errUnexpectedInput = cerr("unexpected input struct")
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")
const errMissingHandlerOutputError = cerr("handler must have its last output of type api.Err")
// errMissingRequestArgument - missing request argument for handler
const errMissingRequestArgument = cerr("handler first argument must be of type api.Request")
@ -47,4 +47,4 @@ const errMissingOutputFromConfig = cerr("missing a parameter from configuration"
const errWrongParamTypeFromConfig = cerr("invalid struct field type")
// 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.Err")

View File

@ -16,7 +16,7 @@ type Handler struct {
// Build a handler from a service configuration and a dynamic function
//
// @fn must have as a signature : `func(inputStruct) (*outputStruct, api.Error)`
// @fn must have as a signature : `func(inputStruct) (*outputStruct, api.Err)`
// - `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)
//
@ -46,8 +46,9 @@ func Build(fn interface{}, service config.Service) (*Handler, error) {
}
// 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) {
fnv := reflect.ValueOf(h.fn)
func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Err) {
var ert = reflect.TypeOf(api.Err{})
var fnv = reflect.ValueOf(h.fn)
callArgs := []reflect.Value{}
@ -80,7 +81,12 @@ func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, a
// no output OR pointer to output struct is nil
outdata := make(map[string]interface{})
if len(h.spec.Output) < 1 || output[0].IsNil() {
return outdata, api.Error(output[len(output)-1].Int())
var structerr = output[len(output)-1].Convert(ert)
return outdata, api.Err{
Code: int(structerr.FieldByName("Code").Int()),
Reason: structerr.FieldByName("Reason").String(),
Status: int(structerr.FieldByName("Status").Int()),
}
}
// extract struct from pointer
@ -91,6 +97,11 @@ func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, a
outdata[name] = field.Interface()
}
// extract api.Error
return outdata, api.Error(output[len(output)-1].Int())
// extract api.Err
var structerr = output[len(output)-1].Convert(ert)
return outdata, api.Err{
Code: int(structerr.FieldByName("Code").Int()),
Reason: structerr.FieldByName("Reason").String(),
Status: int(structerr.FieldByName("Status").Int()),
}
}

View File

@ -91,9 +91,9 @@ func (s spec) checkOutput(fnv reflect.Value) error {
return errMissingHandlerOutput
}
// last output must be api.Error
// last output must be api.Err
errOutput := fnt.Out(fnt.NumOut() - 1)
if !errOutput.AssignableTo(reflect.TypeOf(api.ErrorUnknown)) {
if !errOutput.AssignableTo(reflect.TypeOf(api.ErrUnknown)) {
return errMissingHandlerErrorOutput
}

View File

@ -111,28 +111,28 @@ func TestOutputCheck(t *testing.T) {
Fn interface{}
Err error
}{
// no input -> missing api.Error
// no input -> missing api.Err
{
Output: map[string]reflect.Type{},
Fn: func() {},
Err: errMissingHandlerOutput,
},
// no input -> with last type not api.Error
// no input -> with last type not api.Err
{
Output: map[string]reflect.Type{},
Fn: func() bool { return true },
Err: errMissingHandlerErrorOutput,
},
// no input -> with api.Error
// no input -> with api.Err
{
Output: map[string]reflect.Type{},
Fn: func() api.Error { return api.ErrorSuccess },
Fn: func() api.Err { return api.ErrSuccess },
Err: nil,
},
// func can have output if not specified
{
Output: map[string]reflect.Type{},
Fn: func() (*struct{}, api.Error) { return nil, api.ErrorSuccess },
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
// missing output struct in func
@ -140,7 +140,7 @@ func TestOutputCheck(t *testing.T) {
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() api.Error { return api.ErrorSuccess },
Fn: func() api.Err { return api.ErrSuccess },
Err: errMissingParamOutput,
},
// output not a pointer
@ -148,7 +148,7 @@ func TestOutputCheck(t *testing.T) {
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (int, api.Error) { return 0, api.ErrorSuccess },
Fn: func() (int, api.Err) { return 0, api.ErrSuccess },
Err: errMissingParamOutput,
},
// output not a pointer to struct
@ -156,7 +156,7 @@ func TestOutputCheck(t *testing.T) {
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*int, api.Error) { return nil, api.ErrorSuccess },
Fn: func() (*int, api.Err) { return nil, api.ErrSuccess },
Err: errMissingParamOutput,
},
// unexported param name
@ -164,7 +164,7 @@ func TestOutputCheck(t *testing.T) {
Output: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Error) { return nil, api.ErrorSuccess },
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: errUnexportedName,
},
// output field missing
@ -172,7 +172,7 @@ func TestOutputCheck(t *testing.T) {
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Error) { return nil, api.ErrorSuccess },
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: errMissingParamFromConfig,
},
// output field invalid type
@ -180,7 +180,7 @@ func TestOutputCheck(t *testing.T) {
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 string }, api.Error) { return nil, api.ErrorSuccess },
Fn: func() (*struct{ Test1 string }, api.Err) { return nil, api.ErrSuccess },
Err: errWrongParamTypeFromConfig,
},
// output field valid type
@ -188,7 +188,7 @@ func TestOutputCheck(t *testing.T) {
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 int }, api.Error) { return nil, api.ErrorSuccess },
Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
// ignore type check on nil type
@ -196,7 +196,7 @@ func TestOutputCheck(t *testing.T) {
Output: map[string]reflect.Type{
"Test1": nil,
},
Fn: func() (*struct{ Test1 int }, api.Error) { return nil, api.ErrorSuccess },
Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
}

View File

@ -1,6 +1,7 @@
package reqdata
import (
"fmt"
"math"
"testing"
)
@ -24,7 +25,7 @@ func TestSimpleFloat(t *testing.T) {
tcases := []float64{12.3456789, -12.3456789, 0.0000001, -0.0000001}
for i, tcase := range tcases {
t.Run("case "+string(i), func(t *testing.T) {
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
p := parseParameter(tcase)
cast, canCast := p.(float64)
@ -45,7 +46,7 @@ func TestSimpleBool(t *testing.T) {
tcases := []bool{true, false}
for i, tcase := range tcases {
t.Run("case "+string(i), func(t *testing.T) {
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
p := parseParameter(tcase)
cast, canCast := p.(bool)
@ -136,7 +137,7 @@ func TestJsonPrimitiveBool(t *testing.T) {
}
for i, tcase := range tcases {
t.Run("case "+string(i), func(t *testing.T) {
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
p := parseParameter(tcase.Raw)
cast, canCast := p.(bool)
@ -173,7 +174,7 @@ func TestJsonPrimitiveFloat(t *testing.T) {
}
for i, tcase := range tcases {
t.Run("case "+string(i), func(t *testing.T) {
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
p := parseParameter(tcase.Raw)
cast, canCast := p.(float64)

101
server.go
View File

@ -1,101 +0,0 @@
package aicra
import (
"net/http"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/reqdata"
)
// Server hides the builder and allows handling http requests
type Server Builder
// 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).ServeHTTP(res, req)
return
}
// 2. extract request data
dataset, err := extractRequestData(service, *req)
if err != nil {
errorHandler(api.ErrorMissingParam).ServeHTTP(res, req)
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).ServeHTTP(res, req)
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)
}
func errorHandler(err api.Error) http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) {
r := api.EmptyResponse().WithError(err)
r.ServeHTTP(res, req)
}
}
func extractRequestData(service *config.Service, req http.Request) (*reqdata.T, error) {
dataset := reqdata.New(service)
// 3. extract URI data
err := dataset.GetURI(req)
if err != nil {
return nil, err
}
// 4. extract query data
err = dataset.GetQuery(req)
if err != nil {
return nil, err
}
// 5. extract form/json data
err = dataset.GetForm(req)
if err != nil {
return nil, err
}
return dataset, nil
}