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

339
README.md
View File

@ -6,191 +6,282 @@
[![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 *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 : 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.
- 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) 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 --> <!-- toc -->
- [I/ Installation](#i-installation) * [Installation](#installation)
- [II/ Usage](#ii-usage) - [Usage](#usage)
* [1) Build a server](#1-build-a-server) - [Create a server](#create-a-server)
* [2) API Configuration](#2-api-configuration) - [Create a handler](#create-a-handler)
- [Definition](#definition) - [Configuration](#configuration)
+ [Input Arguments](#input-arguments) - [Global format](#global-format)
- [1. Input types](#1-input-types) * [Input section](#input-section)
- [2. Global Format](#2-global-format) + [Format](#format)
- [III/ Change Log](#iii-change-log) - [Example](#example)
- [Changelog](#changelog)
<!-- tocstop --> <!-- 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 ```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
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
import ( import (
"log" "log"
"net/http" "net/http"
"os" "os"
"git.xdrm.io/go/aicra" "git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/api" "git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin" "git.xdrm.io/go/aicra/datatype/builtin"
) )
func main() { func main() {
builder := &aicra.Builder{}
builder := &aicra.Builder{} // register available validators
builder.AddType(builtin.BoolDataType{})
builder.AddType(builtin.UintDataType{})
builder.AddType(builtin.StringDataType{})
// add datatypes your api uses // load your configuration
builder.AddType(builtin.BoolDataType{}) config, err := os.Open("./api.json")
builder.AddType(builtin.UintDataType{}) if err != nil {
builder.AddType(builtin.StringDataType{}) log.Fatalf("cannot open config: %s", err)
}
err = builder.Setup(config)
config.Close() // free config file
if err != nil {
log.Fatalf("invalid config: %s", err)
}
config, err := os.Open("./api.json") // bind your handlers
if err != nil { builder.Bind(http.MethodGet, "/user/{id}", getUserById)
log.Fatalf("cannot open config: %s", err) builder.Bind(http.MethodGet, "/user/{id}/username", getUsernameByID)
}
// pass your configuration // build the handler and start listening
err = builder.Setup(config) handler, err := builder.Build()
config.Close() if err != nil {
if err != nil { log.Fatalf("cannot build handler: %s", err)
log.Fatalf("invalid config: %s", err) }
} http.ListenAndServe("localhost:8080", handler)
// 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)
} }
``` ```
If you want to use HTTPS, you can configure your own `http.Server`.
Here is an example handler
```go ```go
type req struct{ func main() {
Param1 int server := &http.Server{
Param3 *string // optional are pointers Addr: "localhost:8080",
} TLSConfig: tls.Config{},
type res struct{ // aicra handler
Output1 string Handler: handler,
Output2 bool
}
func myHandler(r req) (*res, api.Error) {
err := doSomething()
if err != nil {
return nil, api.ErrorFailure
} }
return &res{}, api.ErrorSuccess
server.ListenAndServe()
} }
``` ```
### 2) API Configuration #### Create a handler
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 code below implements a simple handler.
```go
// "in": {
// "Input1": { "info": "...", "type": "int" },
// "Input2": { "info": "...", "type": "?string" }
// },
type req struct{
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.Err) {
err := doSomething()
if err != nil {
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"
}
}
```
# 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 configuration file defines :
- routes and their methods - routes and their methods
- every input for each method (called *argument*) - every input argument for each method
- every output 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 : - input policy :
- type of argument (_c.f. data types_) - type of argument (_c.f. data types_)
- required/optional - required/optional
- variable renaming - 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`: Short description of the method
| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------ | - `in`: List of arguments that the clients will have to provide. [Read more](#input-arguments).
| `info` | A short human-readable description of what the method does | `create a new user` | - `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 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) | - `scope`: A 2-dimensional array of permissions. The first level means **or**, the second means **and**. It allows to combine permissions in complex ways.
| `in` | The list of arguments that the clients will have to provide. [Read more](#input-arguments). | | - Example: `[["A", "B"], ["C", "D"]]` translates to : this method requires users to have permissions (A **and** B) **or** (C **and** D)
| `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 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. - **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. - **Query** - data 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. - **Form** - data send from the body of the request ; it can be extracted in 3 ways:
- **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. - _URL encoded_: data send in the body following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **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. - _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 ```json
[ [
{ {
"method": "PUT", "method": "PUT",
"path": "/article/{id}", "path": "/article/{id}",
"scope": [["author"]], "scope": [["author"]],
"info": "updates an article", "info": "updates an article",
"in": { "in": {
"{id}": { "info": "article id", "type": "int", "name": "article_id" }, "{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "new article title", "type": "?string", "name": "title" }, "GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "new article content", "type": "string" } "content": { "info": "...", "type": "string" }
}, },
"out": { "out": {
"id": { "info": "updated article id", "type": "uint" }, "id": { "info": "updated article id", "type": "uint" },
"title": { "info": "updated article title", "type": "string" }, "title": { "info": "updated article title", "type": "string" },
"content": { "info": "updated article content", "type": "string" } "content": { "info": "updated article content", "type": "string" }
} }
} }
] ]
``` ```
- 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. 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 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`. 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 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`. 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" import "net/http"
var ( 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 // 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
ErrorUnknown Error = -1 ErrUnknown = Err{-1, "unknown error", http.StatusOK}
// ErrorSuccess represents a generic successful service execution // ErrSuccess represents a generic successful service execution
ErrorSuccess Error = 0 ErrSuccess = Err{0, "all right", http.StatusOK}
// ErrorFailure is the most generic error // ErrFailure is the most generic error
ErrorFailure Error = 1 ErrFailure = Err{1, "it failed", http.StatusInternalServerError}
// ErrorNoMatchFound has to be set when trying to fetch data and there is no result // ErrNoMatchFound is set when trying to fetch data and there is no result
ErrorNoMatchFound Error = 2 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 // 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 // ErrCreation is set when there is a creation/insert error
ErrorCreation Error = 4 ErrCreation = Err{4, "create error", http.StatusOK}
// ErrorModification has to be set when there is an update/modification error // ErrModification is set when there is an update/modification error
ErrorModification Error = 5 ErrModification = Err{5, "update error", http.StatusOK}
// ErrorDeletion has to be set when there is a deletion/removal error // ErrDeletion is set when there is a deletion/removal error
ErrorDeletion Error = 6 ErrDeletion = Err{6, "delete error", http.StatusOK}
// ErrorTransaction has to be set when there is a transactional error // ErrTransaction is set when there is a transactional error
ErrorTransaction Error = 7 ErrTransaction = Err{7, "transactional error", http.StatusOK}
// ErrorUpload has to be set when a file upload failed // ErrUpload is set when a file upload failed
ErrorUpload Error = 100 ErrUpload = Err{100, "upload failed", http.StatusInternalServerError}
// ErrorDownload has to be set when a file download failed // ErrDownload is set when a file download failed
ErrorDownload Error = 101 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 // 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 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 // 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
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. // 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 // 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 // ErrNotImplemented is set when a handler is not implemented yet
ErrorNotImplemented Error = 203 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 // 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
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 // 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 // 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. // 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. // 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 package api
import ( import (
"encoding/json"
"fmt" "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* // 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 Error int type Err struct {
// error code (unique)
func (e Error) Error() string { Code int `json:"code"`
reason, ok := errorReasons[e] // error small description
if !ok { Reason string `json:"reason"`
return ErrorUnknown.Error() // associated HTTP status
} Status int
return fmt.Sprintf("[%d] %s", e, reason)
} }
// Status returns the associated HTTP status code func (e Err) Error() string {
func (e Error) Status() int { return fmt.Sprintf("[%d] %s", e.Code, e.Reason)
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 Error err Err
} }
// 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: ErrorFailure, err: ErrFailure,
Headers: make(http.Header), Headers: make(http.Header),
} }
} }
// WithError sets the error // WithError sets the error
func (res *Response) WithError(err Error) *Response { func (res *Response) WithError(err Err) *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 server handler // represents an api handler (method-pattern combination)
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 // Setup the builder with its api definition file
// 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 // Bind a dynamic handler to a REST service (method and pattern)
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 // find associated service from config
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)
} }
dyn, err := dynfunc.Build(fn, *service) var 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,21 +79,41 @@ 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 hasAssociatedHandler bool var isHandled 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 {
hasAssociatedHandler = true isHandled = true
break break
} }
} }
if !hasAssociatedHandler { if !isHandled {
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 Server(b), nil return Handler(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.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") 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.Error") const errMissingHandlerOutputError = cerr("handler must have its last output of type api.Err")
// 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.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 // 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) // - `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,8 +46,9 @@ 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.Error) { func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Err) {
fnv := reflect.ValueOf(h.fn) var ert = reflect.TypeOf(api.Err{})
var fnv = reflect.ValueOf(h.fn)
callArgs := []reflect.Value{} 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 // 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() {
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 // extract struct from pointer
@ -91,6 +97,11 @@ func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, a
outdata[name] = field.Interface() outdata[name] = field.Interface()
} }
// extract api.Error // extract api.Err
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()),
}
} }

View File

@ -91,9 +91,9 @@ func (s spec) checkOutput(fnv reflect.Value) error {
return errMissingHandlerOutput return errMissingHandlerOutput
} }
// last output must be api.Error // last output must be api.Err
errOutput := fnt.Out(fnt.NumOut() - 1) errOutput := fnt.Out(fnt.NumOut() - 1)
if !errOutput.AssignableTo(reflect.TypeOf(api.ErrorUnknown)) { if !errOutput.AssignableTo(reflect.TypeOf(api.ErrUnknown)) {
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.Error // no input -> missing api.Err
{ {
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.Error // no input -> with last type not api.Err
{ {
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.Error // no input -> with api.Err
{ {
Output: map[string]reflect.Type{}, Output: map[string]reflect.Type{},
Fn: func() api.Error { return api.ErrorSuccess }, Fn: func() api.Err { return api.ErrSuccess },
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.Error) { return nil, api.ErrorSuccess }, Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
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.Error { return api.ErrorSuccess }, Fn: func() api.Err { return api.ErrSuccess },
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.Error) { return 0, api.ErrorSuccess }, Fn: func() (int, api.Err) { return 0, api.ErrSuccess },
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.Error) { return nil, api.ErrorSuccess }, Fn: func() (*int, api.Err) { return nil, api.ErrSuccess },
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.Error) { return nil, api.ErrorSuccess }, Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
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.Error) { return nil, api.ErrorSuccess }, Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
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.Error) { return nil, api.ErrorSuccess }, Fn: func() (*struct{ Test1 string }, api.Err) { return nil, api.ErrSuccess },
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.Error) { return nil, api.ErrorSuccess }, Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
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.Error) { return nil, api.ErrorSuccess }, Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil, Err: nil,
}, },
} }

View File

@ -1,6 +1,7 @@
package reqdata package reqdata
import ( import (
"fmt"
"math" "math"
"testing" "testing"
) )
@ -24,7 +25,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("case "+string(i), func(t *testing.T) { t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
p := parseParameter(tcase) p := parseParameter(tcase)
cast, canCast := p.(float64) cast, canCast := p.(float64)
@ -45,7 +46,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("case "+string(i), func(t *testing.T) { t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
p := parseParameter(tcase) p := parseParameter(tcase)
cast, canCast := p.(bool) cast, canCast := p.(bool)
@ -136,7 +137,7 @@ func TestJsonPrimitiveBool(t *testing.T) {
} }
for i, tcase := range tcases { 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) p := parseParameter(tcase.Raw)
cast, canCast := p.(bool) cast, canCast := p.(bool)
@ -173,7 +174,7 @@ func TestJsonPrimitiveFloat(t *testing.T) {
} }
for i, tcase := range tcases { 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) p := parseParameter(tcase.Raw)
cast, canCast := p.(float64) 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
}