Adrien Marquès 8b92abd1c2 | ||
---|---|---|
.github/workflows | ||
api | ||
internal | ||
readme.assets | ||
validator | ||
.gitignore | ||
LICENSE | ||
README.md | ||
builder.go | ||
builder_test.go | ||
errors.go | ||
go.mod | ||
go.sum | ||
handler.go | ||
handler_test.go | ||
response.go | ||
response_test.go |
README.md
aicra
Fast, intuitive, and powerful configuration-driven engine for faster and easier REST development.
Presentation
aicra
is a lightweight and idiomatic configuration-driven engine for building REST services. It's especially good at helping you write large APIs that remain maintainable as your project grows.
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 single configuration file to drive the server. This one file describes your entire API: methods, uris, input data, expected output, permissions, etc.
Repetitive tasks are automatically processed by aicra
based on your configuration, you're left with implementing your handlers (usually business logic).
Table of contents
Installation
To install the aicra package, you need to install Go and set your Go workspace first.
not tested under Go 1.14
- you can use the below Go command to install aicra.
$ go get -u github.com/xdrm-io/aicra
- Import it in your code:
import "github.com/xdrm-io/aicra"
What's automated
As the configuration file is here to make your life easier, let's take a quick look at what you do not have to do ; or in other words, what does aicra
automates.
Http requests are only accepted when they have the permissions you have defined. If unauthorized, the request is rejected with an error response.
Request data is automatically extracted and validated before it reaches your code. If a request has missing or invalid data an automatic error response is sent.
When launching the server, it ensures everything is ok and won't start until fixed. You will get errors for:
- handler signature does not match the configuration
- a configuration service has no handler
- a handler does not match any service
The same applies if your configuration is invalid:
- unknown HTTP method
- invalid uri
- uri collision between 2 services
- missing fields
- unknown data type
- input name collision
Getting started
Here is the minimal code to launch your aicra server assuming your configuration file is api.json
.
package main
import (
"log"
"net/http"
"os"
"github.com/xdrm-io/aicra"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/validator/builtin"
)
func main() {
builder := &aicra.Builder{}
// add custom type validators
builder.Validate(validator.BoolDataType{})
builder.Validate(validator.UintDataType{})
builder.Validate(validator.StringDataType{})
// load your configuration
config, err := os.Open("api.json")
if err != nil {
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)
}
// add http middlewares (logger)
builder.With(func(next http.Handler) http.Handler{ /* ... */ })
// add contextual middlewares (authentication)
builder.WithContext(func(next http.Handler) http.Handler{ /* ... */ })
// bind handlers
err = builder.Bind(http.MethodGet, "/user/{id}", getUserById)
if err != nil {
log.Fatalf("cannog bind GET /user/{id}: %s", err)
}
// build your services
handler, err := builder.Build()
if err != nil {
log.Fatalf("cannot build handler: %s", err)
}
http.ListenAndServe("localhost:8080", handler)
}
If you want to use HTTPS, you can configure your own http.Server
.
func main() {
server := &http.Server{
Addr: "localhost:8080",
TLSConfig: tls.Config{},
// ...
Handler: AICRAHandler,
}
server.ListenAndServe()
}
Configuration file
First of all, the configuration uses json
.
Quick note if you thought: "I hate JSON, I would have preferred yaml, or even xml !"
I've had a hard time deciding and testing different formats including yaml and xml. But as it describes our entire api and is crucial for our server to keep working over updates; xml would have been too verbose with growth and yaml on the other side would have been too difficult to read. Json sits in the right spot for this.
Let's take a quick look at the configuration format !
if you don't like boring explanations and prefer a working example, see here
Services
To begin with, the configuration file defines a list of services. Each one is defined by:
method
an HTTP methodpath
an uri pattern (can contain variables)info
a short description of what it doesscope
a list of the required permissionsin
a list of input argumentsout
a list of output arguments
[
{
"method": "GET",
"path": "/article",
"scope": [["author", "reader"], ["admin"]],
"info": "returns all available articles",
"in": {},
"out": {}
}
]
The scope
is a 2-dimensional list of permissions. The first list means or, the second means and, it allows for complex permission combinations. The example above can be translated to: this method requires users to have permissions (author and reader) or (admin)
Input and output parameters
Input and output parameters share the same format, featuring:
info
a short description of what it istype
its data type (c.f. validation)?
whether it is mandatory or optionalname
a custom name for easy access in code
[
{
"method": "PUT",
"path": "/article/{id}",
"scope": [["author"]],
"info": "updates an article",
"in": {
"{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" }
},
"out": {
"title": { "info": "updated article title", "type": "string" },
"content": { "info": "updated article content", "type": "string" }
}
}
]
If a parameter is optional you just have to prefix its type with a question mark, by default all parameters are mandatory.
The format of the key of input arguments defines where it comes from:
{param}
is an URI parameter that is extracted from the"path"
GET@param
is an URL parameter that is extracted from the HTTP Query syntax.param
is a body parameter that can be extracted from 3 formats independently:- url encoded: data send in the body following the HTTP Query syntax.
- multipart: data send in the body with a dedicated format. This format can be quite heavy but allows to transmit data as well as files.
- JSON: data sent in the body as a json object ; The Content-Type header must be
application/json
for it to work.
Example
[
{
"method": "PUT",
"path": "/article/{id}",
"scope": [["author"]],
"info": "updates an article",
"in": {
"{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" }
},
"out": {
"id": { "info": "updated article id", "type": "uint" },
"title": { "info": "updated article title", "type": "string" },
"content": { "info": "updated article content", "type": "string" }
}
}
]
{id}
is extracted from the end of the URI and is a number compliant with theint
type checker. It is renamedID
, this new name will be sent to the handler.GET@title
is extracted from the query (e.g. http://host/uri?get-var=value). It must be a validstring
or not given at all (the?
at the beginning of the type tells that the argument is optional) ; it will be namedtitle
.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 namecontent
.
Writing your code
Besides your main package where you launch your server, you will need to create handlers matching services from the configuration.
The code below implements a simple handler.
// "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(ctx context.Context, 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 won't start.
The api.Err
type automatically maps to HTTP status codes and error descriptions that will be sent to the client as json; clients have to manage the same format for every response.
{
"error": {
"code": 0,
"reason": "all right"
}
}
Changelog
- human-readable json configuration
- nested routes (i.e.
/user/{id}
and/user/post/{id}
) - nested URL arguments (i.e.
/user/{id}
and/user/{uid}/post/{id}
) - 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
- manage request data extraction:
- URL slash-separated strings
- HTTP Query named parameters
- manage array format
- body parameters
- multipart/form-data (variables and file uploads)
- application/x-www-form-urlencoded
- application/json
- required vs. optional parameters with a default value
- parameter renaming
- generic type check (i.e. you can add custom types alongside built-in ones)
- built-in types
any
- matches any valueint
- see go typesuint
- see go typesfloat
- see go typesstring
- any textstring(len)
- any string with a length of exactlylen
charactersstring(min, max)
- any string with a length betweenmin
andmax
[]a
- array containing only elements matchinga
typea[b]
- map containing only keys of typea
and values of typeb
(a or b can be ommited)
- generic handler implementation
- response interface
- generic errors that automatically formats into response
- builtin errors
- possibility to add custom errors
- check for missing handlers when building the handler
- check handlers not matching a route in the configuration at server boot
- specific configuration format errors qt server boot
- statically typed handlers - avoids having to check every input and its type (which is used by context.Context for instance)
- using reflection to use structs as input and output arguments to match the configuration
- check for input and output arguments structs at server boot
- using reflection to use structs as input and output arguments to match the configuration
- check for unavailable types in configuration at server boot
- recover panics from handlers
- improve tests and coverage