Compare commits

..

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

13 changed files with 388 additions and 442 deletions

243
README.md
View File

@ -6,59 +6,50 @@
[![Go doc](https://godoc.org/git.xdrm.io/go/aicra?status.svg)](https://godoc.org/git.xdrm.io/go/aicra) [![Go doc](https://godoc.org/git.xdrm.io/go/aicra?status.svg)](https://godoc.org/git.xdrm.io/go/aicra)
[![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra) [![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra)
----
`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. Aicra is a *configuration-driven* REST API engine written in Go.
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. 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
TL;DR: `aicra` is a fast configuration-driven REST API engine. > A example project is available [here](https://git.xdrm.io/go/articles-api)
Repetitive tasks is automatically processed by `aicra` from your configuration file, you just have to implement your handlers. ## Table of contents
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 --> <!-- toc -->
* [Installation](#installation) - [I/ Installation](#i-installation)
- [Usage](#usage) - [II/ Usage](#ii-usage)
- [Create a server](#create-a-server) * [1) Build a server](#1-build-a-server)
- [Create a handler](#create-a-handler) * [2) API Configuration](#2-api-configuration)
- [Configuration](#configuration) - [Definition](#definition)
- [Global format](#global-format) + [Input Arguments](#input-arguments)
* [Input section](#input-section) - [1. Input types](#1-input-types)
+ [Format](#format) - [2. Global Format](#2-global-format)
- [Example](#example) - [III/ Change Log](#iii-change-log)
- [Changelog](#changelog)
<!-- tocstop --> <!-- tocstop -->
## Installation ## I/ Installation
You need a recent machine with `go` [installed](https://golang.org/doc/install). The package has not been tested under **go1.14**. You need a recent machine with `go` [installed](https://golang.org/doc/install). This package has not been tested under the version **1.14**.
```bash ```bash
go get -u git.xdrm.io/go/aicra 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.
# Usage
#### Create a server ## II/ Usage
The code below sets up and creates an HTTP server from the `api.json` configuration.
### 1) Build a server
Here is some sample code that builds and sets up an aicra server using your api configuration file.
```go ```go
package main package main
@ -74,20 +65,22 @@ import (
) )
func main() { func main() {
builder := &aicra.Builder{} builder := &aicra.Builder{}
// register available validators // add datatypes your api uses
builder.AddType(builtin.BoolDataType{}) builder.AddType(builtin.BoolDataType{})
builder.AddType(builtin.UintDataType{}) builder.AddType(builtin.UintDataType{})
builder.AddType(builtin.StringDataType{}) builder.AddType(builtin.StringDataType{})
// load your configuration
config, err := os.Open("./api.json") config, err := os.Open("./api.json")
if err != nil { if err != nil {
log.Fatalf("cannot open config: %s", err) log.Fatalf("cannot open config: %s", err)
} }
// pass your configuration
err = builder.Setup(config) err = builder.Setup(config)
config.Close() // free config file config.Close()
if err != nil { if err != nil {
log.Fatalf("invalid config: %s", err) log.Fatalf("invalid config: %s", err)
} }
@ -96,123 +89,87 @@ func main() {
builder.Bind(http.MethodGet, "/user/{id}", getUserById) builder.Bind(http.MethodGet, "/user/{id}", getUserById)
builder.Bind(http.MethodGet, "/user/{id}/username", getUsernameByID) builder.Bind(http.MethodGet, "/user/{id}/username", getUsernameByID)
// build the handler and start listening // build the server and start listening
handler, err := builder.Build() server, err := builder.Build()
if err != nil { if err != nil {
log.Fatalf("cannot build handler: %s", err) log.Fatalf("cannot build server: %s", err)
} }
http.ListenAndServe("localhost:8080", handler) http.ListenAndServe("localhost:8080", server)
}
```
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()
} }
``` ```
#### Create a handler Here is an example handler
The code below implements a simple handler.
```go ```go
// "in": {
// "Input1": { "info": "...", "type": "int" },
// "Input2": { "info": "...", "type": "?string" }
// },
type req struct{ type req struct{
Input1 int Param1 int
Input2 *string // optional are pointers Param3 *string // optional are pointers
} }
// "out": {
// "Output1": { "info": "...", "type": "string" },
// "Output2": { "info": "...", "type": "bool" }
// }
type res struct{ type res struct{
Output1 string Output1 string
Output2 bool Output2 bool
} }
func myHandler(r req) (*res, api.Err) { func myHandler(r req) (*res, api.Error) {
err := doSomething() err := doSomething()
if err != nil { if err != nil {
return nil, api.ErrFailure return nil, api.ErrorFailure
}
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
} }
``` ```
# 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 [configuration](https://git.xdrm.io/go/articles-api/src/master/api.json). 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 configuration file defines :
- routes and their methods - routes and their methods
- every input argument for each method - every input for each method (called *argument*)
- every output for each method - every output for each method
- scope permissions (list of permissions required by clients) - scope permissions (list of permissions needed by clients)
- input policy : - input policy :
- type of argument (_c.f. data types_) - type of argument (_c.f. data types_)
- required/optional - required/optional
- variable renaming - variable renaming
#### Global format #### Format
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. 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.
- `info`: Short description of the method | field path | description | example |
- `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. | `info` | A short human-readable description of what the method does | `create a new user` |
- `scope`: A 2-dimensional array of permissions. The first level means **or**, the second means **and**. It allows to combine permissions in complex ways. | `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) |
- Example: `[["A", "B"], ["C", "D"]]` translates to : this method requires 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 |
##### Input section ### Input Arguments
Input arguments defines what data from the HTTP request the method requires. `aicra` is able to extract 3 types of data : 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** - 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 at the end of the URL following the standard [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax. - **Query** - data formatted 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 inside the body of the request but following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- _URL encoded_: data send in the body 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.
- _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 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.
- _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 ```json
[ [
{ {
@ -221,9 +178,9 @@ Variable names from **URI** or **Query** must be named accordingly :
"scope": [["author"]], "scope": [["author"]],
"info": "updates an article", "info": "updates an article",
"in": { "in": {
"{id}": { "info": "...", "type": "int", "name": "id" }, "{id}": { "info": "article id", "type": "int", "name": "article_id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" }, "GET@title": { "info": "new article title", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" } "content": { "info": "new article content", "type": "string" }
}, },
"out": { "out": {
"id": { "info": "updated article id", "type": "uint" }, "id": { "info": "updated article id", "type": "uint" },
@ -234,54 +191,6 @@ Variable names from **URI** or **Query** must be named accordingly :
] ]
``` ```
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. - 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.
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`. - 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`.
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`. - 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`.
# 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,82 +3,129 @@ package api
import "net/http" import "net/http"
var ( var (
// ErrUnknown represents any error which cause is unknown. // ErrorUnknown represents any error which cause is unknown.
// It might also be used for debug purposes as this error // It might also be used for debug purposes as this error
// has to be used the less possible // has to be used the less possible
ErrUnknown = Err{-1, "unknown error", http.StatusOK} ErrorUnknown Error = -1
// ErrSuccess represents a generic successful service execution // ErrorSuccess represents a generic successful service execution
ErrSuccess = Err{0, "all right", http.StatusOK} ErrorSuccess Error = 0
// ErrFailure is the most generic error // ErrorFailure is the most generic error
ErrFailure = Err{1, "it failed", http.StatusInternalServerError} ErrorFailure Error = 1
// ErrNoMatchFound is set when trying to fetch data and there is no result // ErrorNoMatchFound has to be set when trying to fetch data and there is no result
ErrNoMatchFound = Err{2, "resource not found", http.StatusOK} ErrorNoMatchFound Error = 2
// ErrAlreadyExists is set when trying to insert data, but identifiers or // ErrorAlreadyExists has to be set when trying to insert data, but identifiers or
// unique fields already exists // unique fields already exists
ErrAlreadyExists = Err{3, "already exists", http.StatusOK} ErrorAlreadyExists Error = 3
// ErrCreation is set when there is a creation/insert error // ErrorCreation has to be set when there is a creation/insert error
ErrCreation = Err{4, "create error", http.StatusOK} ErrorCreation Error = 4
// ErrModification is set when there is an update/modification error // ErrorModification has to be set when there is an update/modification error
ErrModification = Err{5, "update error", http.StatusOK} ErrorModification Error = 5
// ErrDeletion is set when there is a deletion/removal error // ErrorDeletion has to be set when there is a deletion/removal error
ErrDeletion = Err{6, "delete error", http.StatusOK} ErrorDeletion Error = 6
// ErrTransaction is set when there is a transactional error // ErrorTransaction has to be set when there is a transactional error
ErrTransaction = Err{7, "transactional error", http.StatusOK} ErrorTransaction Error = 7
// ErrUpload is set when a file upload failed // ErrorUpload has to be set when a file upload failed
ErrUpload = Err{100, "upload failed", http.StatusInternalServerError} ErrorUpload Error = 100
// ErrDownload is set when a file download failed // ErrorDownload has to be set when a file download failed
ErrDownload = Err{101, "download failed", http.StatusInternalServerError} ErrorDownload Error = 101
// MissingDownloadHeaders is set when the implementation // MissingDownloadHeaders has to be set when the implementation
// of a service of type 'download' (which returns a file instead of // of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its HEADER field // a set or output fields) is missing its HEADER field
MissingDownloadHeaders = Err{102, "download headers are missing", http.StatusBadRequest} MissingDownloadHeaders Error = 102
// ErrMissingDownloadBody is set when the implementation // ErrorMissingDownloadBody has to be set when the implementation
// of a service of type 'download' (which returns a file instead of // of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its BODY field // a set or output fields) is missing its BODY field
ErrMissingDownloadBody = Err{103, "download body is missing", http.StatusBadRequest} ErrorMissingDownloadBody Error = 103
// ErrUnknownService is set when there is no service matching // ErrorUnknownService is set when there is no service matching
// the http request URI. // the http request URI.
ErrUnknownService = Err{200, "unknown service", http.StatusServiceUnavailable} ErrorUnknownService Error = 200
// ErrUncallableService is set when there the requested service's // ErrorUncallableService is set when there the requested service's
// implementation (plugin file) is not found/callable // implementation (plugin file) is not found/callable
ErrUncallableService = Err{202, "uncallable service", http.StatusServiceUnavailable} ErrorUncallableService Error = 202
// ErrNotImplemented is set when a handler is not implemented yet // ErrorNotImplemented is set when a handler is not implemented yet
ErrNotImplemented = Err{203, "not implemented", http.StatusNotImplemented} ErrorNotImplemented Error = 203
// ErrPermission is set when there is a permission error by default // ErrorPermission is set when there is a permission error by default
// the api returns a permission error when the current scope (built // the api returns a permission error when the current scope (built
// by middlewares) does not match the scope required in the config. // by middlewares) does not match the scope required in the config.
// You can add your own permission policy and use this error // You can add your own permission policy and use this error
ErrPermission = Err{300, "permission error", http.StatusUnauthorized} ErrorPermission Error = 300
// ErrToken is set (usually in authentication middleware) to tell // ErrorToken has to be set (usually in authentication middleware) to tell
// the user that this authentication token is expired or invalid // the user that this authentication token is expired or invalid
ErrToken = Err{301, "token error", http.StatusForbidden} ErrorToken Error = 301
// ErrMissingParam is set when a *required* parameter is missing from the // ErrorMissingParam is set when a *required* parameter is missing from the
// http request // http request
ErrMissingParam = Err{400, "missing parameter", http.StatusBadRequest} ErrorMissingParam Error = 400
// ErrInvalidParam is set when a given parameter fails its type check as // ErrorInvalidParam is set when a given parameter fails its type check as
// defined in the config file. // defined in the config file.
ErrInvalidParam = Err{401, "invalid parameter", http.StatusBadRequest} ErrorInvalidParam Error = 401
// ErrInvalidDefaultParam is set when an optional parameter's default value // ErrorInvalidDefaultParam is set when an optional parameter's default value
// does not match its type. // does not match its type.
ErrInvalidDefaultParam = Err{402, "invalid default param", http.StatusBadRequest} ErrorInvalidDefaultParam Error = 402
) )
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,21 +1,49 @@
package api package api
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http"
) )
// Err represents an http response error following the api format. // Error represents an http response error following the api format.
// These are used by the services to set the *execution status* // These are used by the services to set the *execution status*
// directly into the response as JSON alongside response output fields. // directly into the response as JSON alongside response output fields.
type Err struct { type Error int
// error code (unique)
Code int `json:"code"` func (e Error) Error() string {
// error small description reason, ok := errorReasons[e]
Reason string `json:"reason"` if !ok {
// associated HTTP status return ErrorUnknown.Error()
Status int }
return fmt.Sprintf("[%d] %s", e, reason)
} }
func (e Err) Error() string { // Status returns the associated HTTP status code
return fmt.Sprintf("[%d] %s", e.Code, e.Reason) 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 {
Code int `json:"code"`
Reason string `json:"reason"`
}{
Code: int(e),
Reason: reason,
}
return json.Marshal(formatted)
} }

View File

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

View File

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

2
go.mod
View File

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

View File

@ -1,107 +0,0 @@
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") const errMissingHandlerOutput = cerr("handler must have at least 1 output")
// errMissingHandlerOutputError - missing error output for handler // errMissingHandlerOutputError - missing error output for handler
const errMissingHandlerOutputError = cerr("handler must have its last output of type api.Err") const errMissingHandlerOutputError = cerr("handler must have its last output of type api.Error")
// errMissingRequestArgument - missing request argument for handler // errMissingRequestArgument - missing request argument for handler
const errMissingRequestArgument = cerr("handler first argument must be of type api.Request") 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") const errWrongParamTypeFromConfig = cerr("invalid struct field type")
// errMissingHandlerErrorOutput - missing handler output error // errMissingHandlerErrorOutput - missing handler output error
const errMissingHandlerErrorOutput = cerr("last output must be of type api.Err") const errMissingHandlerErrorOutput = cerr("last output must be of type api.Error")

View File

@ -16,7 +16,7 @@ type Handler struct {
// Build a handler from a service configuration and a dynamic function // Build a handler from a service configuration and a dynamic function
// //
// @fn must have as a signature : `func(inputStruct) (*outputStruct, api.Err)` // @fn must have as a signature : `func(inputStruct) (*outputStruct, api.Error)`
// - `inputStruct` is a struct{} containing a field for each service input (with valid reflect.Type) // - `inputStruct` is a struct{} containing a field for each service input (with valid reflect.Type)
// - `outputStruct` is a struct{} containing a field for each service output (with valid reflect.Type) // - `outputStruct` is a struct{} containing a field for each service output (with valid reflect.Type)
// //
@ -46,9 +46,8 @@ func Build(fn interface{}, service config.Service) (*Handler, error) {
} }
// Handle binds input @data into the dynamic function and returns map output // Handle binds input @data into the dynamic function and returns map output
func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Err) { func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Error) {
var ert = reflect.TypeOf(api.Err{}) fnv := reflect.ValueOf(h.fn)
var fnv = reflect.ValueOf(h.fn)
callArgs := []reflect.Value{} callArgs := []reflect.Value{}
@ -81,12 +80,7 @@ func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, a
// no output OR pointer to output struct is nil // no output OR pointer to output struct is nil
outdata := make(map[string]interface{}) outdata := make(map[string]interface{})
if len(h.spec.Output) < 1 || output[0].IsNil() { if len(h.spec.Output) < 1 || output[0].IsNil() {
var structerr = output[len(output)-1].Convert(ert) return outdata, api.Error(output[len(output)-1].Int())
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 // extract struct from pointer
@ -97,11 +91,6 @@ func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, a
outdata[name] = field.Interface() outdata[name] = field.Interface()
} }
// extract api.Err // extract api.Error
var structerr = output[len(output)-1].Convert(ert) return outdata, api.Error(output[len(output)-1].Int())
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 return errMissingHandlerOutput
} }
// last output must be api.Err // last output must be api.Error
errOutput := fnt.Out(fnt.NumOut() - 1) errOutput := fnt.Out(fnt.NumOut() - 1)
if !errOutput.AssignableTo(reflect.TypeOf(api.ErrUnknown)) { if !errOutput.AssignableTo(reflect.TypeOf(api.ErrorUnknown)) {
return errMissingHandlerErrorOutput return errMissingHandlerErrorOutput
} }

View File

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

View File

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

101
server.go Normal file
View File

@ -0,0 +1,101 @@
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
}