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)
[![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.
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
## Table of contents
<!-- toc -->
* [Installation](#installation)
- [Usage](#usage)
- [Create a server](#create-a-server)
- [Create a handler](#create-a-handler)
- [Configuration](#configuration)
- [Global format](#global-format)
* [Input section](#input-section)
+ [Format](#format)
- [Example](#example)
- [Changelog](#changelog)
- [I/ Installation](#i-installation)
- [II/ Usage](#ii-usage)
* [1) Build a server](#1-build-a-server)
* [2) API Configuration](#2-api-configuration)
- [Definition](#definition)
+ [Input Arguments](#input-arguments)
- [1. Input types](#1-input-types)
- [2. Global Format](#2-global-format)
- [III/ Change Log](#iii-change-log)
<!-- 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
go get -u git.xdrm.io/go/aicra
go get -u git.xdrm.io/go/aicra/cmd/aicra
```
# Usage
The library should now be available as `git.xdrm.io/go/aicra` in your imports.
#### 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
package main
@ -74,20 +65,22 @@ import (
)
func main() {
builder := &aicra.Builder{}
// register available validators
// add datatypes your api uses
builder.AddType(builtin.BoolDataType{})
builder.AddType(builtin.UintDataType{})
builder.AddType(builtin.StringDataType{})
// load your configuration
config, err := os.Open("./api.json")
if err != nil {
log.Fatalf("cannot open config: %s", err)
}
// pass your configuration
err = builder.Setup(config)
config.Close() // free config file
config.Close()
if err != nil {
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}/username", getUsernameByID)
// build the handler and start listening
handler, err := builder.Build()
// build the server and start listening
server, err := builder.Build()
if err != nil {
log.Fatalf("cannot build handler: %s", err)
log.Fatalf("cannot build server: %s", err)
}
http.ListenAndServe("localhost:8080", handler)
}
```
If you want to use HTTPS, you can configure your own `http.Server`.
```go
func main() {
server := &http.Server{
Addr: "localhost:8080",
TLSConfig: tls.Config{},
// aicra handler
Handler: handler,
}
server.ListenAndServe()
http.ListenAndServe("localhost:8080", server)
}
```
#### Create a handler
The code below implements a simple handler.
Here is an example handler
```go
// "in": {
// "Input1": { "info": "...", "type": "int" },
// "Input2": { "info": "...", "type": "?string" }
// },
type req struct{
Input1 int
Input2 *string // optional are pointers
Param1 int
Param3 *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) {
func myHandler(r req) (*res, api.Error) {
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"
return nil, api.ErrorFailure
}
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
- every input argument for each method
- every input for each method (called *argument*)
- every output for each method
- scope permissions (list of permissions required by clients)
- scope permissions (list of permissions needed by clients)
- input policy :
- type of argument (_c.f. data types_)
- required/optional
- 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
- `in`: List of arguments that the clients will have to provide. [Read more](#input-arguments).
- `out`: List of output data that your controllers will output. It has the same syntax as the `in` field but optional parameters are not allowed.
- `scope`: A 2-dimensional array of permissions. The first level means **or**, the second means **and**. It allows to combine permissions in complex ways.
- Example: `[["A", "B"], ["C", "D"]]` translates to : this method requires users to have permissions (A **and** B) **or** (C **and** D)
| field path | description | example |
| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| `info` | A short human-readable description of what the method does | `create a new user` |
| `scope` | A 2-dimensional array of permissions. The first dimension can be translated to a **or** operator, the second dimension as a **and**. It allows you to combine permissions in complex ways. | `[["A", "B"], ["C", "D"]]` can be translated to : this method needs users to have permissions (A **and** B) **or** (C **and** D) |
| `in` | The list of arguments that the clients will have to provide. [Read more](#input-arguments). | |
| `out` | The list of output data that will be returned by your controllers. It has the same syntax as the `in` field but optional parameters are not allowed |
##### 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.
- **Query** - data at the end of the URL following the standard [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **Form** - data send from the body of the request ; it can be extracted in 3 ways:
- _URL encoded_: data send in the body following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- _Multipart_: data send in the body with a dedicated [format](https://tools.ietf.org/html/rfc2388#section-3). This format can be quite heavy but allows to transmit data as well as files.
- _JSON_: data send in the body as a json object ; each key being a variable name, each value its content. Note that the 'Content-Type' header must be set to `application/json` for the API to use it.
> For Form data, the 3 methods can be used at once for different arguments; for instance if you need to send a file to an aicra server as well as other parameters, you can use JSON for parameters and Multipart for the file.
###### Format
The `in` field describes as list of arguments where the key is the argument name, and the value defines how to manage the variable.
- **Query** - data formatted at the end of the URL following the standard [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **URL encoded** - data send inside the body of the request but following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **Multipart** - data send inside the body of the request with a dedicated [format](https://tools.ietf.org/html/rfc2388#section-3). This format is not very lightweight but allows you to receive data as well as files.
- **JSON** - data send inside the body as a json object ; each key being a variable name, each value its content. Note that the HTTP header '**Content-Type**' must be set to `application/json` for the API to use it.
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
[
{
@ -221,9 +178,9 @@ Variable names from **URI** or **Query** must be named accordingly :
"scope": [["author"]],
"info": "updates an article",
"in": {
"{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" }
"{id}": { "info": "article id", "type": "int", "name": "article_id" },
"GET@title": { "info": "new article title", "type": "?string", "name": "title" },
"content": { "info": "new article content", "type": "string" }
},
"out": {
"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.
2. `GET@title` is extracted from the query (_e.g. [http://host/uri?get-var=value](http://host/uri?get-var=value)_). It must be a valid `string` or not given at all (the `?` at the beginning of the type tells that the argument is **optional**) ; it will be named `title`.
3. `content` can be extracted from json, multipart or url-encoded data; it makes no difference and only give clients a choice over the technology to use. If not renamed, the variable will be given to the handler with its original name `content`.
# Changelog
- [x] human-readable json configuration
- [x] nested routes (*i.e. `/user/{id}` and `/user/post/{id}`*)
- [x] nested URL arguments (*i.e. `/user/{id}` and `/user/{uid}/post/{id}`*)
- [x] useful http methods: GET, POST, PUT, DELETE
- [ ] add support for PATCH method
- [ ] add support for OPTIONS method
- [ ] it might be interesting to generate the list of allowed methods from the configuration
- [ ] add CORS support
- [x] manage request data extraction:
- [x] URL slash-separated strings
- [x] HTTP Query named parameters
- [x] manage array format
- [x] body parameters
- [x] multipart/form-data (variables and file uploads)
- [x] application/x-www-form-urlencoded
- [x] application/json
- [x] required vs. optional parameters with a default value
- [x] parameter renaming
- [x] generic type check (*i.e. you can add custom types alongside built-in ones*)
- [x] built-in types
- [x] `any` - matches any value
- [x] `int` - see go types
- [x] `uint` - see go types
- [x] `float` - see go types
- [x] `string` - any text
- [x] `string(len)` - any string with a length of exactly `len` characters
- [x] `string(min, max)` - any string with a length between `min` and `max`
- [ ] `[]a` - array containing **only** elements matching `a` type
- [ ] `a[b]` - map containing **only** keys of type `a` and values of type `b` (*a or b can be ommited*)
- [x] generic handler implementation
- [x] response interface
- [x] generic errors that automatically formats into response
- [x] builtin errors
- [x] possibility to add custom errors
- [x] check for missing handlers when building the handler
- [x] check handlers not matching a route in the configuration at server boot
- [x] specific configuration format errors qt server boot
- [x] statically typed handlers - avoids having to check every input and its type (_which is used by context.Context for instance_)
- [x] using reflection to use structs as input and output arguments to match the configuration
- [x] check for input and output arguments structs at server boot
- [x] check for unavailable types in configuration at server boot
- [x] recover panics from handlers
- [ ] improve tests and coverage
- the 1^st^ one is send at the end of the URI and is a number compliant with the `int` type checker. It is renamed `article_id`, this new name will be sent to the handler.
- the 2^nd^ one is send in the query (_e.g. [http://host/uri?get-var=value](http://host/uri?get-var=value)_). It must be a valid `string` or not given at all (the `?` at the beginning of the type tells that the argument is **optional**) ; it will be named `title`.
- the 3^rd^ can be send with a **JSON** body, in **multipart** or **URL encoded** it makes no difference and only give clients a choice over the technology to use. If not renamed, the variable will be given to the handler with the name `content`.

View File

@ -3,82 +3,129 @@ package api
import "net/http"
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
// has to be used the less possible
ErrUnknown = Err{-1, "unknown error", http.StatusOK}
ErrorUnknown Error = -1
// ErrSuccess represents a generic successful service execution
ErrSuccess = Err{0, "all right", http.StatusOK}
// ErrorSuccess represents a generic successful service execution
ErrorSuccess Error = 0
// ErrFailure is the most generic error
ErrFailure = Err{1, "it failed", http.StatusInternalServerError}
// ErrorFailure is the most generic error
ErrorFailure Error = 1
// ErrNoMatchFound is set when trying to fetch data and there is no result
ErrNoMatchFound = Err{2, "resource not found", http.StatusOK}
// ErrorNoMatchFound has to be set when trying to fetch data and there is no result
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
ErrAlreadyExists = Err{3, "already exists", http.StatusOK}
ErrorAlreadyExists Error = 3
// ErrCreation is set when there is a creation/insert error
ErrCreation = Err{4, "create error", http.StatusOK}
// ErrorCreation has to be set when there is a creation/insert error
ErrorCreation Error = 4
// ErrModification is set when there is an update/modification error
ErrModification = Err{5, "update error", http.StatusOK}
// ErrorModification has to be set when there is an update/modification error
ErrorModification Error = 5
// ErrDeletion is set when there is a deletion/removal error
ErrDeletion = Err{6, "delete error", http.StatusOK}
// ErrorDeletion has to be set when there is a deletion/removal error
ErrorDeletion Error = 6
// ErrTransaction is set when there is a transactional error
ErrTransaction = Err{7, "transactional error", http.StatusOK}
// ErrorTransaction has to be set when there is a transactional error
ErrorTransaction Error = 7
// ErrUpload is set when a file upload failed
ErrUpload = Err{100, "upload failed", http.StatusInternalServerError}
// ErrorUpload has to be set when a file upload failed
ErrorUpload Error = 100
// ErrDownload is set when a file download failed
ErrDownload = Err{101, "download failed", http.StatusInternalServerError}
// ErrorDownload has to be set when a file download failed
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
// 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
// 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.
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
ErrUncallableService = Err{202, "uncallable service", http.StatusServiceUnavailable}
ErrorUncallableService Error = 202
// ErrNotImplemented is set when a handler is not implemented yet
ErrNotImplemented = Err{203, "not implemented", http.StatusNotImplemented}
// ErrorNotImplemented is set when a handler is not implemented yet
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
// by middlewares) does not match the scope required in the config.
// 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
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
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.
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.
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
import (
"encoding/json"
"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*
// directly into the response as JSON alongside response output fields.
type Err struct {
// error code (unique)
Code int `json:"code"`
// error small description
Reason string `json:"reason"`
// associated HTTP status
Status int
type Error int
func (e Error) Error() string {
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.Error()
}
return fmt.Sprintf("[%d] %s", e, reason)
}
func (e Err) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Reason)
// Status returns the associated HTTP status code
func (e Error) Status() int {
status, ok := errorStatus[e]
if !ok {
return http.StatusOK
}
return status
}
// MarshalJSON implements encoding/json.Marshaler interface
func (e Error) MarshalJSON() ([]byte, error) {
// use unknown error if no reason
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.MarshalJSON()
}
// format to proper struct
formatted := struct {
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
Status int
Headers http.Header
err Err
err Error
}
// EmptyResponse creates an empty response.
@ -21,13 +21,13 @@ func EmptyResponse() *Response {
return &Response{
Status: http.StatusOK,
Data: make(ResponseData),
err: ErrFailure,
err: ErrorFailure,
Headers: make(http.Header),
}
}
// WithError sets the error
func (res *Response) WithError(err Err) *Response {
func (res *Response) WithError(err Error) *Response {
res.err = err
return res
}
@ -53,7 +53,7 @@ func (res *Response) MarshalJSON() ([]byte, error) {
}
func (res *Response) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(res.err.Status)
w.WriteHeader(res.err.Status())
encoded, err := json.Marshal(res)
if err != nil {
return err

View File

@ -16,7 +16,7 @@ type Builder struct {
handlers []*apiHandler
}
// represents an api handler (method-pattern combination)
// represents an server handler
type apiHandler struct {
Method string
Path string
@ -34,7 +34,7 @@ func (b *Builder) AddType(t datatype.T) {
b.conf.Types = append(b.conf.Types, t)
}
// Setup the builder with its api definition file
// Setup the builder with its api definition
// panics if already setup
func (b *Builder) Setup(r io.Reader) error {
if b.conf == nil {
@ -46,13 +46,13 @@ func (b *Builder) Setup(r io.Reader) error {
return b.conf.Parse(r)
}
// Bind a dynamic handler to a REST service (method and pattern)
// Bind a dynamic handler to a REST service
func (b *Builder) Bind(method, path string, fn interface{}) error {
if b.conf.Services == nil {
return errNotSetup
}
// find associated service from config
// find associated service
var service *config.Service
for _, s := range b.conf.Services {
if method == s.Method && path == s.Pattern {
@ -65,7 +65,7 @@ func (b *Builder) Bind(method, path string, fn interface{}) error {
return fmt.Errorf("%s '%s': %w", method, path, errUnknownService)
}
var dyn, err = dynfunc.Build(fn, *service)
dyn, err := dynfunc.Build(fn, *service)
if err != nil {
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
}
// Get is equivalent to Bind(http.MethodGet)
func (b *Builder) Get(path string, fn interface{}) error {
return b.Bind(http.MethodGet, path, fn)
}
// Post is equivalent to Bind(http.MethodPost)
func (b *Builder) Post(path string, fn interface{}) error {
return b.Bind(http.MethodPost, path, fn)
}
// Put is equivalent to Bind(http.MethodPut)
func (b *Builder) Put(path string, fn interface{}) error {
return b.Bind(http.MethodPut, path, fn)
}
// Delete is equivalent to Bind(http.MethodDelete)
func (b *Builder) Delete(path string, fn interface{}) error {
return b.Bind(http.MethodDelete, path, fn)
}
// Build a fully-featured HTTP server
func (b Builder) Build() (http.Handler, error) {
for _, service := range b.conf.Services {
var isHandled bool
var hasAssociatedHandler bool
for _, handler := range b.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
isHandled = true
hasAssociatedHandler = true
break
}
}
if !isHandled {
if !hasAssociatedHandler {
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
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")
// 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
const errMissingRequestArgument = cerr("handler first argument must be of type api.Request")
@ -47,4 +47,4 @@ const errMissingOutputFromConfig = cerr("missing a parameter from configuration"
const errWrongParamTypeFromConfig = cerr("invalid struct field type")
// errMissingHandlerErrorOutput - missing handler output error
const errMissingHandlerErrorOutput = cerr("last output must be of type api.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
//
// @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)
// - `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
func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Err) {
var ert = reflect.TypeOf(api.Err{})
var fnv = reflect.ValueOf(h.fn)
func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Error) {
fnv := reflect.ValueOf(h.fn)
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
outdata := make(map[string]interface{})
if len(h.spec.Output) < 1 || output[0].IsNil() {
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()),
}
return outdata, api.Error(output[len(output)-1].Int())
}
// extract struct from pointer
@ -97,11 +91,6 @@ func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, a
outdata[name] = field.Interface()
}
// extract api.Err
var structerr = output[len(output)-1].Convert(ert)
return outdata, api.Err{
Code: int(structerr.FieldByName("Code").Int()),
Reason: structerr.FieldByName("Reason").String(),
Status: int(structerr.FieldByName("Status").Int()),
}
// extract api.Error
return outdata, api.Error(output[len(output)-1].Int())
}

View File

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

View File

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

View File

@ -1,7 +1,6 @@
package reqdata
import (
"fmt"
"math"
"testing"
)
@ -25,7 +24,7 @@ func TestSimpleFloat(t *testing.T) {
tcases := []float64{12.3456789, -12.3456789, 0.0000001, -0.0000001}
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)
cast, canCast := p.(float64)
@ -46,7 +45,7 @@ func TestSimpleBool(t *testing.T) {
tcases := []bool{true, false}
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)
cast, canCast := p.(bool)
@ -137,7 +136,7 @@ func TestJsonPrimitiveBool(t *testing.T) {
}
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)
cast, canCast := p.(bool)
@ -174,7 +173,7 @@ func TestJsonPrimitiveFloat(t *testing.T) {
}
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)
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
}