aicra

Fast, intuitive, and powerful configuration-driven engine for faster and easier REST development.

License: MIT Go version Go doc Go Report Card Build status

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

  1. you can use the below Go command to install aicra.

$ go get -u github.com/xdrm-io/aicra
  1. 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 method
  • path an uri pattern (can contain variables)
  • info a short description of what it does
  • scope a list of the required permissions
  • in a list of input arguments
  • out 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 is
  • type its data type (c.f. validation)
  • ? whether it is mandatory or optional
  • name 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"    },
            "[email protected]": { "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:

  1. {param} is an URI parameter that is extracted from the "path"
  2. [email protected] is an URL parameter that is extracted from the HTTP Query syntax.
  3. 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"    },
            "[email protected]": { "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" }
        }
    }
]
  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. [email protected] is extracted from the query (e.g. 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.

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, error) {
    err := doSomething()
    if err != nil {
        return nil, api.ErrFailure
    }
    return &res{"out1", true}, nil
}

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:

HTTP/1.1 500 OK
Content-Type: application/json
{
    "status": "it failed"
}

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 value
    • int – see go types
    • uint – see go types
    • float – see go types
    • string – any text
    • string(len) – any string with a length of exactly len characters
    • 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)
  • generic handler implementation
  • responder interface
  • generic errors that automatically formats into response
    • only use the error interface, with an optional interface{ Status() int } to associate http status codes
    • 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
  • check for unavailable types in configuration at server boot
  • recover panics from handlers
  • improve tests and coverage

GitHub

https://github.com/xdrm-io/aicra