Compare commits

..

No commits in common. "0.4.0" and "master" have entirely different histories.

64 changed files with 1916 additions and 4892 deletions

11
.drone.yml Normal file
View File

@ -0,0 +1,11 @@
---
kind: pipeline
type: docker
name: default
steps:
- name: test
image: golang:1.13
commands:
- go get ./...
- go test -v -race -cover -coverprofile ./coverage.out ./...

View File

@ -1,27 +0,0 @@
name: Go
on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.16
- name: Build
run: go build -v ./...
- name: Test
run: go test -race -v ./... -cover

378
README.md
View File

@ -1,83 +1,61 @@
<p align="center">
<a href="https://github.com/xdrm-io/aicra">
<img src="https://github.com/xdrm-io/aicra/raw/0.4.0/readme.assets/logo.png" alt="aicra logo" width="200" height="200">
</a>
</p>
<h3 align="center">aicra</h3>
<p align="center">
Fast, intuitive, and powerful configuration-driven engine for faster and easier <em>REST</em> development.
</p>
# | aicra |
[![Go version](https://img.shields.io/badge/go_version-1.10.3-blue.svg)](https://golang.org/doc/go1.10)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go version](https://img.shields.io/badge/go_version-1.16-blue.svg)](https://golang.org/doc/go1.16)
[![Go doc](https://pkg.go.dev/badge/github.com/xdrm-io/aicra)](https://pkg.go.dev/github.com/xdrm-io/aicra)
[![Go Report Card](https://goreportcard.com/badge/github.com/xdrm-io/aicra)](https://goreportcard.com/report/github.com/xdrm-io/aicra)
[![Build status](https://github.com/xdrm-io/aicra/actions/workflows/go.yml/badge.svg)](https://github.com/xdrm-io/aicra/actions/workflows/go.yml)
[![Go Report Card](https://goreportcard.com/badge/git.xdrm.io/go/aicra)](https://goreportcard.com/report/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)
## 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.
**Aicra** is a *configuration-driven* **web framework** written in Go that allows you to create a fully featured REST API.
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.
The whole management is done for you from 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
Repetitive tasks are automatically processed by `aicra` based on your configuration, you're left with implementing your handlers (_usually business logic_).
## Table of contents
The aicra server fulfills the `net/http` [Server interface](https://golang.org/pkg/net/http/#Server).
> A example project is available [here](https://git.xdrm.io/go/tiny-url-ex)
### Table of contents
<!-- toc -->
- [Installation](#installation)
- [What's automated](#whats-automated)
- [Getting started](#getting-started)
- [Configuration file](#configuration-file)
* [Services](#services)
* [Input and output parameters](#input-and-output-parameters)
* [Example](#example)
- [Writing your code](#writing-your-code)
- [Changelog](#changelog)
- [I/ Installation](#i-installation)
- [II/ Development](#ii-development)
* [1) Main executable](#1-main-executable)
* [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). This package has not been tested under the version **1.10**.
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.
```bash
$ go get -u github.com/xdrm-io/aicra
```
2. Import it in your code:
```go
import "github.com/xdrm-io/aicra"
go get -u git.xdrm.io/go/aicra/cmd/aicra
```
## What's automated
The library should now be available as `git.xdrm.io/go/aicra` in your imports.
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.
### II/ Development
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
#### 1) Main executable
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`.
Your main executable will declare and run the aicra server, it might look quite like the code below.
```go
package main
@ -85,110 +63,106 @@ 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"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func main() {
builder := &aicra.Builder{}
// add custom type validators
builder.Validate(validator.BoolDataType{})
builder.Validate(validator.UintDataType{})
builder.Validate(validator.StringDataType{})
// 1. select your datatypes (builtin, custom)
var dtypes []datatype.T
dtypes = append(dtypes, builtin.AnyDataType{})
dtypes = append(dtypes, builtin.BoolDataType{})
dtypes = append(dtypes, builtin.UintDataType{})
dtypes = append(dtypes, builtin.StringDataType{})
// load your configuration
config, err := os.Open("api.json")
// 2. create the server from the configuration file
server, err := aicra.New("path/to/your/api/definition.json", dtypes...)
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)
log.Fatalf("cannot built aicra server: %s\n", err)
}
// add http middlewares (logger)
builder.With(func(next http.Handler) http.Handler{ /* ... */ })
// 3. bind your implementations
server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){
// ... process stuff ...
res.SetError(api.ErrorSuccess());
})
// add contextual middlewares (authentication)
builder.WithContext(func(next http.Handler) http.Handler{ /* ... */ })
// bind handlers
err = builder.Bind(http.MethodGet, "/user/{id}", getUserById)
// 4. extract to http server
httpServer, err := server.ToHTTPServer()
if err != nil {
log.Fatalf("cannog bind GET /user/{id}: %s", err)
log.Fatalf("cannot get to http server: %s", err)
}
// build your services
handler, err := builder.Build()
if err != nil {
log.Fatalf("cannot build handler: %s", err)
}
http.ListenAndServe("localhost:8080", handler)
// 4. launch server
log.Fatal( http.ListenAndServe("localhost:8080", server) )
}
```
If you want to use HTTPS, you can configure your own `http.Server`.
```go
func main() {
server := &http.Server{
Addr: "localhost:8080",
TLSConfig: tls.Config{},
// ...
Handler: AICRAHandler,
}
server.ListenAndServe()
}
```
## Configuration file
First of all, the configuration uses `json`.
#### 2) API Configuration
> Quick note if you thought: "I hate JSON, I would have preferred yaml, or even xml !"
The whole project 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/tiny-url-ex/src/master/api.json). This file defines :
- routes and their methods
- every input for each method (called *argument*)
- every output for each method
- scope permissions (list of permissions needed by clients)
- input policy :
- type of argument (_i.e. for data types_)
- required/optional
- variable renaming
###### Definition
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.
| 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. See [here](#input-arguments) for details. | |
| `out` | The list of output data that will be returned by your controllers. It has the same syntax as the `in` field but is only use for readability purpose and documentation. | |
##### Input Arguments
###### 1. Input types
Input arguments defines what data from the HTTP request the method needs. Aicra is able to extract 3 types of data :
- **URI** - Curly Braces enclosed strings 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.
- **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.
###### 2. Global 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 :
>
> 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.
> - the **URI** variable `{id}` from your request route must be named `{id}`.
> - the variable `somevar` in the **Query** has to be names `GET@somevar`.
Let's take a quick look at the configuration format !
**Example**
> if you don't like boring explanations and prefer a working example, see [here](https://git.xdrm.io/go/articles-api/src/master/api.json)
In this example we want 3 arguments :
### Services
- 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`.
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
```json
[
{
"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
```json
[
{
@ -197,40 +171,9 @@ Input and output parameters share the same format, featuring:
"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:
1. `{param}` is an URI parameter that is extracted from the `"path"`
2. `GET@param` is an URL parameter that is extracted from the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) 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](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 sent in the body as a json object ; The _Content-Type_ header must be `application/json` for it to work.
### Example
```json
[
{
"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" }
"{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" },
@ -241,99 +184,32 @@ The format of the key of input arguments defines where it comes from:
]
```
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`.
## 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.
```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(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.
```json
{
"error": {
"code": 0,
"reason": "all right"
}
}
```
## Changelog
### III/ Change Log
- [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] nested routes (*i.e. `/user/:id:` and `/user/post/:id:`*)
- [x] nested URL arguments (*i.e. `/user/:id:` and `/user/:id:/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] manage URL, query and body arguments:
- [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] generic type check (*i.e. implement custom types alongside built-in ones*)
- [ ] built-in types
- [x] `any` - wildcard matching all values
- [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
- [ ] `[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 controllers implementation (shared objects)
- [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
- [x] log bound resources when building the aicra server
- [x] fail on check for unimplemented resources at server boot.
- [x] fail on check for unavailable types in api.json at server boot.

View File

@ -1,62 +0,0 @@
package api
// Auth can be used by http middleware to
// 1) consult required roles in @Auth.Required
// 2) update active roles in @Auth.Active
type Auth struct {
// required roles for this request
// - the first dimension of the array reads as a OR
// - the second dimension reads as a AND
//
// Example:
// [ [A, B], [C, D] ] reads: roles (A and B) or (C and D) are required
//
// Warning: must not be mutated
Required [][]string
// active roles to be updated by authentication
// procedures (e.g. jwt)
Active []string
}
// Granted returns whether the authorization is granted
// i.e. Auth.Active fulfills Auth.Required
func (a *Auth) Granted() bool {
var nothingRequired = true
// first dimension: OR ; at least one is valid
for _, required := range a.Required {
// empty list
if len(required) < 1 {
continue
}
nothingRequired = false
// second dimension: AND ; all required must be fulfilled
if a.fulfills(required) {
return true
}
}
return nothingRequired
}
// returns whether Auth.Active fulfills (contains) all @required roles
func (a *Auth) fulfills(required []string) bool {
for _, requiredRole := range required {
var found = false
for _, activeRole := range a.Active {
if activeRole == requiredRole {
found = true
break
}
}
// missing role -> fail
if !found {
return false
}
}
// all @required are fulfilled
return true
}

View File

@ -1,114 +0,0 @@
package api
import (
"testing"
)
func TestCombination(t *testing.T) {
tcases := []struct {
Name string
Required [][]string
Active []string
Granted bool
}{
{
Name: "no requirement none given",
Required: [][]string{},
Active: []string{},
Granted: true,
},
{
Name: "empty requirements none given",
Required: [][]string{{}},
Active: []string{},
Granted: true,
},
{
Name: "no requirement 1 given",
Required: [][]string{},
Active: []string{"a"},
Granted: true,
},
{
Name: "no requirement some given",
Required: [][]string{},
Active: []string{"a", "b"},
Granted: true,
},
{
Name: "1 required none given",
Required: [][]string{{"a"}},
Active: []string{},
Granted: false,
},
{
Name: "1 required fulfilled",
Required: [][]string{{"a"}},
Active: []string{"a"},
Granted: true,
},
{
Name: "1 required mismatch",
Required: [][]string{{"a"}},
Active: []string{"b"},
Granted: false,
},
{
Name: "2 required none gien",
Required: [][]string{{"a", "b"}},
Active: []string{},
Granted: false,
},
{
Name: "2 required other given",
Required: [][]string{{"a", "b"}},
Active: []string{"c"},
Granted: false,
},
{
Name: "2 required one given",
Required: [][]string{{"a", "b"}},
Active: []string{"a"},
Granted: false,
},
{
Name: "2 required fulfilled",
Required: [][]string{{"a", "b"}},
Active: []string{"a", "b"},
Granted: true,
},
{
Name: "2 or 2 required first fulfilled",
Required: [][]string{{"a", "b"}, {"c", "d"}},
Active: []string{"a", "b"},
Granted: true,
},
{
Name: "2 or 2 required second fulfilled",
Required: [][]string{{"a", "b"}, {"c", "d"}},
Active: []string{"c", "d"},
Granted: true,
},
}
for _, tcase := range tcases {
t.Run(tcase.Name, func(t *testing.T) {
auth := Auth{
Required: tcase.Required,
Active: tcase.Active,
}
// all right
if tcase.Granted == auth.Granted() {
return
}
if tcase.Granted && !auth.Granted() {
t.Fatalf("expected granted authorization")
}
t.Fatalf("unexpected granted authorization")
})
}
}

View File

@ -1,44 +0,0 @@
package api
import (
"context"
"net/http"
"github.com/xdrm-io/aicra/internal/ctx"
)
// GetRequest extracts the current request from a context.Context
func GetRequest(c context.Context) *http.Request {
var (
raw = c.Value(ctx.Request)
cast, ok = raw.(*http.Request)
)
if !ok {
return nil
}
return cast
}
// GetResponseWriter extracts the response writer from a context.Context
func GetResponseWriter(c context.Context) http.ResponseWriter {
var (
raw = c.Value(ctx.Response)
cast, ok = raw.(http.ResponseWriter)
)
if !ok {
return nil
}
return cast
}
// GetAuth returns the api.Auth associated with this request from a context.Context
func GetAuth(c context.Context) *Auth {
var (
raw = c.Value(ctx.Auth)
cast, ok = raw.(*Auth)
)
if !ok {
return nil
}
return cast
}

View File

@ -1,79 +0,0 @@
package api_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/ctx"
)
func TestContextGetRequest(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/random", nil)
if err != nil {
t.Fatalf("cannot create http request: %s", err)
}
// store in bare context
c := context.Background()
c = context.WithValue(c, ctx.Request, req)
// fetch from context
fetched := api.GetRequest(c)
if fetched != req {
t.Fatalf("fetched http request %v ; expected %v", fetched, req)
}
}
func TestContextGetNilRequest(t *testing.T) {
// fetch from bare context
fetched := api.GetRequest(context.Background())
if fetched != nil {
t.Fatalf("fetched http request %v from empty context; expected nil", fetched)
}
}
func TestContextGetResponseWriter(t *testing.T) {
res := httptest.NewRecorder()
// store in bare context
c := context.Background()
c = context.WithValue(c, ctx.Response, res)
// fetch from context
fetched := api.GetResponseWriter(c)
if fetched != res {
t.Fatalf("fetched http response writer %v ; expected %v", fetched, res)
}
}
func TestContextGetNilResponseWriter(t *testing.T) {
// fetch from bare context
fetched := api.GetResponseWriter(context.Background())
if fetched != nil {
t.Fatalf("fetched http response writer %v from empty context; expected nil", fetched)
}
}
func TestContextGetAuth(t *testing.T) {
auth := &api.Auth{}
// store in bare context
c := context.Background()
c = context.WithValue(c, ctx.Auth, auth)
// fetch from context
fetched := api.GetAuth(c)
if fetched != auth {
t.Fatalf("fetched api auth %v ; expected %v", fetched, auth)
}
}
func TestContextGetNilAuth(t *testing.T) {
// fetch from bare context
fetched := api.GetAuth(context.Background())
if fetched != nil {
t.Fatalf("fetched api auth %v from empty context; expected nil", fetched)
}
}

View File

@ -1,84 +1,94 @@
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}
// ErrorConfig has to be set when there is a configuration error
ErrorConfig Error = 4
// ErrModification is set when there is an update/modification error
ErrModification = Err{5, "update error", http.StatusOK}
// ErrorUpload has to be set when a file upload failed
ErrorUpload Error = 100
// ErrDeletion is set when there is a deletion/removal error
ErrDeletion = Err{6, "delete error", http.StatusOK}
// ErrorDownload has to be set when a file download failed
ErrorDownload Error = 101
// ErrTransaction is set when there is a transactional error
ErrTransaction = Err{7, "transactional error", http.StatusOK}
// ErrUpload is set when a file upload failed
ErrUpload = Err{100, "upload failed", http.StatusInternalServerError}
// ErrDownload is set when a file download failed
ErrDownload = Err{101, "download failed", http.StatusInternalServerError}
// 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",
ErrorConfig: "configuration 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",
}

View File

@ -1,21 +1,42 @@
package api
import (
"encoding/json"
"fmt"
)
// 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 `json:"-"`
type Error int
// Error implements the error interface
func (e Error) Error() string {
// use unknown error if no reason
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.Error()
}
func (e Err) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Reason)
return fmt.Sprintf("[%d] %s", e, reason)
}
// 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)
}

59
api/request.go Normal file
View File

@ -0,0 +1,59 @@
package api
import (
"net/http"
"strings"
)
// Request represents an API request i.e. HTTP
type Request struct {
// corresponds to the list of uri components
// featured in the request URI
URI []string
// Scope from the configuration file of the current service
Scope [][]string
// original HTTP request
Request *http.Request
// input parameters
Param RequestParam
}
// NewRequest builds an interface request from a http.Request
func NewRequest(req *http.Request) (*Request, error) {
// 1. get useful data
uri := normaliseURI(req.URL.Path)
uriparts := strings.Split(uri, "/")
// 3. Init request
inst := &Request{
URI: uriparts,
Scope: nil,
Request: req,
Param: make(RequestParam),
}
return inst, nil
}
// normaliseURI removes the trailing '/' to always
// have the same Uri format for later processing
func normaliseURI(uri string) string {
if len(uri) < 1 {
return uri
}
if uri[0] == '/' {
uri = uri[1:]
}
if len(uri) > 1 && uri[len(uri)-1] == '/' {
uri = uri[0 : len(uri)-1]
}
return uri
}

162
api/request.param.go Normal file
View File

@ -0,0 +1,162 @@
package api
import (
"fmt"
)
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// ErrReqParamNotFound is thrown when a request parameter is not found
const ErrReqParamNotFound = cerr("request parameter not found")
// ErrReqParamNotType is thrown when a request parameter is not asked with the right type
const ErrReqParamNotType = cerr("request parameter does not fulfills type")
// RequestParam defines input parameters of an api request
type RequestParam map[string]interface{}
// Get returns the raw value (not typed) and an error if not found
func (rp RequestParam) Get(key string) (interface{}, error) {
rawValue, found := rp[key]
if !found {
return "", ErrReqParamNotFound
}
return rawValue, nil
}
// GetString returns a string and an error if not found or invalid type
func (rp RequestParam) GetString(key string) (string, error) {
rawValue, err := rp.Get(key)
if err != nil {
return "", err
}
switch cast := rawValue.(type) {
case fmt.Stringer:
return cast.String(), nil
case []byte:
return string(cast), nil
case string:
return cast, nil
default:
return "", ErrReqParamNotType
}
}
// GetFloat returns a float64 and an error if not found or invalid type
func (rp RequestParam) GetFloat(key string) (float64, error) {
rawValue, err := rp.Get(key)
if err != nil {
return 0, err
}
switch cast := rawValue.(type) {
case float32:
return float64(cast), nil
case float64:
return cast, nil
case int, int8, int16, int32, int64:
intVal, ok := cast.(int)
if !ok || intVal != int(float64(intVal)) {
return 0, ErrReqParamNotType
}
return float64(intVal), nil
case uint, uint8, uint16, uint32, uint64:
uintVal, ok := cast.(uint)
if !ok || uintVal != uint(float64(uintVal)) {
return 0, ErrReqParamNotType
}
return float64(uintVal), nil
default:
return 0, ErrReqParamNotType
}
}
// GetInt returns an int and an error if not found or invalid type
func (rp RequestParam) GetInt(key string) (int, error) {
rawValue, err := rp.Get(key)
if err != nil {
return 0, err
}
switch cast := rawValue.(type) {
case float32, float64:
floatVal, ok := cast.(float64)
if !ok || floatVal < 0 || floatVal != float64(int(floatVal)) {
return 0, ErrReqParamNotType
}
return int(floatVal), nil
case int, int8, int16, int32, int64:
intVal, ok := cast.(int)
if !ok || intVal != int(int(intVal)) {
return 0, ErrReqParamNotType
}
return int(intVal), nil
default:
return 0, ErrReqParamNotType
}
}
// GetUint returns an uint and an error if not found or invalid type
func (rp RequestParam) GetUint(key string) (uint, error) {
rawValue, err := rp.Get(key)
if err != nil {
return 0, err
}
switch cast := rawValue.(type) {
case float32, float64:
floatVal, ok := cast.(float64)
if !ok || floatVal < 0 || floatVal != float64(uint(floatVal)) {
return 0, ErrReqParamNotType
}
return uint(floatVal), nil
case int, int8, int16, int32, int64:
intVal, ok := cast.(int)
if !ok || intVal != int(uint(intVal)) {
return 0, ErrReqParamNotType
}
return uint(intVal), nil
case uint, uint8, uint16, uint32, uint64:
uintVal, ok := cast.(uint)
if !ok {
return 0, ErrReqParamNotType
}
return uintVal, nil
default:
return 0, ErrReqParamNotType
}
}
// GetStrings returns an []slice and an error if not found or invalid type
func (rp RequestParam) GetStrings(key string) ([]string, error) {
rawValue, err := rp.Get(key)
if err != nil {
return nil, err
}
switch cast := rawValue.(type) {
case []fmt.Stringer:
strings := make([]string, len(cast))
for i, stringer := range cast {
strings[i] = stringer.String()
}
return strings, nil
case [][]byte:
strings := make([]string, len(cast))
for i, bytes := range cast {
strings[i] = string(bytes)
}
return strings, nil
case []string:
return cast, nil
default:
return nil, ErrReqParamNotType
}
}

77
api/response.go Normal file
View File

@ -0,0 +1,77 @@
package api
import (
"encoding/json"
"net/http"
)
// ResponseData defines format for response parameters to return
type ResponseData map[string]interface{}
// Response represents an API response to be sent
type Response struct {
Data ResponseData
Status int
Headers http.Header
err Error
}
// EmptyResponse creates an empty response.
func EmptyResponse() *Response {
return &Response{
Status: http.StatusOK,
Data: make(ResponseData),
err: ErrorFailure,
Headers: make(http.Header),
}
}
// WithError sets the error from a base error with error arguments.
func (res *Response) WithError(err Error) *Response {
res.err = err
return res
}
// Error implements the error interface and dispatches to internal error.
func (res *Response) Error() string {
return res.err.Error()
}
// SetData adds/overrides a new response field
func (res *Response) SetData(name string, value interface{}) {
res.Data[name] = value
}
// GetData gets a response field
func (res *Response) GetData(name string) interface{} {
value, _ := res.Data[name]
return value
}
// MarshalJSON implements the 'json.Marshaler' interface and is used
// to generate the JSON representation of the response
func (res *Response) MarshalJSON() ([]byte, error) {
fmt := make(map[string]interface{})
for k, v := range res.Data {
fmt[k] = v
}
fmt["error"] = res.err
return json.Marshal(fmt)
}
// ServeHTTP implements http.Handler and writes the API response.
func (res *Response) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(res.Status)
encoded, err := json.Marshal(res)
if err != nil {
return err
}
w.Write(encoded)
return nil
}

View File

@ -1,157 +0,0 @@
package aicra
import (
"fmt"
"io"
"net/http"
"github.com/xdrm-io/aicra/internal/config"
"github.com/xdrm-io/aicra/internal/dynfunc"
"github.com/xdrm-io/aicra/validator"
)
// Builder for an aicra server
type Builder struct {
// the server configuration defining available services
conf *config.Server
// user-defined handlers bound to services from the configuration
handlers []*apiHandler
// http middlewares wrapping the entire http connection (e.g. logger)
middlewares []func(http.Handler) http.Handler
// custom middlewares only wrapping the service handler of a request
// they will benefit from the request's context that contains service-specific
// information (e.g. required permissions from the configuration)
ctxMiddlewares []func(http.Handler) http.Handler
}
// represents an api handler (method-pattern combination)
type apiHandler struct {
Method string
Path string
dyn *dynfunc.Handler
}
// Validate adds an available Type to validate in the api definition
func (b *Builder) Validate(t validator.Type) error {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
return errLateType
}
if b.conf.Validators == nil {
b.conf.Validators = make([]validator.Type, 0)
}
b.conf.Validators = append(b.conf.Validators, t)
return nil
}
// With adds an http middleware on top of the http connection
//
// Authentication management can only be done with the WithContext() methods as
// the service associated with the request has not been found at this stage.
// This stage is perfect for logging or generic request management.
func (b *Builder) With(mw func(http.Handler) http.Handler) {
if b.middlewares == nil {
b.middlewares = make([]func(http.Handler) http.Handler, 0)
}
b.middlewares = append(b.middlewares, mw)
}
// WithContext adds an http middleware with the fully loaded context
//
// Logging or generic request management should be done with the With() method as
// it wraps the full http connection. Middlewares added through this method only
// wrap the user-defined service handler. The context.Context is filled with useful
// data that can be access with api.GetRequest(), api.GetResponseWriter(),
// api.GetAuth(), etc methods.
func (b *Builder) WithContext(mw func(http.Handler) http.Handler) {
if b.ctxMiddlewares == nil {
b.ctxMiddlewares = make([]func(http.Handler) http.Handler, 0)
}
b.ctxMiddlewares = append(b.ctxMiddlewares, mw)
}
// Setup the builder with its api definition file
// panics if already setup
func (b *Builder) Setup(r io.Reader) error {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
return errAlreadySetup
}
return b.conf.Parse(r)
}
// Bind a dynamic handler to a REST service (method and pattern)
func (b *Builder) Bind(method, path string, fn interface{}) error {
if b.conf == nil || b.conf.Services == nil {
return errNotSetup
}
// find associated service from config
var service *config.Service
for _, s := range b.conf.Services {
if method == s.Method && path == s.Pattern {
service = s
break
}
}
if service == nil {
return fmt.Errorf("%s '%s': %w", method, path, errUnknownService)
}
var dyn, err = dynfunc.Build(fn, *service)
if err != nil {
return fmt.Errorf("%s '%s' handler: %w", method, path, err)
}
b.handlers = append(b.handlers, &apiHandler{
Path: path,
Method: method,
dyn: dyn,
})
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
for _, handler := range b.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
isHandled = true
break
}
}
if !isHandled {
return nil, fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, errMissingHandler)
}
}
return Handler(b), nil
}

View File

@ -1,439 +0,0 @@
package aicra
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/dynfunc"
"github.com/xdrm-io/aicra/validator"
)
func addBuiltinTypes(b *Builder) error {
if err := b.Validate(validator.AnyType{}); err != nil {
return err
}
if err := b.Validate(validator.BoolType{}); err != nil {
return err
}
if err := b.Validate(validator.FloatType{}); err != nil {
return err
}
if err := b.Validate(validator.IntType{}); err != nil {
return err
}
if err := b.Validate(validator.StringType{}); err != nil {
return err
}
if err := b.Validate(validator.UintType{}); err != nil {
return err
}
return nil
}
func TestAddType(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Validate(validator.BoolType{})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Setup(strings.NewReader("[]"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Validate(validator.FloatType{})
if err != errLateType {
t.Fatalf("expected <%v> got <%v>", errLateType, err)
}
}
func TestSetupNoType(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader("[]"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
}
func TestSetupTwice(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader("[]"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// double Setup() must fail
err = builder.Setup(strings.NewReader("[]"))
if err != errAlreadySetup {
t.Fatalf("expected error %v, got %v", errAlreadySetup, err)
}
}
func TestBindBeforeSetup(t *testing.T) {
t.Parallel()
builder := &Builder{}
// binding before Setup() must fail
err := builder.Bind(http.MethodGet, "/path", func() {})
if err != errNotSetup {
t.Fatalf("expected error %v, got %v", errNotSetup, err)
}
}
func TestBindUnknownService(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader("[]"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Bind(http.MethodGet, "/path", func() {})
if !errors.Is(err, errUnknownService) {
t.Fatalf("expected error %v, got %v", errUnknownService, err)
}
}
func TestBindInvalidHandler(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader(`[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Bind(http.MethodGet, "/path", func() {})
if err == nil {
t.Fatalf("expected an error")
}
if !errors.Is(err, dynfunc.ErrMissingHandlerContextArgument) {
t.Fatalf("expected a dynfunc.Err got %v", err)
}
}
func TestBindGet(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader(`[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
},
{
"method": "POST",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
},
{
"method": "PUT",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
},
{
"method": "DELETE",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Get("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
err = builder.Post("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
err = builder.Put("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
err = builder.Delete("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
}
func TestUnhandledService(t *testing.T) {
t.Parallel()
builder := &Builder{}
err := builder.Setup(strings.NewReader(`[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
},
{
"method": "POST",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Get("/path", func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess })
if err != nil {
t.Fatalf("unexpected error %v", err)
}
_, err = builder.Build()
if !errors.Is(err, errMissingHandler) {
t.Fatalf("expected a %v error, got %v", errMissingHandler, err)
}
}
func TestBind(t *testing.T) {
t.Parallel()
tcases := []struct {
Name string
Config string
HandlerMethod string
HandlerPath string
HandlerFn interface{} // not bound if nil
BindErr error
BuildErr error
}{
{
Name: "none required none provided",
Config: "[]",
HandlerMethod: "",
HandlerPath: "",
HandlerFn: nil,
BindErr: nil,
BuildErr: nil,
},
{
Name: "none required 1 provided",
Config: "[]",
HandlerMethod: "",
HandlerPath: "",
HandlerFn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: nil,
},
{
Name: "1 required none provided",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`,
HandlerMethod: "",
HandlerPath: "",
HandlerFn: nil,
BindErr: nil,
BuildErr: errMissingHandler,
},
{
Name: "1 required wrong method provided",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`,
HandlerMethod: http.MethodPost,
HandlerPath: "/path",
HandlerFn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: errMissingHandler,
},
{
Name: "1 required wrong path provided",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/paths",
HandlerFn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: errMissingHandler,
},
{
Name: "1 required valid provided",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
{
Name: "1 required with int",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {
"id": { "info": "info", "type": "int", "name": "Name" }
},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(context.Context, struct{ Name int }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
{
Name: "1 required with uint",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {
"id": { "info": "info", "type": "uint", "name": "Name" }
},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(context.Context, struct{ Name uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
{
Name: "1 required with string",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {
"id": { "info": "info", "type": "string", "name": "Name" }
},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(context.Context, struct{ Name string }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
{
Name: "1 required with bool",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {
"id": { "info": "info", "type": "bool", "name": "Name" }
},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(context.Context, struct{ Name bool }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
}
for _, tcase := range tcases {
t.Run(tcase.Name, func(t *testing.T) {
t.Parallel()
builder := &Builder{}
if err := addBuiltinTypes(builder); err != nil {
t.Fatalf("add built-in types: %s", err)
}
err := builder.Setup(strings.NewReader(tcase.Config))
if err != nil {
t.Fatalf("setup: unexpected error <%v>", err)
}
if tcase.HandlerFn != nil {
err := builder.Bind(tcase.HandlerMethod, tcase.HandlerPath, tcase.HandlerFn)
if !errors.Is(err, tcase.BindErr) {
t.Fatalf("bind: expected <%v> got <%v>", tcase.BindErr, err)
}
}
_, err = builder.Build()
if !errors.Is(err, tcase.BuildErr) {
t.Fatalf("build: expected <%v> got <%v>", tcase.BuildErr, err)
}
})
}
}

26
datatype/builtin/any.go Normal file
View File

@ -0,0 +1,26 @@
package builtin
import (
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// AnyDataType is what its name tells
type AnyDataType struct{}
// Type returns the type of data
func (AnyDataType) Type() reflect.Type {
return reflect.TypeOf(interface{}(nil))
}
// Build returns the validator
func (AnyDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "any" {
return nil
}
return func(value interface{}) (interface{}, bool) {
return value, true
}
}

View File

@ -1,29 +1,16 @@
package validator_test
package builtin_test
import (
"fmt"
"reflect"
"testing"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestAny_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.AnyType{}
expected = reflect.TypeOf(interface{}(nil))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestAny_AvailableTypes(t *testing.T) {
t.Parallel()
dt := validator.AnyType{}
dt := builtin.AnyDataType{}
tests := []struct {
Type string
@ -39,7 +26,7 @@ func TestAny_AvailableTypes(t *testing.T) {
}
for _, test := range tests {
validator := dt.Validator(test.Type)
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
@ -60,7 +47,7 @@ func TestAny_AlwaysTrue(t *testing.T) {
const typeName = "any"
validator := validator.AnyType{}.Validator(typeName)
validator := builtin.AnyDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()

View File

@ -1,24 +1,23 @@
package validator
package builtin
import (
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// BoolType makes the "bool" type available in the aicra configuration
// It considers valid:
// - booleans
// - strings containing "true" or "false"
// - []byte containing "true" or "false"
type BoolType struct{}
// BoolDataType is what its name tells
type BoolDataType struct{}
// GoType returns the `bool` type
func (BoolType) GoType() reflect.Type {
// Type returns the type of data
func (BoolDataType) Type() reflect.Type {
return reflect.TypeOf(true)
}
// Validator for bool values
func (BoolType) Validator(typename string, avail ...Type) ValidateFunc {
if typename != "bool" {
// Build returns the validator
func (BoolDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "bool" {
return nil
}

View File

@ -1,29 +1,16 @@
package validator_test
package builtin_test
import (
"fmt"
"reflect"
"testing"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestBool_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.BoolType{}
expected = reflect.TypeOf(true)
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestBool_AvailableTypes(t *testing.T) {
t.Parallel()
dt := validator.BoolType{}
dt := builtin.BoolDataType{}
tests := []struct {
Type string
@ -39,7 +26,7 @@ func TestBool_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Validator(test.Type)
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
@ -62,7 +49,7 @@ func TestBool_Values(t *testing.T) {
const typeName = "bool"
validator := validator.BoolType{}.Validator(typeName)
validator := builtin.BoolDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()

View File

@ -1,27 +1,24 @@
package validator
package builtin
import (
"encoding/json"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// FloatType makes the "float" (or "float64") type available in the aicra configuration
// It considers valid:
// - float64
// - int (since it does not overflow)
// - uint (since it does not overflow)
// - strings containing json-compatible floats
// - []byte containing json-compatible floats
type FloatType struct{}
// FloatDataType is what its name tells
type FloatDataType struct{}
// GoType returns the `float64` type
func (FloatType) GoType() reflect.Type {
// Type returns the type of data
func (FloatDataType) Type() reflect.Type {
return reflect.TypeOf(float64(0))
}
// Validator for float64 values
func (FloatType) Validator(typename string, avail ...Type) ValidateFunc {
if typename != "float64" && typename != "float" {
// Build returns the validator
func (FloatDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "float64" && typeName != "float" {
return nil
}
return func(value interface{}) (interface{}, bool) {

View File

@ -1,30 +1,17 @@
package validator_test
package builtin_test
import (
"fmt"
"math"
"reflect"
"testing"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestFloat64_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.FloatType{}
expected = reflect.TypeOf(float64(0.0))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestFloat64_AvailableTypes(t *testing.T) {
t.Parallel()
dt := validator.FloatType{}
dt := builtin.FloatDataType{}
tests := []struct {
Type string
@ -46,7 +33,7 @@ func TestFloat64_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Validator(test.Type)
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
@ -69,7 +56,7 @@ func TestFloat64_Values(t *testing.T) {
const typeName = "float"
validator := validator.FloatType{}.Validator(typeName)
validator := builtin.FloatDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()

View File

@ -1,29 +1,25 @@
package validator
package builtin
import (
"encoding/json"
"math"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// IntType makes the "int" type available in the aicra configuration
// It considers valid:
// - int
// - float64 (since it does not overflow)
// - uint (since it does not overflow)
// - strings containing json-compatible integers
// - []byte containing json-compatible integers
type IntType struct{}
// IntDataType is what its name tells
type IntDataType struct{}
// GoType returns the `int` type
func (IntType) GoType() reflect.Type {
// Type returns the type of data
func (IntDataType) Type() reflect.Type {
return reflect.TypeOf(int(0))
}
// Validator for int values
func (IntType) Validator(typename string, avail ...Type) ValidateFunc {
// Build returns the validator
func (IntDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typename != "int" {
if typeName != "int" {
return nil
}

View File

@ -1,30 +1,17 @@
package validator_test
package builtin_test
import (
"fmt"
"math"
"reflect"
"testing"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestInt_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.IntType{}
expected = reflect.TypeOf(int(0))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestInt_AvailableTypes(t *testing.T) {
t.Parallel()
dt := validator.IntType{}
dt := builtin.IntDataType{}
tests := []struct {
Type string
@ -40,7 +27,7 @@ func TestInt_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Validator(test.Type)
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
@ -63,7 +50,7 @@ func TestInt_Values(t *testing.T) {
const typeName = "int"
validator := validator.IntType{}.Validator(typeName)
validator := builtin.IntDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
@ -84,7 +71,7 @@ func TestInt_Values(t *testing.T) {
{uint(math.MaxInt64 + 1), false},
{float64(math.MinInt64), true},
// we cannot just subtract 1 because of how precision works
// we cannot just substract 1 because of how precision works
{float64(math.MinInt64 - 1024 - 1), false},
// WARNING : this is due to how floats are compared

View File

@ -1,37 +1,32 @@
package validator
package builtin
import (
"reflect"
"regexp"
"strconv"
"git.xdrm.io/go/aicra/datatype"
)
var (
fixedLengthRegex = regexp.MustCompile(`^string\((\d+)\)$`)
variableLengthRegex = regexp.MustCompile(`^string\((\d+), ?(\d+)\)$`)
)
var fixedLengthRegex = regexp.MustCompile(`^string\((\d+)\)$`)
var variableLengthRegex = regexp.MustCompile(`^string\((\d+), ?(\d+)\)$`)
// StringType makes the types beloz available in the aicra configuration:
// - "string" considers any string valid
// - "string(n)" considers any string with an exact size of `n` valid
// - "string(a,b)" considers any string with a size between `a` and `b` valid
// > for the last one, `a` and `b` are included in the valid sizes
type StringType struct{}
// StringDataType is what its name tells
type StringDataType struct{}
// GoType returns the `string` type
func (StringType) GoType() reflect.Type {
// Type returns the type of data
func (StringDataType) Type() reflect.Type {
return reflect.TypeOf(string(""))
}
// Validator for strings with any/fixed/bound sizes
func (s StringType) Validator(typename string, avail ...Type) ValidateFunc {
var (
simple = (typename == "string")
fixedLengthMatches = fixedLengthRegex.FindStringSubmatch(typename)
variableLengthMatches = variableLengthRegex.FindStringSubmatch(typename)
)
// Build returns the validator.
// availables type names are : `string`, `string(length)` and `string(minLength, maxLength)`.
func (s StringDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
simple := typeName == "string"
fixedLengthMatches := fixedLengthRegex.FindStringSubmatch(typeName)
variableLengthMatches := variableLengthRegex.FindStringSubmatch(typeName)
// ignore unknown typename
// nothing if type not handled
if !simple && fixedLengthMatches == nil && variableLengthMatches == nil {
return nil
}
@ -45,7 +40,7 @@ func (s StringType) Validator(typename string, avail ...Type) ValidateFunc {
if fixedLengthMatches != nil {
exLen, ok := s.getFixedLength(fixedLengthMatches)
if !ok {
return nil
mustFail = true
}
min = exLen
max = exLen
@ -54,7 +49,7 @@ func (s StringType) Validator(typename string, avail ...Type) ValidateFunc {
} else if variableLengthMatches != nil {
exMin, exMax, ok := s.getVariableLength(variableLengthMatches)
if !ok {
return nil
mustFail = true
}
min = exMin
max = exMax
@ -89,7 +84,7 @@ func (s StringType) Validator(typename string, avail ...Type) ValidateFunc {
}
// getFixedLength returns the fixed length from regex matches and a success state.
func (StringType) getFixedLength(regexMatches []string) (int, bool) {
func (StringDataType) getFixedLength(regexMatches []string) (int, bool) {
// incoherence error
if regexMatches == nil || len(regexMatches) < 2 {
return 0, false
@ -105,7 +100,7 @@ func (StringType) getFixedLength(regexMatches []string) (int, bool) {
}
// getVariableLength returns the length min and max from regex matches and a success state.
func (StringType) getVariableLength(regexMatches []string) (int, int, bool) {
func (StringDataType) getVariableLength(regexMatches []string) (int, int, bool) {
// incoherence error
if regexMatches == nil || len(regexMatches) < 3 {
return 0, 0, false

View File

@ -1,29 +1,16 @@
package validator_test
package builtin_test
import (
"fmt"
"reflect"
"testing"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestString_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.StringType{}
expected = reflect.TypeOf(string("abc"))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestString_AvailableTypes(t *testing.T) {
t.Parallel()
dt := validator.StringType{}
dt := builtin.StringDataType{}
tests := []struct {
Type string
@ -66,7 +53,7 @@ func TestString_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Validator(test.Type)
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
@ -88,7 +75,7 @@ func TestString_AnyLength(t *testing.T) {
const typeName = "string"
validator := validator.StringType{}.Validator(typeName)
validator := builtin.StringDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
@ -146,7 +133,7 @@ func TestString_FixedLength(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
validator := validator.StringType{}.Validator(test.Type)
validator := builtin.StringDataType{}.Build(test.Type)
if validator == nil {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()
@ -207,7 +194,7 @@ func TestString_VariableLength(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
validator := validator.StringType{}.Validator(test.Type)
validator := builtin.StringDataType{}.Build(test.Type)
if validator == nil {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()

View File

@ -1,28 +1,25 @@
package validator
package builtin
import (
"encoding/json"
"math"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// UintType makes the "uint" type available in the aicra configuration
// It considers valid:
// - uint
// - int (since it does not overflow)
// - float64 (since it does not overflow)
// - strings containing json-compatible integers
// - []byte containing json-compatible integers
type UintType struct{}
// UintDataType is what its name tells
type UintDataType struct{}
// GoType returns the `uint` type
func (UintType) GoType() reflect.Type {
// Type returns the type of data
func (UintDataType) Type() reflect.Type {
return reflect.TypeOf(uint(0))
}
// Validator for uint values
func (UintType) Validator(other string, avail ...Type) ValidateFunc {
if other != "uint" {
// Build returns the validator
func (UintDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "uint" {
return nil
}

View File

@ -1,30 +1,17 @@
package validator_test
package builtin_test
import (
"fmt"
"math"
"reflect"
"testing"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestUint_ReflectType(t *testing.T) {
t.Parallel()
var (
dt = validator.UintType{}
expected = reflect.TypeOf(uint(0))
)
if dt.GoType() != expected {
t.Fatalf("invalid GoType() %v ; expected %v", dt.GoType(), expected)
}
}
func TestUint_AvailableTypes(t *testing.T) {
t.Parallel()
dt := validator.UintType{}
dt := builtin.UintDataType{}
tests := []struct {
Type string
@ -40,7 +27,7 @@ func TestUint_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
validator := dt.Validator(test.Type)
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
@ -63,7 +50,7 @@ func TestUint_Values(t *testing.T) {
const typeName = "uint"
validator := validator.UintType{}.Validator(typeName)
validator := builtin.UintDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()

15
datatype/types.go Normal file
View File

@ -0,0 +1,15 @@
package datatype
import "reflect"
// Validator returns whether a given value fulfills a datatype
// and casts the value into a compatible type
type Validator func(value interface{}) (cast interface{}, valid bool)
// T builds a T from the type definition (from the configuration field "type") and returns NIL if the type
// definition does not match this T ; the registry is passed for recursive datatypes (e.g. slices, structs, etc)
// to be able to access other datatypes
type T interface {
Type() reflect.Type
Build(typeDefinition string, registry ...T) Validator
}

48
dynamic/errors.go Normal file
View File

@ -0,0 +1,48 @@
package dynamic
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// ErrHandlerNotFunc - handler is not a func
const ErrHandlerNotFunc = cerr("handler must be a func")
// ErrNoServiceForHandler - no service matching this handler
const ErrNoServiceForHandler = cerr("no service found for this handler")
// ErrMissingHandlerArgumentParam - missing params arguments for handler
const ErrMissingHandlerArgumentParam = cerr("missing handler argument : parameter struct")
// ErrMissingHandlerOutput - missing output for handler
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.Error")
// ErrMissingRequestArgument - missing request argument for handler
const ErrMissingRequestArgument = cerr("handler first argument must be of type api.Request")
// ErrMissingParamArgument - missing parameters argument for handler
const ErrMissingParamArgument = cerr("handler second argument must be a struct")
// ErrMissingParamOutput - missing output argument for handler
const ErrMissingParamOutput = cerr("handler first output must be a *struct")
// ErrMissingParamFromConfig - missing a parameter in handler struct
const ErrMissingParamFromConfig = cerr("missing a parameter from configuration")
// ErrMissingOutputFromConfig - missing a parameter in handler struct
const ErrMissingOutputFromConfig = cerr("missing a parameter from configuration")
// ErrWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct
const ErrWrongParamTypeFromConfig = cerr("invalid struct field type")
// ErrWrongOutputTypeFromConfig - a configuration output type is invalid in the handler output struct
const ErrWrongOutputTypeFromConfig = cerr("invalid struct field type")
// ErrMissingHandlerErrorOutput - missing handler output error
const ErrMissingHandlerErrorOutput = cerr("last output must be of type api.Error")

90
dynamic/handler.go Normal file
View File

@ -0,0 +1,90 @@
package dynamic
import (
"fmt"
"reflect"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
)
// Build a handler from a service configuration and a HandlerFn
//
// a HandlerFn must have as a signature : `func(api.Request, 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)
//
// Special cases:
// - it there is no input, `inputStruct` can be omitted
// - it there is no output, `outputStruct` can be omitted
func Build(fn HandlerFn, service config.Service) (*Handler, error) {
h := &Handler{
spec: makeSpec(service),
fn: fn,
}
fnv := reflect.ValueOf(fn)
if fnv.Type().Kind() != reflect.Func {
return nil, ErrHandlerNotFunc
}
if err := h.spec.checkInput(fnv); err != nil {
return nil, fmt.Errorf("input: %w", err)
}
if err := h.spec.checkOutput(fnv); err != nil {
return nil, fmt.Errorf("output: %w", err)
}
return h, nil
}
// Handle binds input @data into HandleFn and returns map output
func (h *Handler) Handle(data map[string]interface{}) (map[string]interface{}, api.Error) {
fnv := reflect.ValueOf(h.fn)
callArgs := []reflect.Value{}
// bind input data
if fnv.Type().NumIn() > 0 {
// create zero value struct
callStructPtr := reflect.New(fnv.Type().In(0))
callStruct := callStructPtr.Elem()
// set each field
for name := range h.spec.Input {
field := callStruct.FieldByName(name)
if !field.CanSet() {
continue
}
// get value from @data
value, inData := data[name]
if !inData {
continue
}
field.Set(reflect.ValueOf(value).Convert(field.Type()))
}
callArgs = append(callArgs, callStruct)
}
// call the HandlerFn
output := fnv.Call(callArgs)
// no output OR pointer to output struct is nil
outdata := make(map[string]interface{})
if len(h.spec.Output) < 1 || output[0].IsNil() {
return outdata, api.Error(output[len(output)-1].Int())
}
// extract struct from pointer
returnStruct := output[0].Elem()
for name := range h.spec.Output {
field := returnStruct.FieldByName(name)
outdata[name] = field.Interface()
}
// extract api.Error
return outdata, api.Error(output[len(output)-1].Int())
}

119
dynamic/spec.go Normal file
View File

@ -0,0 +1,119 @@
package dynamic
import (
"fmt"
"reflect"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
)
// builds a spec from the configuration service
func makeSpec(service config.Service) spec {
spec := spec{
Input: make(map[string]reflect.Type),
Output: make(map[string]reflect.Type),
}
for _, param := range service.Input {
// make a pointer if optional
if param.Optional {
spec.Input[param.Rename] = reflect.PtrTo(param.ExtractType)
continue
}
spec.Input[param.Rename] = param.ExtractType
}
for _, param := range service.Output {
spec.Output[param.Rename] = param.ExtractType
}
return spec
}
// checks for HandlerFn input arguments
func (s spec) checkInput(fnv reflect.Value) error {
fnt := fnv.Type()
// no input -> ok
if len(s.Input) == 0 {
return nil
}
if fnt.NumIn() != 1 {
return ErrMissingHandlerArgumentParam
}
// arg must be a struct
structArg := fnt.In(0)
if structArg.Kind() != reflect.Struct {
return ErrMissingParamArgument
}
// check for invlaid param
for name, ptype := range s.Input {
field, exists := structArg.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, ErrMissingParamFromConfig)
}
if !ptype.AssignableTo(field.Type) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, ErrWrongParamTypeFromConfig, field.Type, ptype)
}
}
return nil
}
// checks for HandlerFn output arguments
func (s spec) checkOutput(fnv reflect.Value) error {
fnt := fnv.Type()
if fnt.NumOut() < 1 {
return ErrMissingHandlerOutput
}
// last output must be api.Error
errOutput := fnt.Out(fnt.NumOut() - 1)
if !errOutput.AssignableTo(reflect.TypeOf(api.ErrorUnknown)) {
return ErrMissingHandlerErrorOutput
}
// no output -> ok
if len(s.Output) == 0 {
return nil
}
if fnt.NumOut() != 2 {
return ErrMissingParamOutput
}
// fail if first output is not a pointer to struct
structOutputPtr := fnt.Out(0)
if structOutputPtr.Kind() != reflect.Ptr {
return ErrMissingParamOutput
}
structOutput := structOutputPtr.Elem()
if structOutput.Kind() != reflect.Struct {
return ErrMissingParamOutput
}
// fail on invalid output
for name, ptype := range s.Output {
field, exists := structOutput.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, ErrMissingOutputFromConfig)
}
// ignore types evalutating to nil
if ptype == nil {
continue
}
if !ptype.ConvertibleTo(field.Type) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, ErrWrongOutputTypeFromConfig, field.Type, ptype)
}
}
return nil
}

17
dynamic/types.go Normal file
View File

@ -0,0 +1,17 @@
package dynamic
import "reflect"
// HandlerFn defines a dynamic handler function
type HandlerFn interface{}
// Handler represents a dynamic api handler
type Handler struct {
spec spec
fn HandlerFn
}
type spec struct {
Input map[string]reflect.Type
Output map[string]reflect.Type
}

View File

@ -3,21 +3,13 @@ package aicra
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// errLateType - cannot add datatype after setting up the definition
const errLateType = cerr("types cannot be added after Setup")
// ErrNoServiceForHandler - no service matching this handler
const ErrNoServiceForHandler = cerr("no service found for this handler")
// errNotSetup - not set up yet
const errNotSetup = cerr("not set up")
// errAlreadySetup - already set up
const errAlreadySetup = cerr("already set up")
// errUnknownService - no service matching this handler
const errUnknownService = cerr("unknown service")
// errMissingHandler - missing handler
const errMissingHandler = cerr("missing handler")
// ErrNoHandlerForService - no handler matching this service
const ErrNoHandlerForService = cerr("no handler found for this service")

4
go.mod
View File

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

View File

@ -1,174 +1,32 @@
package aicra
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/config"
"github.com/xdrm-io/aicra/internal/ctx"
"github.com/xdrm-io/aicra/internal/reqdata"
"git.xdrm.io/go/aicra/dynamic"
"git.xdrm.io/go/aicra/internal/config"
)
// Handler wraps the builder to handle requests
type Handler Builder
// ServeHTTP implements http.Handler and wraps it in middlewares (adapters)
func (s Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var h http.Handler = http.HandlerFunc(s.resolve)
for _, mw := range s.middlewares {
h = mw(h)
}
h.ServeHTTP(w, r)
type handler struct {
Method string
Path string
dynHandler *dynamic.Handler
}
// ServeHTTP implements http.Handler and wraps it in middlewares (adapters)
func (s Handler) resolve(w http.ResponseWriter, r *http.Request) {
// match service from config
var service = s.conf.Find(r)
if service == nil {
newResponse().WithError(api.ErrUnknownService).ServeHTTP(w, r)
return
}
// createHandler builds a handler from its http method and path
// also it checks whether the function signature is valid
func createHandler(method, path string, service config.Service, fn dynamic.HandlerFn) (*handler, error) {
method = strings.ToUpper(method)
// extract request data
var input, err = extractInput(service, *r)
dynHandler, err := dynamic.Build(fn, service)
if err != nil {
if errors.Is(err, reqdata.ErrInvalidType) {
newResponse().WithError(api.ErrInvalidParam).ServeHTTP(w, r)
} else {
newResponse().WithError(api.ErrMissingParam).ServeHTTP(w, r)
}
return
return nil, fmt.Errorf("%s '%s' handler: %w", method, path, err)
}
// match handler
var handler *apiHandler
for _, h := range s.handlers {
if h.Method == service.Method && h.Path == service.Pattern {
handler = h
}
}
// no handler found
if handler == nil {
newResponse().WithError(api.ErrUncallableService).ServeHTTP(w, r)
return
}
// add info into context
c := r.Context()
c = context.WithValue(c, ctx.Request, r)
c = context.WithValue(c, ctx.Response, w)
c = context.WithValue(c, ctx.Auth, buildAuth(service.Scope, input.Data))
// create http handler
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// should not happen
auth := api.GetAuth(r.Context())
if auth == nil {
newResponse().WithError(api.ErrPermission).ServeHTTP(w, r)
return
}
// reject non granted requests
if !auth.Granted() {
newResponse().WithError(api.ErrPermission).ServeHTTP(w, r)
return
}
// execute the service handler
s.handle(r.Context(), input, handler, service, w, r)
})
// run contextual middlewares
for _, mw := range s.ctxMiddlewares {
h = mw(h)
}
// serve using the pre-filled context
h.ServeHTTP(w, r.WithContext(c))
}
// handle the service request with the associated handler func and respond using
// the handler func output
func (s *Handler) handle(c context.Context, input *reqdata.T, handler *apiHandler, service *config.Service, w http.ResponseWriter, r *http.Request) {
// pass execution to the handler function
var outData, outErr = handler.dyn.Handle(c, input.Data)
// build response from output arguments
var res = newResponse().WithError(outErr)
for key, value := range outData {
// find original name from 'rename' field
for name, param := range service.Output {
if param.Rename == key {
res.WithValue(name, value)
}
}
}
// write response and close request
w.Header().Set("Content-Type", "application/json; charset=utf-8")
res.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
}
// buildAuth builds the api.Auth struct from the service scope configuration
//
// it replaces format '[a]' in scope where 'a' is an existing input argument's
// name with its value
func buildAuth(scope [][]string, in map[string]interface{}) *api.Auth {
updated := make([][]string, len(scope))
// replace '[arg_name]' with the 'arg_name' value if it is a known variable
// name
for a, list := range scope {
updated[a] = make([]string, len(list))
for b, perm := range list {
updated[a][b] = perm
for name, value := range in {
var (
token = fmt.Sprintf("[%s]", name)
replacement = ""
)
if value != nil {
replacement = fmt.Sprintf("[%v]", value)
}
updated[a][b] = strings.ReplaceAll(updated[a][b], token, replacement)
}
}
}
return &api.Auth{
Required: updated,
Active: []string{},
}
return &handler{
Path: path,
Method: method,
dynHandler: dynHandler,
}, nil
}

File diff suppressed because it is too large Load Diff

116
http.go Normal file
View File

@ -0,0 +1,116 @@
package aicra
import (
"log"
"net/http"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/reqdata"
)
// httpServer wraps the aicra server to allow handling http requests
type httpServer Server
// ServeHTTP implements http.Handler and has to be called on each request
func (server httpServer) ServeHTTP(res http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
// 1. find a matching service in the config
service := server.config.Find(req)
if service == nil {
response := api.EmptyResponse().WithError(api.ErrorUnknownService)
response.ServeHTTP(res, req)
logError(response)
return
}
// 2. build input parameter receiver
dataset := reqdata.New(service)
// 3. extract URI data
err := dataset.ExtractURI(req)
if err != nil {
response := api.EmptyResponse().WithError(api.ErrorMissingParam)
response.ServeHTTP(res, req)
logError(response)
return
}
// 4. extract query data
err = dataset.ExtractQuery(req)
if err != nil {
response := api.EmptyResponse().WithError(api.ErrorMissingParam)
response.ServeHTTP(res, req)
logError(response)
return
}
// 5. extract form/json data
err = dataset.ExtractForm(req)
if err != nil {
response := api.EmptyResponse().WithError(api.ErrorMissingParam)
response.ServeHTTP(res, req)
logError(response)
return
}
// 6. find a matching handler
var foundHandler *handler
var found bool
for _, handler := range server.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
foundHandler = handler
found = true
}
}
// 7. fail if found no handler
if foundHandler == nil {
if found {
r := api.EmptyResponse().WithError(api.ErrorUncallableService)
r.ServeHTTP(res, req)
logError(r)
return
}
r := api.EmptyResponse().WithError(api.ErrorUnknownService)
r.ServeHTTP(res, req)
logError(r)
return
}
// 8. build api.Request from http.Request
apireq, err := api.NewRequest(req)
if err != nil {
log.Fatal(err)
}
// 9. feed request with scope & parameters
apireq.Scope = service.Scope
apireq.Param = dataset.Data
// 10. execute
returned, apiErr := foundHandler.dynHandler.Handle(dataset.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)
}
}
}
// 11. 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)
}
}
// 12. write to response
response.ServeHTTP(res, req)
}

View File

@ -1,182 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/xdrm-io/aicra/validator"
)
// Server definition
type Server struct {
Validators []validator.Type
Services []*Service
}
// Parse a configuration into a server. Server.Types must be set beforehand to
// make datatypes available when checking and formatting the read configuration.
func (s *Server) Parse(r io.Reader) error {
err := json.NewDecoder(r).Decode(&s.Services)
if err != nil {
return fmt.Errorf("%s: %w", ErrRead, err)
}
err = s.validate()
if err != nil {
return fmt.Errorf("%s: %w", ErrFormat, err)
}
return nil
}
// validate implements the validator interface
func (s Server) validate(datatypes ...validator.Type) error {
for _, service := range s.Services {
err := service.validate(s.Validators...)
if err != nil {
return fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, err)
}
}
if err := s.collide(); err != nil {
return fmt.Errorf("%s: %w", ErrFormat, err)
}
return nil
}
// Find a service matching an incoming HTTP request
func (s Server) Find(r *http.Request) *Service {
for _, service := range s.Services {
if matches := service.Match(r); matches {
return service
}
}
return nil
}
// collide returns if there is collision between any service for the same method and colliding paths.
// Note that service path collision detection relies on datatypes:
// - example 1: `/user/{id}` and `/user/articles` will not collide as {id} is an int and "articles" is not
// - example 2: `/user/{name}` and `/user/articles` will collide as {name} is a string so as "articles"
// - example 3: `/user/{name}` and `/user/{id}` will collide as {name} and {id} cannot be checked against their potential values
func (s *Server) collide() error {
length := len(s.Services)
// for each service combination
for a := 0; a < length; a++ {
for b := a + 1; b < length; b++ {
aService := s.Services[a]
bService := s.Services[b]
if aService.Method != bService.Method {
continue
}
aURIParts := SplitURL(aService.Pattern)
bURIParts := SplitURL(bService.Pattern)
if len(aURIParts) != len(bURIParts) {
continue
}
err := checkURICollision(aURIParts, bURIParts, aService.Input, bService.Input)
if err != nil {
return fmt.Errorf("(%s '%s') vs (%s '%s'): %w", aService.Method, aService.Pattern, bService.Method, bService.Pattern, err)
}
}
}
return nil
}
// check if uri of services A and B collide
func checkURICollision(uriA, uriB []string, inputA, inputB map[string]*Parameter) error {
var errors = []error{}
// for each part
for pi, aPart := range uriA {
bPart := uriB[pi]
// no need for further check as it has been done earlier in the validation process
aIsCapture := len(aPart) > 1 && aPart[0] == '{'
bIsCapture := len(bPart) > 1 && bPart[0] == '{'
// both captures -> as we cannot check, consider a collision
if aIsCapture && bIsCapture {
errors = append(errors, fmt.Errorf("%w (path %s and %s)", ErrPatternCollision, aPart, bPart))
continue
}
// no capture -> check strict equality
if !aIsCapture && !bIsCapture {
if aPart == bPart {
errors = append(errors, fmt.Errorf("%w (same path '%s')", ErrPatternCollision, aPart))
continue
}
}
// A captures B -> check type (B is A ?)
if aIsCapture {
input, exists := inputA[aPart]
// fail if no type or no validator
if !exists || input.Validator == nil {
errors = append(errors, fmt.Errorf("%w (invalid type for %s)", ErrPatternCollision, aPart))
continue
}
// fail if not valid
if _, valid := input.Validator(bPart); valid {
errors = append(errors, fmt.Errorf("%w (%s captures '%s')", ErrPatternCollision, aPart, bPart))
continue
}
// B captures A -> check type (A is B ?)
} else if bIsCapture {
input, exists := inputB[bPart]
// fail if no type or no validator
if !exists || input.Validator == nil {
errors = append(errors, fmt.Errorf("%w (invalid type for %s)", ErrPatternCollision, bPart))
continue
}
// fail if not valid
if _, valid := input.Validator(aPart); valid {
errors = append(errors, fmt.Errorf("%w (%s captures '%s')", ErrPatternCollision, bPart, aPart))
continue
}
}
errors = append(errors, nil)
}
// at least 1 URI part not matching -> no collision
var firstError error
for _, err := range errors {
if err != nil && firstError == nil {
firstError = err
}
if err == nil {
return nil
}
}
return firstError
}
// SplitURL without empty sets
func SplitURL(url string) []string {
trimmed := strings.Trim(url, " /\t\r\n")
split := strings.Split(trimmed, "/")
// remove empty set when empty url
if len(split) == 1 && len(split[0]) == 0 {
return []string{}
}
return split
}

View File

@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestLegalServiceName(t *testing.T) {
@ -80,8 +80,7 @@ func TestLegalServiceName(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) {
srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
_, err := Parse(strings.NewReader(test.Raw))
if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error())
@ -135,8 +134,7 @@ func TestAvailableMethods(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) {
srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
_, err := Parse(strings.NewReader(test.Raw))
if test.ValidMethod && err != nil {
t.Errorf("unexpected error: '%s'", err.Error())
@ -152,22 +150,20 @@ func TestAvailableMethods(t *testing.T) {
}
func TestParseEmpty(t *testing.T) {
t.Parallel()
r := strings.NewReader(`[]`)
srv := &Server{}
err := srv.Parse(r)
reader := strings.NewReader(`[]`)
_, err := Parse(reader)
if err != nil {
t.Errorf("unexpected error (got '%s')", err)
t.FailNow()
}
}
func TestParseJsonError(t *testing.T) {
r := strings.NewReader(`{
reader := strings.NewReader(`{
"GET": {
"info": "info
},
}`) // trailing ',' is invalid JSON
srv := &Server{}
err := srv.Parse(r)
_, err := Parse(reader)
if err == nil {
t.Errorf("expected error")
t.FailNow()
@ -184,7 +180,7 @@ func TestParseMissingMethodDescription(t *testing.T) {
`[ { "method": "GET", "path": "/" }]`,
false,
},
{ // missing descriptiontype
{ // missing description
`[ { "method": "GET", "path": "/subservice" }]`,
false,
},
@ -209,8 +205,7 @@ func TestParseMissingMethodDescription(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
_, err := Parse(strings.NewReader(test.Raw))
if test.ValidDescription && err != nil {
t.Errorf("unexpected error: '%s'", err)
@ -228,7 +223,7 @@ func TestParseMissingMethodDescription(t *testing.T) {
func TestParamEmptyRenameNoRename(t *testing.T) {
t.Parallel()
r := strings.NewReader(`[
reader := strings.NewReader(`[
{
"method": "GET",
"path": "/",
@ -238,9 +233,7 @@ func TestParamEmptyRenameNoRename(t *testing.T) {
}
}
]`)
srv := &Server{}
srv.Validators = append(srv.Validators, validator.AnyType{})
err := srv.Parse(r)
srv, err := Parse(reader, builtin.AnyDataType{})
if err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
@ -261,7 +254,7 @@ func TestParamEmptyRenameNoRename(t *testing.T) {
}
func TestOptionalParam(t *testing.T) {
t.Parallel()
r := strings.NewReader(`[
reader := strings.NewReader(`[
{
"method": "GET",
"path": "/",
@ -274,10 +267,7 @@ func TestOptionalParam(t *testing.T) {
}
}
]`)
srv := &Server{}
srv.Validators = append(srv.Validators, validator.AnyType{})
srv.Validators = append(srv.Validators, validator.BoolType{})
err := srv.Parse(r)
srv, err := Parse(reader, builtin.AnyDataType{}, builtin.BoolDataType{})
if err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
@ -587,9 +577,7 @@ func TestParseParameters(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Validators = append(srv.Validators, validator.AnyType{})
err := srv.Parse(strings.NewReader(test.Raw))
_, err := Parse(strings.NewReader(test.Raw), builtin.AnyDataType{})
if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error())
@ -826,10 +814,7 @@ func TestServiceCollision(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Validators = append(srv.Validators, validator.StringType{})
srv.Validators = append(srv.Validators, validator.UintType{})
err := srv.Parse(strings.NewReader(test.Config))
_, err := Parse(strings.NewReader(test.Config), builtin.StringDataType{}, builtin.UintDataType{})
if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error())
@ -877,36 +862,6 @@ func TestMatchSimple(t *testing.T) {
"/a",
false,
},
{ // root url
`[ {
"method": "GET",
"path": "/a",
"info": "info",
"in": {}
} ]`,
"/",
false,
},
{
`[ {
"method": "GET",
"path": "/a",
"info": "info",
"in": {}
} ]`,
"/",
false,
},
{
`[ {
"method": "GET",
"path": "/",
"info": "info",
"in": {}
} ]`,
"/",
true,
},
{
`[ {
"method": "GET",
@ -996,11 +951,7 @@ func TestMatchSimple(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Validators = append(srv.Validators, validator.AnyType{})
srv.Validators = append(srv.Validators, validator.IntType{})
srv.Validators = append(srv.Validators, validator.BoolType{})
err := srv.Parse(strings.NewReader(test.Config))
srv, err := Parse(strings.NewReader(test.Config), builtin.AnyDataType{}, builtin.IntDataType{}, builtin.BoolDataType{})
if err != nil {
t.Errorf("unexpected error: '%s'", err)
@ -1027,80 +978,3 @@ func TestMatchSimple(t *testing.T) {
}
}
func TestFindPriority(t *testing.T) {
t.Parallel()
tests := []struct {
Config string
URL string
MatchingDesc string
}{
{
`[
{ "method": "GET", "path": "/a", "info": "s1" },
{ "method": "GET", "path": "/", "info": "s2" }
]`,
"/",
"s2",
},
{
`[
{ "method": "GET", "path": "/", "info": "s2" },
{ "method": "GET", "path": "/a", "info": "s1" }
]`,
"/",
"s2",
},
{
`[
{ "method": "GET", "path": "/a", "info": "s1" },
{ "method": "GET", "path": "/", "info": "s2" }
]`,
"/a",
"s1",
},
{
`[
{ "method": "GET", "path": "/a/b/c", "info": "s1" },
{ "method": "GET", "path": "/a/b", "info": "s2" }
]`,
"/a/b/c",
"s1",
},
{
`[
{ "method": "GET", "path": "/a/b/c", "info": "s1" },
{ "method": "GET", "path": "/a/b", "info": "s2" }
]`,
"/a/b/",
"s2",
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Validators = append(srv.Validators, validator.AnyType{})
srv.Validators = append(srv.Validators, validator.IntType{})
srv.Validators = append(srv.Validators, validator.BoolType{})
err := srv.Parse(strings.NewReader(test.Config))
if err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
}
req := httptest.NewRequest(http.MethodGet, test.URL, nil)
service := srv.Find(req)
if service == nil {
t.Errorf("expected to find a service")
t.FailNow()
}
if service.Description != test.MatchingDesc {
t.Errorf("expected description '%s', got '%s'", test.MatchingDesc, service.Description)
t.FailNow()
}
})
}
}

View File

@ -1,61 +1,60 @@
package config
// Err allows you to create constant "const" error with type boxing.
type Err string
// cerr allows you to create constant "const" error with type boxing.
type cerr string
func (err Err) Error() string {
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
const (
// ErrRead - read error
ErrRead = Err("cannot read config")
// ErrRead - a problem ocurred when trying to read the configuration file
const ErrRead = cerr("cannot read config")
// ErrUnknownMethod - unknown http method
ErrUnknownMethod = Err("unknown HTTP method")
// ErrUnknownMethod - invalid http method
const ErrUnknownMethod = cerr("unknown HTTP method")
// ErrFormat - invalid format
ErrFormat = Err("invalid config format")
// ErrFormat - a invalid format has been detected
const ErrFormat = cerr("invalid config format")
// ErrPatternCollision - collision between 2 services' patterns
ErrPatternCollision = Err("pattern collision")
// ErrPatternCollision - there is a collision between 2 services' patterns (same method)
const ErrPatternCollision = cerr("pattern collision")
// ErrInvalidPattern - malformed service pattern
ErrInvalidPattern = Err("malformed service path: must begin with a '/' and not end with")
// ErrInvalidPattern - a service pattern is malformed
const ErrInvalidPattern = cerr("must begin with a '/' and not end with")
// ErrInvalidPatternBraceCapture - invalid brace capture
ErrInvalidPatternBraceCapture = Err("invalid uri parameter")
// ErrInvalidPatternBraceCapture - a service pattern brace capture is invalid
const ErrInvalidPatternBraceCapture = cerr("invalid uri capturing braces")
// ErrUnspecifiedBraceCapture - missing path brace capture
ErrUnspecifiedBraceCapture = Err("missing uri parameter")
// ErrUnspecifiedBraceCapture - a parameter brace capture is not specified in the pattern
const ErrUnspecifiedBraceCapture = cerr("capturing brace missing in the path")
// ErrUndefinedBraceCapture - missing capturing brace definition
ErrUndefinedBraceCapture = Err("missing uri parameter definition")
// ErrMandatoryRename - capture/query parameters must have a rename
const ErrMandatoryRename = cerr("capture and query parameters must have a 'name'")
// ErrMandatoryRename - capture/query parameters must be renamed
ErrMandatoryRename = Err("uri and query parameters must be renamed")
// ErrUndefinedBraceCapture - a parameter brace capture in the pattern is not defined in parameters
const ErrUndefinedBraceCapture = cerr("capturing brace missing input definition")
// ErrMissingDescription - a service is missing its description
ErrMissingDescription = Err("missing description")
const ErrMissingDescription = cerr("missing description")
// ErrIllegalOptionalURIParam - uri parameter cannot optional
ErrIllegalOptionalURIParam = Err("uri parameter cannot be optional")
// ErrIllegalOptionalURIParam - an URI parameter cannot be optional
const ErrIllegalOptionalURIParam = cerr("URI parameter cannot be optional")
// ErrOptionalOption - cannot have optional output
ErrOptionalOption = Err("output cannot be optional")
// ErrOptionalOption - an output is optional
const ErrOptionalOption = cerr("output cannot be optional")
// ErrMissingParamDesc - missing parameter description
ErrMissingParamDesc = Err("missing parameter description")
// ErrMissingParamDesc - a parameter is missing its description
const ErrMissingParamDesc = cerr("missing parameter description")
// ErrUnknownParamType - unknown parameter type
ErrUnknownParamType = Err("unknown parameter datatype")
// ErrUnknownDataType - a parameter has an unknown datatype name
const ErrUnknownDataType = cerr("unknown data type")
// ErrIllegalParamName - illegal parameter name
ErrIllegalParamName = Err("illegal parameter name")
// ErrIllegalParamName - a parameter has an illegal name
const ErrIllegalParamName = cerr("illegal parameter name")
// ErrMissingParamType - missing parameter type
ErrMissingParamType = Err("missing parameter type")
// ErrMissingParamType - a parameter has an illegal type
const ErrMissingParamType = cerr("missing parameter type")
// ErrParamNameConflict - name/rename conflict
ErrParamNameConflict = Err("parameter name conflict")
)
// ErrParamNameConflict - a parameter has a conflict with its name/rename field
const ErrParamNameConflict = cerr("name conflict for parameter")

15
internal/config/func.go Normal file
View File

@ -0,0 +1,15 @@
package config
import "strings"
// SplitURL without empty sets
func SplitURL(url string) []string {
trimmed := strings.Trim(url, " /\t\r\n")
split := strings.Split(trimmed, "/")
// remove empty set when empty url
if len(split) == 1 && len(split[0]) == 0 {
return []string{}
}
return split
}

View File

@ -1,48 +1,38 @@
package config
import (
"reflect"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype"
)
// Parameter represents a parameter definition (from api.json)
type Parameter struct {
Description string `json:"info"`
Type string `json:"type"`
Rename string `json:"name,omitempty"`
Optional bool
// GoType is the type the Validator will cast into
GoType reflect.Type
// Validator is inferred from the "type" property
Validator validator.ValidateFunc
}
func (param *Parameter) validate(datatypes ...validator.Type) error {
// Validate implements the validator interface
func (param *Parameter) Validate(datatypes ...datatype.T) error {
// missing description
if len(param.Description) < 1 {
return ErrMissingParamDesc
}
// invalid type
if len(param.Type) < 1 || param.Type == "?" {
return ErrMissingParamType
}
// optional type
// optional type transform
if param.Type[0] == '?' {
param.Optional = true
param.Type = param.Type[1:]
}
// find validator
// assign the datatype
for _, dtype := range datatypes {
param.Validator = dtype.Validator(param.Type, datatypes...)
param.GoType = dtype.GoType()
param.Validator = dtype.Build(param.Type, datatypes...)
param.ExtractType = dtype.Type()
if param.Validator != nil {
break
}
}
if param.Validator == nil {
return ErrUnknownParamType
return ErrUnknownDataType
}
return nil
}

169
internal/config/server.go Normal file
View File

@ -0,0 +1,169 @@
package config
import (
"encoding/json"
"fmt"
"io"
"net/http"
"git.xdrm.io/go/aicra/datatype"
)
// Parse builds a server configuration from a json reader and checks for most format errors.
// you can provide additional DataTypes as variadic arguments
func Parse(r io.Reader, dtypes ...datatype.T) (*Server, error) {
server := &Server{
Types: make([]datatype.T, 0),
Services: make([]*Service, 0),
}
// add data types
for _, dtype := range dtypes {
server.Types = append(server.Types, dtype)
}
if err := json.NewDecoder(r).Decode(&server.Services); err != nil {
return nil, fmt.Errorf("%s: %w", ErrRead, err)
}
if err := server.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", ErrFormat, err)
}
return server, nil
}
// Validate implements the validator interface
func (server Server) Validate(datatypes ...datatype.T) error {
for _, service := range server.Services {
err := service.Validate(server.Types...)
if err != nil {
return fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, err)
}
}
// check for collisions
if err := server.collide(); err != nil {
return fmt.Errorf("%s: %w", ErrFormat, err)
}
return nil
}
// Find a service matching an incoming HTTP request
func (server Server) Find(r *http.Request) *Service {
for _, service := range server.Services {
if matches := service.Match(r); matches {
return service
}
}
return nil
}
// collide returns if there is collision between services
func (server *Server) collide() error {
length := len(server.Services)
// for each service combination
for a := 0; a < length; a++ {
for b := a + 1; b < length; b++ {
aService := server.Services[a]
bService := server.Services[b]
// ignore different method
if aService.Method != bService.Method {
continue
}
aParts := SplitURL(aService.Pattern)
bParts := SplitURL(bService.Pattern)
// not same size
if len(aParts) != len(bParts) {
continue
}
partErrors := make([]error, 0)
// for each part
for pi, aPart := range aParts {
bPart := bParts[pi]
aIsCapture := len(aPart) > 1 && aPart[0] == '{'
bIsCapture := len(bPart) > 1 && bPart[0] == '{'
// both captures -> as we cannot check, consider a collision
if aIsCapture && bIsCapture {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (path %s and %s)", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, aPart, bPart))
continue
}
// no capture -> check equal
if !aIsCapture && !bIsCapture {
if aPart == bPart {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (same path '%s')", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, aPart))
continue
}
}
// A captures B -> check type (B is A ?)
if aIsCapture {
input, exists := aService.Input[aPart]
// fail if no type or no validator
if !exists || input.Validator == nil {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (invalid type for %s)", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, aPart))
continue
}
// fail if not valid
if _, valid := input.Validator(bPart); valid {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (%s captures '%s')", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, aPart, bPart))
continue
}
// B captures A -> check type (A is B ?)
} else if bIsCapture {
input, exists := bService.Input[bPart]
// fail if no type or no validator
if !exists || input.Validator == nil {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (invalid type for %s)", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, bPart))
continue
}
// fail if not valid
if _, valid := input.Validator(aPart); valid {
partErrors = append(partErrors, fmt.Errorf("(%s '%s') vs (%s '%s'): %w (%s captures '%s')", aService.Method, aService.Pattern, bService.Method, bService.Pattern, ErrPatternCollision, bPart, aPart))
continue
}
}
partErrors = append(partErrors, nil)
}
// if at least 1 url part does not match -> ok
var firstError error
oneMismatch := false
for _, err := range partErrors {
if err != nil && firstError == nil {
firstError = err
}
if err == nil {
oneMismatch = true
continue
}
}
if !oneMismatch {
return firstError
}
}
}
return nil
}

View File

@ -6,71 +6,42 @@ import (
"regexp"
"strings"
"github.com/xdrm-io/aicra/validator"
"git.xdrm.io/go/aicra/datatype"
)
var (
captureRegex = regexp.MustCompile(`^{([a-z_-]+)}$`)
queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`)
availableHTTPMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
)
// Service definition
type Service struct {
Method string `json:"method"`
Pattern string `json:"path"`
Scope [][]string `json:"scope"`
Description string `json:"info"`
Input map[string]*Parameter `json:"in"`
Output map[string]*Parameter `json:"out"`
// Captures contains references to URI parameters from the `Input` map. The format
// of these parameter names is "{paramName}"
Captures []*BraceCapture
// Query contains references to HTTP Query parameters from the `Input` map.
// Query parameters names are "GET@paramName", this map contains escaped names (e.g. "paramName")
Query map[string]*Parameter
// Form references form parameters from the `Input` map (all but Captures and Query).
Form map[string]*Parameter
}
// BraceCapture links to the related URI parameter
type BraceCapture struct {
Name string
Index int
Ref *Parameter
}
var braceRegex = regexp.MustCompile(`^{([a-z_-]+)}$`)
var queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`)
// Match returns if this service would handle this HTTP request
func (svc *Service) Match(req *http.Request) bool {
var (
uri = req.RequestURI
queryIndex = strings.IndexByte(uri, '?')
)
// remove query part for matching the pattern
if queryIndex > -1 {
uri = uri[:queryIndex]
// method
if req.Method != svc.Method {
return false
}
return req.Method == svc.Method && svc.matchPattern(uri)
// check path
if !svc.matchPattern(req.RequestURI) {
return false
}
// check and extract input
// todo: check if input match and extract models
return true
}
// checks if an uri matches the service's pattern
func (svc *Service) matchPattern(uri string) bool {
var (
uriparts = SplitURL(uri)
parts = SplitURL(svc.Pattern)
)
uriparts := SplitURL(uri)
parts := SplitURL(svc.Pattern)
// fail if size differ
if len(uriparts) != len(parts) {
return false
}
// root url '/'
if len(parts) == 0 && len(uriparts) == 0 {
if len(parts) == 0 {
return true
}
@ -105,35 +76,40 @@ func (svc *Service) matchPattern(uri string) bool {
}
// Validate implements the validator interface
func (svc *Service) validate(datatypes ...validator.Type) error {
err := svc.checkMethod()
func (svc *Service) Validate(datatypes ...datatype.T) error {
// check method
err := svc.isMethodAvailable()
if err != nil {
return fmt.Errorf("field 'method': %w", err)
}
// check pattern
svc.Pattern = strings.Trim(svc.Pattern, " \t\r\n")
err = svc.checkPattern()
err = svc.isPatternValid()
if err != nil {
return fmt.Errorf("field 'path': %w", err)
}
// check description
if len(strings.Trim(svc.Description, " \t\r\n")) < 1 {
return fmt.Errorf("field 'description': %w", ErrMissingDescription)
}
err = svc.checkInput(datatypes)
// check input parameters
err = svc.validateInput(datatypes)
if err != nil {
return fmt.Errorf("field 'in': %w", err)
}
// fail when a brace capture remains undefined
// fail if a brace capture remains undefined
for _, capture := range svc.Captures {
if capture.Ref == nil {
return fmt.Errorf("field 'in': %s: %w", capture.Name, ErrUndefinedBraceCapture)
}
}
err = svc.checkOutput(datatypes)
// check output
err = svc.validateOutput(datatypes)
if err != nil {
return fmt.Errorf("field 'out': %w", err)
}
@ -141,7 +117,7 @@ func (svc *Service) validate(datatypes ...validator.Type) error {
return nil
}
func (svc *Service) checkMethod() error {
func (svc *Service) isMethodAvailable() error {
for _, available := range availableHTTPMethods {
if svc.Method == available {
return nil
@ -150,14 +126,7 @@ func (svc *Service) checkMethod() error {
return ErrUnknownMethod
}
// checkPattern checks for the validity of the pattern definition (i.e. the uri)
//
// Note that the uri can contain capture params e.g. `/a/{b}/c/{d}`, in this
// example, input parameters with names `{b}` and `{d}` are expected.
//
// This methods sets up the service state with adding capture params that are
// expected; checkInputs() will be able to check params agains pattern captures.
func (svc *Service) checkPattern() error {
func (svc *Service) isPatternValid() error {
length := len(svc.Pattern)
// empty pattern
@ -180,7 +149,7 @@ func (svc *Service) checkPattern() error {
}
// if brace capture
if matches := captureRegex.FindAllStringSubmatch(part, -1); len(matches) > 0 && len(matches[0]) > 1 {
if matches := braceRegex.FindAllStringSubmatch(part, -1); len(matches) > 0 && len(matches[0]) > 1 {
braceName := matches[0][1]
// append
@ -199,183 +168,147 @@ func (svc *Service) checkPattern() error {
if strings.ContainsAny(part, "{}") {
return ErrInvalidPatternBraceCapture
}
}
return nil
}
func (svc *Service) checkInput(types []validator.Type) error {
// no parameter
func (svc *Service) validateInput(types []datatype.T) error {
// ignore no parameter
if svc.Input == nil || len(svc.Input) < 1 {
svc.Input = map[string]*Parameter{}
svc.Input = make(map[string]*Parameter, 0)
return nil
}
// for each parameter
for name, p := range svc.Input {
if len(name) < 1 {
return fmt.Errorf("%s: %w", name, ErrIllegalParamName)
for paramName, param := range svc.Input {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
}
// parse parameters: capture (uri), query or form and update the service
// attributes accordingly
ptype, err := svc.parseParam(name, p)
if err != nil {
return err
}
// Rename mandatory for capture and query
if len(p.Rename) < 1 && (ptype == captureParam || ptype == queryParam) {
return fmt.Errorf("%s: %w", name, ErrMandatoryRename)
}
// fallback to name when Rename is not provided
if len(p.Rename) < 1 {
p.Rename = name
}
err = p.validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
// capture parameter cannot be optional
if p.Optional && ptype == captureParam {
return fmt.Errorf("%s: %w", name, ErrIllegalOptionalURIParam)
}
err = nameConflicts(name, p, svc.Input)
if err != nil {
return err
}
}
return nil
}
func (svc *Service) checkOutput(types []validator.Type) error {
// no parameter
if svc.Output == nil || len(svc.Output) < 1 {
svc.Output = make(map[string]*Parameter, 0)
return nil
}
for name, p := range svc.Output {
if len(name) < 1 {
return fmt.Errorf("%s: %w", name, ErrIllegalParamName)
}
// fallback to name when Rename is not provided
if len(p.Rename) < 1 {
p.Rename = name
}
err := p.validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
if p.Optional {
return fmt.Errorf("%s: %w", name, ErrOptionalOption)
}
err = nameConflicts(name, p, svc.Output)
if err != nil {
return err
}
}
return nil
}
type paramType int
const (
captureParam paramType = iota
queryParam
formParam
)
// parseParam determines which param type it is from its name:
// - `{paramName}` is an capture; it captures a segment of the uri defined in
// the pattern definition, e.g. `/some/path/with/{paramName}/somewhere`
// - `GET@paramName` is an uri query that is received from the http query format
// in the uri, e.g. `http://domain.com/uri?paramName=paramValue&param2=value2`
// - any other name that contains valid characters is considered a Form
// parameter; it is extracted from the http request's body as: json, multipart
// or using the x-www-form-urlencoded format.
//
// Special notes:
// - capture params MUST be found in the pattern definition.
// - capture params MUST NOT be optional as they are in the pattern anyways.
// - capture and query params MUST be renamed because the `{param}` or
// `GET@param` name formats cannot be translated to a valid go exported name.
// c.f. the `dynfunc` package that creates a handler func() signature from
// the service definitions (i.e. input and output parameters).
func (svc *Service) parseParam(name string, p *Parameter) (paramType, error) {
var (
captureMatches = captureRegex.FindAllStringSubmatch(name, -1)
isCapture = len(captureMatches) > 0 && len(captureMatches[0]) > 1
)
// Parameter is a capture (uri/{param})
if isCapture {
captureName := captureMatches[0][1]
// fail if brace capture does not exists in pattern
var iscapture, isquery bool
if matches := braceRegex.FindAllStringSubmatch(paramName, -1); len(matches) > 0 && len(matches[0]) > 1 {
braceName := matches[0][1]
found := false
for _, capture := range svc.Captures {
if capture.Name == captureName {
capture.Ref = p
if capture.Name == braceName {
capture.Ref = param
found = true
break
}
}
if !found {
return captureParam, fmt.Errorf("%s: %w", name, ErrUnspecifiedBraceCapture)
}
return captureParam, nil
return fmt.Errorf("%s: %w", paramName, ErrUnspecifiedBraceCapture)
}
iscapture = true
var (
queryMatches = queryRegex.FindAllStringSubmatch(name, -1)
isQuery = len(queryMatches) > 0 && len(queryMatches[0]) > 1
)
} else if matches := queryRegex.FindAllStringSubmatch(paramName, -1); len(matches) > 0 && len(matches[0]) > 1 {
// Parameter is a query (uri?param)
if isQuery {
queryName := queryMatches[0][1]
queryName := matches[0][1]
// init map
if svc.Query == nil {
svc.Query = make(map[string]*Parameter)
}
svc.Query[queryName] = p
return queryParam, nil
}
// Parameter is a form param
svc.Query[queryName] = param
isquery = true
} else {
if svc.Form == nil {
svc.Form = make(map[string]*Parameter)
}
svc.Form[name] = p
return formParam, nil
svc.Form[paramName] = param
}
// nameConflicts returns whether ar given parameter has its name or Rename field
// in conflict with an existing parameter
func nameConflicts(name string, param *Parameter, others map[string]*Parameter) error {
for otherName, other := range others {
// fail if capture or query without rename
if len(param.Rename) < 1 && (iscapture || isquery) {
return fmt.Errorf("%s: %w", paramName, ErrMandatoryRename)
}
// use param name if no rename
if len(param.Rename) < 1 {
param.Rename = paramName
}
err := param.Validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
}
// capture parameter cannot be optional
if iscapture && param.Optional {
return fmt.Errorf("%s: %w", paramName, ErrIllegalOptionalURIParam)
}
// fail on name/rename conflict
for paramName2, param2 := range svc.Input {
// ignore self
if otherName == name {
if paramName == paramName2 {
continue
}
// 1. same rename field
// 2. original name matches a renamed field
// 3. renamed field matches an original name
if param.Rename == other.Rename || name == other.Rename || otherName == param.Rename {
return fmt.Errorf("%s: %w", otherName, ErrParamNameConflict)
// 3.2.1. Same rename field
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if param.Rename == param2.Rename || paramName == param2.Rename || paramName2 == param.Rename {
return fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
}
}
}
return nil
}
func (svc *Service) validateOutput(types []datatype.T) error {
// ignore no parameter
if svc.Output == nil || len(svc.Output) < 1 {
svc.Output = make(map[string]*Parameter, 0)
return nil
}
// for each parameter
for paramName, param := range svc.Output {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
}
// use param name if no rename
if len(param.Rename) < 1 {
param.Rename = paramName
}
err := param.Validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
}
if param.Optional {
return fmt.Errorf("%s: %w", paramName, ErrOptionalOption)
}
// fail on name/rename conflict
for paramName2, param2 := range svc.Output {
// ignore self
if paramName == paramName2 {
continue
}
// 3.2.1. Same rename field
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if param.Rename == param2.Rename || paramName == param2.Rename || paramName2 == param.Rename {
return fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
}
}
}
return nil
}

63
internal/config/types.go Normal file
View File

@ -0,0 +1,63 @@
package config
import (
"net/http"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
var availableHTTPMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
// validator unifies the check and format routine
type validator interface {
Validate(...datatype.T) error
}
// Server represents a full server configuration
type Server struct {
Types []datatype.T
Services []*Service
}
// Service represents a service definition (from api.json)
type Service struct {
Method string `json:"method"`
Pattern string `json:"path"`
Scope [][]string `json:"scope"`
Description string `json:"info"`
Input map[string]*Parameter `json:"in"`
Output map[string]*Parameter `json:"out"`
// references to url parameters
// format: '/uri/{param}'
Captures []*BraceCapture
// references to Query parameters
// format: 'GET@paranName'
Query map[string]*Parameter
// references for form parameters (all but Captures and Query)
Form map[string]*Parameter
}
// Parameter represents a parameter definition (from api.json)
type Parameter struct {
Description string `json:"info"`
Type string `json:"type"`
Rename string `json:"name,omitempty"`
// ExtractType is the type of data the datatype returns
ExtractType reflect.Type
// Optional is set to true when the type is prefixed with '?'
Optional bool
// Validator is inferred from @Type
Validator datatype.Validator
}
// BraceCapture links to the related URI parameter
type BraceCapture struct {
Name string
Index int
Ref *Parameter
}

View File

@ -1,13 +0,0 @@
package ctx
// Key defines a custom context key type
type Key int
const (
// Request is the key for the current *http.Request
Request Key = iota
// Response is the key for the associated http.ResponseWriter
Response
// Auth is the key for the request's authentication information
Auth
)

View File

@ -1,52 +0,0 @@
package dynfunc
// Err allows you to create constant "const" error with type boxing.
type Err string
func (err Err) Error() string {
return string(err)
}
const (
// ErrHandlerNotFunc - handler is not a func
ErrHandlerNotFunc = Err("handler must be a func")
// ErrNoServiceForHandler - no service matching this handler
ErrNoServiceForHandler = Err("no service found for this handler")
// errMissingHandlerArgumentParam - missing params arguments for handler
ErrMissingHandlerContextArgument = Err("missing handler first argument of type context.Context")
// ErrInvalidHandlerContextArgument - missing handler output error
ErrInvalidHandlerContextArgument = Err("first input argument should be of type context.Context")
// ErrMissingHandlerInputArgument - missing params arguments for handler
ErrMissingHandlerInputArgument = Err("missing handler argument: input struct")
// ErrUnexpectedInput - input argument is not expected
ErrUnexpectedInput = Err("unexpected input struct")
// ErrMissingHandlerOutputArgument - missing output for handler
ErrMissingHandlerOutputArgument = Err("missing handler first output argument: output struct")
// ErrMissingHandlerErrorArgument - missing error output for handler
ErrMissingHandlerErrorArgument = Err("missing handler last output argument of type api.Err")
// ErrInvalidHandlerErrorArgument - missing handler output error
ErrInvalidHandlerErrorArgument = Err("last output must be of type api.Err")
// ErrMissingParamArgument - missing parameters argument for handler
ErrMissingParamArgument = Err("handler second argument must be a struct")
// ErrUnexportedName - argument is unexported in struct
ErrUnexportedName = Err("unexported name")
// ErrWrongOutputArgumentType - wrong type for output first argument
ErrWrongOutputArgumentType = Err("handler first output argument must be a *struct")
// ErrMissingConfigArgument - missing an input/output argument in handler struct
ErrMissingConfigArgument = Err("missing an argument from the configuration")
// ErrWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct
ErrWrongParamTypeFromConfig = Err("invalid struct field type")
)

View File

@ -1,144 +0,0 @@
package dynfunc
import (
"context"
"fmt"
"log"
"reflect"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/config"
)
// Handler represents a dynamic aicra service handler
type Handler struct {
// signature defined from the service configuration
signature *Signature
// fn provided function that will be the service's handler
fn interface{}
}
// Build a handler from a dynamic function and checks its signature against a
// service configuration
//
// `fn` must have as a signature : `func(context.Context, in) (*out, api.Err)`
// - `in` is a struct{} containing a field for each service input (with valid reflect.Type)
// - `out` is a struct{} containing a field for each service output (with valid reflect.Type)
//
// Special cases:
// - it there is no input, `in` MUST be omitted
// - it there is no output, `out` CAN be omitted
func Build(fn interface{}, service config.Service) (*Handler, error) {
var (
h = &Handler{
signature: BuildSignature(service),
fn: fn,
}
fnType = reflect.TypeOf(fn)
)
if fnType.Kind() != reflect.Func {
return nil, ErrHandlerNotFunc
}
if err := h.signature.ValidateInput(fnType); err != nil {
return nil, fmt.Errorf("input: %w", err)
}
if err := h.signature.ValidateOutput(fnType); err != nil {
return nil, fmt.Errorf("output: %w", err)
}
return h, nil
}
// Handle binds input `data` into the dynamic function and returns an output map
func (h *Handler) Handle(ctx context.Context, data map[string]interface{}) (map[string]interface{}, api.Err) {
var (
ert = reflect.TypeOf(api.Err{})
fnv = reflect.ValueOf(h.fn)
callArgs = make([]reflect.Value, 0)
)
// bind context
callArgs = append(callArgs, reflect.ValueOf(ctx))
inputStructRequired := fnv.Type().NumIn() > 1
// bind input arguments
if inputStructRequired {
// create zero value struct
var (
callStructPtr = reflect.New(fnv.Type().In(1))
callStruct = callStructPtr.Elem()
)
// set each field
for name := range h.signature.Input {
field := callStruct.FieldByName(name)
if !field.CanSet() {
continue
}
// get value from @data
value, provided := data[name]
if !provided {
continue
}
var refvalue = reflect.ValueOf(value)
// T to pointer of T
if field.Kind() == reflect.Ptr {
var ptrType = field.Type().Elem()
if !refvalue.Type().ConvertibleTo(ptrType) {
log.Printf("Cannot convert %v into *%v", refvalue.Type(), ptrType)
return nil, api.ErrUncallableService
}
ptr := reflect.New(ptrType)
ptr.Elem().Set(reflect.ValueOf(value).Convert(ptrType))
field.Set(ptr)
continue
}
if !reflect.ValueOf(value).Type().ConvertibleTo(field.Type()) {
log.Printf("Cannot convert %v into %v", reflect.ValueOf(value).Type(), field.Type())
return nil, api.ErrUncallableService
}
field.Set(refvalue.Convert(field.Type()))
}
callArgs = append(callArgs, callStruct)
}
// call the handler
output := fnv.Call(callArgs)
// no output OR pointer to output struct is nil
outdata := make(map[string]interface{})
if len(h.signature.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()),
}
}
// extract struct from pointer
returnStruct := output[0].Elem()
for name := range h.signature.Output {
field := returnStruct.FieldByName(name)
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()),
}
}

View File

@ -1,167 +0,0 @@
package dynfunc
import (
"context"
"fmt"
"reflect"
"testing"
"github.com/xdrm-io/aicra/api"
)
type testsignature Signature
// builds a mock service with provided arguments as Input and matched as Output
func (s *testsignature) withArgs(dtypes ...reflect.Type) *testsignature {
if s.Input == nil {
s.Input = make(map[string]reflect.Type)
}
if s.Output == nil {
s.Output = make(map[string]reflect.Type)
}
for i, dtype := range dtypes {
name := fmt.Sprintf("P%d", i+1)
s.Input[name] = dtype
if dtype.Kind() == reflect.Ptr {
s.Output[name] = dtype.Elem()
} else {
s.Output[name] = dtype
}
}
return s
}
func TestInput(t *testing.T) {
type intstruct struct {
P1 int
}
type intptrstruct struct {
P1 *int
}
tcases := []struct {
Name string
Spec *testsignature
HasContext bool
Fn interface{}
Input []interface{}
ExpectedOutput []interface{}
ExpectedErr api.Err
}{
{
Name: "none required none provided",
Spec: (&testsignature{}).withArgs(),
Fn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
HasContext: false,
Input: []interface{}{},
ExpectedOutput: []interface{}{},
ExpectedErr: api.ErrSuccess,
},
{
Name: "int proxy (0)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(int(0))),
Fn: func(ctx context.Context, in intstruct) (*intstruct, api.Err) {
return &intstruct{P1: in.P1}, api.ErrSuccess
},
HasContext: false,
Input: []interface{}{int(0)},
ExpectedOutput: []interface{}{int(0)},
ExpectedErr: api.ErrSuccess,
},
{
Name: "int proxy (11)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(int(0))),
Fn: func(ctx context.Context, in intstruct) (*intstruct, api.Err) {
return &intstruct{P1: in.P1}, api.ErrSuccess
},
HasContext: false,
Input: []interface{}{int(11)},
ExpectedOutput: []interface{}{int(11)},
ExpectedErr: api.ErrSuccess,
},
{
Name: "*int proxy (nil)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(new(int))),
Fn: func(ctx context.Context, in intptrstruct) (*intptrstruct, api.Err) {
return &intptrstruct{P1: in.P1}, api.ErrSuccess
},
HasContext: false,
Input: []interface{}{},
ExpectedOutput: []interface{}{nil},
ExpectedErr: api.ErrSuccess,
},
{
Name: "*int proxy (28)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(new(int))),
Fn: func(ctx context.Context, in intptrstruct) (*intstruct, api.Err) {
return &intstruct{P1: *in.P1}, api.ErrSuccess
},
HasContext: false,
Input: []interface{}{28},
ExpectedOutput: []interface{}{28},
ExpectedErr: api.ErrSuccess,
},
{
Name: "*int proxy (13)",
Spec: (&testsignature{}).withArgs(reflect.TypeOf(new(int))),
Fn: func(ctx context.Context, in intptrstruct) (*intstruct, api.Err) {
return &intstruct{P1: *in.P1}, api.ErrSuccess
},
HasContext: false,
Input: []interface{}{13},
ExpectedOutput: []interface{}{13},
ExpectedErr: api.ErrSuccess,
},
}
for _, tcase := range tcases {
t.Run(tcase.Name, func(t *testing.T) {
t.Parallel()
var handler = &Handler{
signature: &Signature{Input: tcase.Spec.Input, Output: tcase.Spec.Output},
fn: tcase.Fn,
}
// build input
input := make(map[string]interface{})
for i, val := range tcase.Input {
var key = fmt.Sprintf("P%d", i+1)
input[key] = val
}
var output, err = handler.Handle(context.Background(), input)
if err != tcase.ExpectedErr {
t.Fatalf("expected api error <%v> got <%v>", tcase.ExpectedErr, err)
}
// check output
for i, expected := range tcase.ExpectedOutput {
var (
key = fmt.Sprintf("P%d", i+1)
val, exists = output[key]
)
if !exists {
t.Fatalf("missing output[%s]", key)
}
if expected != val {
var (
expectedt = reflect.ValueOf(expected)
valt = reflect.ValueOf(val)
expectedNil = !expectedt.IsValid() || expectedt.Kind() == reflect.Ptr && expectedt.IsNil()
valNil = !valt.IsValid() || valt.Kind() == reflect.Ptr && valt.IsNil()
)
// ignore both nil
if valNil && expectedNil {
continue
}
t.Fatalf("expected output[%s] to equal %T <%v> got %T <%v>", key, expected, expected, val, val)
}
}
})
}
}

View File

@ -1,159 +0,0 @@
package dynfunc
import (
"context"
"fmt"
"reflect"
"strings"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/config"
)
// Signature represents input/output arguments for service from the aicra configuration
type Signature struct {
// Input arguments of the service
Input map[string]reflect.Type
// Output arguments of the service
Output map[string]reflect.Type
}
// BuildSignature builds a signature for a service configuration
func BuildSignature(service config.Service) *Signature {
s := &Signature{
Input: make(map[string]reflect.Type),
Output: make(map[string]reflect.Type),
}
for _, param := range service.Input {
if len(param.Rename) < 1 {
continue
}
// make a pointer if optional
if param.Optional {
s.Input[param.Rename] = reflect.PtrTo(param.GoType)
continue
}
s.Input[param.Rename] = param.GoType
}
for _, param := range service.Output {
if len(param.Rename) < 1 {
continue
}
s.Output[param.Rename] = param.GoType
}
return s
}
// ValidateInput validates a handler's input arguments against the service signature
func (s *Signature) ValidateInput(handlerType reflect.Type) error {
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
// missing or invalid first arg: context.Context
if handlerType.NumIn() < 1 {
return ErrMissingHandlerContextArgument
}
firstArgType := handlerType.In(0)
if !firstArgType.Implements(ctxType) {
return ErrInvalidHandlerContextArgument
}
// no input required
if len(s.Input) == 0 {
// input struct provided
if handlerType.NumIn() > 1 {
return ErrUnexpectedInput
}
return nil
}
// too much arguments
if handlerType.NumIn() != 2 {
return ErrMissingHandlerInputArgument
}
// arg must be a struct
inStruct := handlerType.In(1)
if inStruct.Kind() != reflect.Struct {
return ErrMissingParamArgument
}
// check for invalid param
for name, ptype := range s.Input {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, ErrUnexportedName)
}
field, exists := inStruct.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, ErrMissingConfigArgument)
}
if !ptype.AssignableTo(field.Type) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, ErrWrongParamTypeFromConfig, field.Type, ptype)
}
}
return nil
}
// ValidateOutput validates a handler's output arguments against the service signature
func (s Signature) ValidateOutput(handlerType reflect.Type) error {
errType := reflect.TypeOf(api.ErrUnknown)
if handlerType.NumOut() < 1 {
return ErrMissingHandlerErrorArgument
}
// last output must be api.Err
lastArgType := handlerType.Out(handlerType.NumOut() - 1)
if !lastArgType.AssignableTo(errType) {
return ErrInvalidHandlerErrorArgument
}
// no output required -> ok
if len(s.Output) == 0 {
return nil
}
if handlerType.NumOut() < 2 {
return ErrMissingHandlerOutputArgument
}
// fail if first output is not a pointer to struct
outStructPtr := handlerType.Out(0)
if outStructPtr.Kind() != reflect.Ptr {
return ErrWrongOutputArgumentType
}
outStruct := outStructPtr.Elem()
if outStruct.Kind() != reflect.Struct {
return ErrWrongOutputArgumentType
}
// fail on invalid output
for name, ptype := range s.Output {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, ErrUnexportedName)
}
field, exists := outStruct.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, ErrMissingConfigArgument)
}
// ignore types evalutating to nil
if ptype == nil {
continue
}
if !field.Type.ConvertibleTo(ptype) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, ErrWrongParamTypeFromConfig, field.Type, ptype)
}
}
return nil
}

View File

@ -1,570 +0,0 @@
package dynfunc
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/internal/config"
)
func TestInputValidation(t *testing.T) {
tt := []struct {
name string
input map[string]reflect.Type
fn interface{}
err error
}{
{
name: "missing context",
input: map[string]reflect.Type{},
fn: func() {},
err: ErrMissingHandlerContextArgument,
},
{
name: "invalid context",
input: map[string]reflect.Type{},
fn: func(int) {},
err: ErrInvalidHandlerContextArgument,
},
{
name: "no input 0 given",
input: map[string]reflect.Type{},
fn: func(context.Context) {},
err: nil,
},
{
name: "no input 1 given",
input: map[string]reflect.Type{},
fn: func(context.Context, int) {},
err: ErrUnexpectedInput,
},
{
name: "no input 2 given",
input: map[string]reflect.Type{},
fn: func(context.Context, int, string) {},
err: ErrUnexpectedInput,
},
{
name: "1 input 0 given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func(context.Context) {},
err: ErrMissingHandlerInputArgument,
},
{
name: "1 input non-struct given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func(context.Context, int) {},
err: ErrMissingParamArgument,
},
{
name: "unexported input",
input: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
fn: func(context.Context, struct{}) {},
err: ErrUnexportedName,
},
{
name: "1 input empty struct given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func(context.Context, struct{}) {},
err: ErrMissingConfigArgument,
},
{
name: "1 input invalid given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func(context.Context, struct{ Test1 string }) {},
err: ErrWrongParamTypeFromConfig,
},
{
name: "1 input valid given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func(context.Context, struct{ Test1 int }) {},
err: nil,
},
{
name: "1 input ptr empty struct given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
fn: func(context.Context, struct{}) {},
err: ErrMissingConfigArgument,
},
{
name: "1 input ptr invalid given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
fn: func(context.Context, struct{ Test1 string }) {},
err: ErrWrongParamTypeFromConfig,
},
{
name: "1 input ptr invalid ptr type given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
fn: func(context.Context, struct{ Test1 *string }) {},
err: ErrWrongParamTypeFromConfig,
},
{
name: "1 input ptr valid given",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
fn: func(context.Context, struct{ Test1 *int }) {},
err: nil,
},
{
name: "1 valid string",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(string("")),
},
fn: func(context.Context, struct{ Test1 string }) {},
err: nil,
},
{
name: "1 valid uint",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(uint(0)),
},
fn: func(context.Context, struct{ Test1 uint }) {},
err: nil,
},
{
name: "1 valid float64",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(float64(0)),
},
fn: func(context.Context, struct{ Test1 float64 }) {},
err: nil,
},
{
name: "1 valid []byte",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf([]byte("")),
},
fn: func(context.Context, struct{ Test1 []byte }) {},
err: nil,
},
{
name: "1 valid []rune",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf([]rune("")),
},
fn: func(context.Context, struct{ Test1 []rune }) {},
err: nil,
},
{
name: "1 valid *string",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(string)),
},
fn: func(context.Context, struct{ Test1 *string }) {},
err: nil,
},
{
name: "1 valid *uint",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(uint)),
},
fn: func(context.Context, struct{ Test1 *uint }) {},
err: nil,
},
{
name: "1 valid *float64",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(float64)),
},
fn: func(context.Context, struct{ Test1 *float64 }) {},
err: nil,
},
{
name: "1 valid *[]byte",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new([]byte)),
},
fn: func(context.Context, struct{ Test1 *[]byte }) {},
err: nil,
},
{
name: "1 valid *[]rune",
input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new([]rune)),
},
fn: func(context.Context, struct{ Test1 *[]rune }) {},
err: nil,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
// mock spec
s := Signature{
Input: tc.input,
Output: nil,
}
err := s.ValidateInput(reflect.TypeOf(tc.fn))
if err == nil && tc.err != nil {
t.Fatalf("expected an error: '%s'", tc.err.Error())
}
if err != nil && tc.err == nil {
t.Fatalf("unexpected error: '%s'", err.Error())
}
if err != nil && tc.err != nil {
if !errors.Is(err, tc.err) {
t.Fatalf("expected the error <%s> got <%s>", tc.err, err)
}
}
})
}
}
func TestOutputValidation(t *testing.T) {
tt := []struct {
name string
output map[string]reflect.Type
fn interface{}
err error
}{
{
name: "no output missing err",
output: map[string]reflect.Type{},
fn: func() {},
err: ErrMissingHandlerErrorArgument,
},
{
name: "no output invalid err",
output: map[string]reflect.Type{},
fn: func() bool { return true },
err: ErrInvalidHandlerErrorArgument,
},
{
name: "1 output none required",
output: map[string]reflect.Type{},
fn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
err: nil,
},
{
name: "no output 1 required",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func() api.Err { return api.ErrSuccess },
err: ErrMissingHandlerOutputArgument,
},
{
name: "invalid int output",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func() (int, api.Err) { return 0, api.ErrSuccess },
err: ErrWrongOutputArgumentType,
},
{
name: "invalid int ptr output",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func() (*int, api.Err) { return nil, api.ErrSuccess },
err: ErrWrongOutputArgumentType,
},
{
name: "invalid struct output",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func() (struct{ Test1 int }, api.Err) { return struct{ Test1 int }{Test1: 1}, api.ErrSuccess },
err: ErrWrongOutputArgumentType,
},
{
name: "unexported param",
output: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
err: ErrUnexportedName,
},
{
name: "missing output param",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
err: ErrMissingConfigArgument,
},
{
name: "invalid output param",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func() (*struct{ Test1 string }, api.Err) { return nil, api.ErrSuccess },
err: ErrWrongParamTypeFromConfig,
},
{
name: "valid param",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
err: nil,
},
{
name: "2 valid params",
output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
"Test2": reflect.TypeOf(string("")),
},
fn: func() (*struct {
Test1 int
Test2 string
}, api.Err) {
return nil, api.ErrSuccess
},
err: nil,
},
{
name: "nil type ignore typecheck",
output: map[string]reflect.Type{
"Test1": nil,
},
fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
err: nil,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
// mock spec
s := Signature{
Input: nil,
Output: tc.output,
}
err := s.ValidateOutput(reflect.TypeOf(tc.fn))
if !errors.Is(err, tc.err) {
t.Fatalf("expected the error <%s> got <%s>", tc.err, err)
}
})
}
}
func TestServiceValidation(t *testing.T) {
tt := []struct {
name string
in []*config.Parameter
out []*config.Parameter
fn interface{}
err error
}{
{
name: "missing context",
fn: func() {},
err: ErrMissingHandlerContextArgument,
},
{
name: "invalid context",
fn: func(int) {},
err: ErrInvalidHandlerContextArgument,
},
{
name: "missing error",
fn: func(context.Context) {},
err: ErrMissingHandlerErrorArgument,
},
{
name: "invalid error",
fn: func(context.Context) int { return 1 },
err: ErrInvalidHandlerErrorArgument,
},
{
name: "no in no out",
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "unamed in",
in: []*config.Parameter{
{
Rename: "", // should be ignored
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "missing in",
in: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: ErrMissingHandlerInputArgument,
},
{
name: "valid in",
in: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context, struct{ Test1 int }) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "optional in not ptr",
in: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
Optional: true,
},
},
fn: func(context.Context, struct{ Test1 int }) api.Err { return api.ErrSuccess },
err: ErrWrongParamTypeFromConfig,
},
{
name: "valid optional in",
in: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
Optional: true,
},
},
fn: func(context.Context, struct{ Test1 *int }) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "unamed out",
out: []*config.Parameter{
{
Rename: "", // should be ignored
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: nil,
},
{
name: "missing out struct",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) api.Err { return api.ErrSuccess },
err: ErrMissingHandlerOutputArgument,
},
{
name: "invalid out struct type",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) (int, api.Err) { return 0, api.ErrSuccess },
err: ErrWrongOutputArgumentType,
},
{
name: "missing out",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) (*struct{}, api.Err) { return nil, api.ErrSuccess },
err: ErrMissingConfigArgument,
},
{
name: "valid out",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
},
},
fn: func(context.Context) (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
err: nil,
},
{
name: "optional out not ptr",
out: []*config.Parameter{
{
Rename: "Test1",
GoType: reflect.TypeOf(int(0)),
Optional: true,
},
},
fn: func(context.Context) (*struct{ Test1 *int }, api.Err) { return nil, api.ErrSuccess },
err: ErrWrongParamTypeFromConfig,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
service := config.Service{
Input: make(map[string]*config.Parameter),
Output: make(map[string]*config.Parameter),
}
// fill service with arguments
if tc.in != nil && len(tc.in) > 0 {
for i, in := range tc.in {
service.Input[fmt.Sprintf("%d", i)] = in
}
}
if tc.out != nil && len(tc.out) > 0 {
for i, out := range tc.out {
service.Output[fmt.Sprintf("%d", i)] = out
}
}
s := BuildSignature(service)
err := s.ValidateInput(reflect.TypeOf(tc.fn))
if err != nil {
if !errors.Is(err, tc.err) {
t.Fatalf("expected the error <%s> got <%s>", tc.err, err)
}
return
}
err = s.ValidateOutput(reflect.TypeOf(tc.fn))
if err != nil {
if !errors.Is(err, tc.err) {
t.Fatalf("expected the error <%s> got <%s>", tc.err, err)
}
return
}
// no error encountered but expected 1
if tc.err != nil {
t.Fatalf("expected an error <%v>", tc.err)
}
})
}
}

View File

@ -13,19 +13,19 @@ func (comp *Component) parseHeaders(_raw []byte) error {
// 1. Extract lines
_lines := strings.Split(string(_raw), "\n")
if len(_lines) < 2 {
return errNoHeader
return ErrNoHeader
}
// 2. trim each line + remove 'Content-Disposition' prefix
header := strings.Trim(_lines[0], " \t\r")
if !strings.HasPrefix(header, "Content-Disposition: form-data;") {
return errNoHeader
return ErrNoHeader
}
header = strings.Trim(header[len("Content-Disposition: form-data;"):], " \t\r")
if len(header) < 1 {
return errNoHeader
return ErrNoHeader
}
// 3. Extract each key-value pair

View File

@ -71,11 +71,11 @@ func (reader *Reader) Parse() error {
name := comp.GetHeader("name")
if len(name) < 1 {
return errMissingDataName
return ErrMissingDataName
}
if _, nameUsed := reader.Data[name]; nameUsed {
return errDataNameConflict
return ErrDataNameConflict
}
reader.Data[name] = comp

View File

@ -196,8 +196,8 @@ func TestNoName(t *testing.T) {
return
}
if err = mpr.Parse(); err != errMissingDataName {
t.Errorf("expected the error <%s>, got <%s>", errMissingDataName, err)
if err = mpr.Parse(); err != ErrMissingDataName {
t.Errorf("expected the error <%s>, got <%s>", ErrMissingDataName, err)
return
}
})
@ -238,8 +238,8 @@ func TestNoHeader(t *testing.T) {
return
}
if err = mpr.Parse(); err != errNoHeader {
t.Errorf("expected the error <%s>, got <%s>", errNoHeader, err)
if err = mpr.Parse(); err != ErrNoHeader {
t.Errorf("expected the error <%s>, got <%s>", ErrNoHeader, err)
return
}
})
@ -274,8 +274,8 @@ facebook.com
t.Fatalf("unexpected error <%s>", err)
}
if err = mpr.Parse(); err != errDataNameConflict {
t.Fatalf("expected the error <%s>, got <%s>", errDataNameConflict, err)
if err = mpr.Parse(); err != ErrDataNameConflict {
t.Fatalf("expected the error <%s>, got <%s>", ErrDataNameConflict, err)
}
}

View File

@ -3,18 +3,19 @@ package multipart
// cerr allows you to create constant "const" error with type boxing.
type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string {
return string(err)
}
// errMissingDataName is set when a multipart variable/file has no name="..."
const errMissingDataName = cerr("data has no name")
// ErrMissingDataName is set when a multipart variable/file has no name="..."
const ErrMissingDataName = cerr("data has no name")
// errDataNameConflict is set when a multipart variable/file name is already used
const errDataNameConflict = cerr("data name conflict")
// ErrDataNameConflict is set when a multipart variable/file name is already used
const ErrDataNameConflict = cerr("data name conflict")
// errNoHeader is set when a multipart variable/file has no (valid) header
const errNoHeader = cerr("data has no header")
// ErrNoHeader is set when a multipart variable/file has no (valid) header
const ErrNoHeader = cerr("data has no header")
// Component represents a multipart variable/file
type Component struct {

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)

View File

@ -6,36 +6,40 @@ import (
"io"
"reflect"
"github.com/xdrm-io/aicra/internal/config"
"github.com/xdrm-io/aicra/internal/multipart"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/multipart"
"net/http"
"strings"
)
// T represents all data that can be caught from an http request for a specific
// configuration Service; it features:
// Set represents all data that can be caught:
// - URI (from the URI)
// - GET (standard url data)
// - GET (default url data)
// - POST (from json, form-data, url-encoded)
// - 'application/json' => key-value pair is parsed as json into the map
// - 'application/x-www-form-urlencoded' => standard parameters as QUERY parameters
// - 'multipart/form-data' => parse form-data format
type T struct {
type Set struct {
service *config.Service
// contains URL+GET+FORM data with prefixes:
// - FORM: no prefix
// - URL: '{uri_var}'
// - GET: 'GET@' followed by the key in GET
Data map[string]interface{}
}
// New creates a new empty store.
func New(service *config.Service) *T {
return &T{
func New(service *config.Service) *Set {
return &Set{
service: service,
Data: map[string]interface{}{},
Data: make(map[string]interface{}),
}
}
// GetURI parameters
func (i *T) GetURI(req http.Request) error {
// ExtractURI fills 'Set' with creating pointers inside 'Url'
func (i *Set) ExtractURI(req *http.Request) error {
uriparts := config.SplitURL(req.URL.RequestURI())
for _, capture := range i.service.Captures {
@ -50,115 +54,122 @@ func (i *T) GetURI(req http.Request) error {
return fmt.Errorf("%s: %w", capture.Name, ErrUnknownType)
}
// parse parameter
parsed := parseParameter(value)
// check type
cast, valid := capture.Ref.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", capture.Name, ErrInvalidType)
}
// store cast value in 'Set'
i.Data[capture.Ref.Rename] = cast
}
return nil
}
// GetQuery data from the url query parameters
func (i *T) GetQuery(req http.Request) error {
// ExtractQuery data from the url query parameters
func (i *Set) ExtractQuery(req *http.Request) error {
query := req.URL.Query()
for name, param := range i.service.Query {
values, exist := query[name]
value, exist := query[name]
if !exist {
if !param.Optional {
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
var parsed interface{}
// consider element instead of slice or elements when only 1
if len(values) == 1 {
parsed = parseParameter(values[0])
} else { // consider slice
parsed = parseParameter(values)
}
// parse parameter
parsed := parseParameter(value)
// check type
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
return nil
}
// GetForm parameters the from request
// ExtractForm data from request
//
// - parse 'form-data' if not supported for non-POST requests
// - parse 'x-www-form-urlencoded'
// - parse 'application/json'
func (i *T) GetForm(req http.Request) error {
func (i *Set) ExtractForm(req *http.Request) error {
// ignore GET method
if req.Method == http.MethodGet {
return nil
}
ct := req.Header.Get("Content-Type")
switch {
case strings.HasPrefix(ct, "application/json"):
err := i.parseJSON(req)
if err != nil {
return err
contentType := req.Header.Get("Content-Type")
// parse json
if strings.HasPrefix(contentType, "application/json") {
return i.parseJSON(req)
}
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"):
err := i.parseUrlencoded(req)
if err != nil {
return err
// parse urlencoded
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
return i.parseUrlencoded(req)
}
case strings.HasPrefix(ct, "multipart/form-data; boundary="):
err := i.parseMultipart(req)
if err != nil {
return err
}
// parse multipart
if strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
return i.parseMultipart(req)
}
// fail on at least 1 mandatory form param when there is no body
for name, param := range i.service.Form {
_, exists := i.Data[param.Rename]
if !exists && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
}
// nothing to parse
return nil
}
// parseJSON parses JSON from the request body inside 'Form'
// and 'Set'
func (i *T) parseJSON(req http.Request) error {
var parsed map[string]interface{}
func (i *Set) parseJSON(req *http.Request) error {
parsed := make(map[string]interface{}, 0)
decoder := json.NewDecoder(req.Body)
err := decoder.Decode(&parsed)
if err := decoder.Decode(&parsed); err != nil {
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("%s: %w", err, ErrInvalidJSON)
}
for name, param := range i.service.Form {
value, exist := parsed[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// fail on invalid type
cast, valid := param.Validator(value)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
@ -167,31 +178,35 @@ func (i *T) parseJSON(req http.Request) error {
// parseUrlencoded parses urlencoded from the request body inside 'Form'
// and 'Set'
func (i *T) parseUrlencoded(req http.Request) error {
func (i *Set) parseUrlencoded(req *http.Request) error {
// use http.Request interface
if err := req.ParseForm(); err != nil {
return err
}
for name, param := range i.service.Form {
values, exist := req.PostForm[name]
value, exist := req.PostForm[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
var parsed interface{}
// consider element instead of slice or elements when only 1
if len(values) == 1 {
parsed = parseParameter(values[0])
} else { // consider slice
parsed = parseParameter(values)
}
// parse parameter
parsed := parseParameter(value)
// check type
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
@ -200,33 +215,46 @@ func (i *T) parseUrlencoded(req http.Request) error {
// parseMultipart parses multi-part from the request body inside 'Form'
// and 'Set'
func (i *T) parseMultipart(req http.Request) error {
func (i *Set) parseMultipart(req *http.Request) error {
// 1. create reader
boundary := req.Header.Get("Content-Type")[len("multipart/form-data; boundary="):]
mpr, err := multipart.NewReader(req.Body, boundary)
if err != nil {
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("%s: %w", err, ErrInvalidMultipart)
return err
}
err = mpr.Parse()
if err != nil {
// 2. parse multipart
if err = mpr.Parse(); err != nil {
return fmt.Errorf("%s: %w", err, ErrInvalidMultipart)
}
for name, param := range i.service.Form {
component, exist := mpr.Data[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// parse parameter
parsed := parseParameter(string(component.Data))
// fail on invalid type
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
@ -238,47 +266,58 @@ func (i *T) parseMultipart(req http.Request) error {
// - []string : return array of json elements
// - string : return json if valid, else return raw string
func parseParameter(data interface{}) interface{} {
rt := reflect.TypeOf(data)
rv := reflect.ValueOf(data)
dtype := reflect.TypeOf(data)
dvalue := reflect.ValueOf(data)
switch rt.Kind() {
switch dtype.Kind() {
// []string -> recursive
/* (1) []string -> recursive */
case reflect.Slice:
if rv.Len() == 0 {
// 1. ignore empty
if dvalue.Len() == 0 {
return data
}
slice := make([]interface{}, rv.Len())
for i, l := 0, rv.Len(); i < l; i++ {
element := rv.Index(i)
slice[i] = parseParameter(element.Interface())
}
return slice
// 2. parse each element recursively
result := make([]interface{}, dvalue.Len())
// string -> parse as json
// keep as string if invalid json
for i, l := 0, dvalue.Len(); i < l; i++ {
element := dvalue.Index(i)
result[i] = parseParameter(element.Interface())
}
return result
/* (2) string -> parse */
case reflect.String:
var cast interface{}
wrapper := fmt.Sprintf("{\"wrapped\":%s}", rv.String())
err := json.Unmarshal([]byte(wrapper), &cast)
// build json wrapper
wrapper := fmt.Sprintf("{\"wrapped\":%s}", dvalue.String())
// try to parse as json
var result interface{}
err := json.Unmarshal([]byte(wrapper), &result)
// return if success
if err != nil {
return rv.String()
return dvalue.String()
}
mapval, ok := cast.(map[string]interface{})
mapval, ok := result.(map[string]interface{})
if !ok {
return rv.String()
return dvalue.String()
}
wrapped, ok := mapval["wrapped"]
if !ok {
return rv.String()
return dvalue.String()
}
return wrapped
// any type -> unchanged
default:
return rv.Interface()
}
/* (3) NIL if unknown type */
return dvalue.Interface()
}

View File

@ -10,7 +10,7 @@ import (
"strings"
"testing"
"github.com/xdrm-io/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/config"
)
func getEmptyService() *config.Service {
@ -131,15 +131,17 @@ func TestStoreWithUri(t *testing.T) {
store := New(service)
req := httptest.NewRequest(http.MethodGet, "http://host.com"+test.URI, nil)
err := store.GetURI(*req)
err := store.ExtractURI(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Fatalf("unexpected error <%s>", err)
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if len(store.Data) != len(service.Input) {
@ -181,14 +183,14 @@ func TestExtractQuery(t *testing.T) {
Query: "a",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{{""}},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParam: []string{"a"},
Query: "a&b",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{{""}},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParam: []string{"a", "missing"},
@ -202,58 +204,61 @@ func TestExtractQuery(t *testing.T) {
Query: "a&b",
Err: nil,
ParamNames: []string{"a", "b"},
ParamValues: [][]string{{""}, {""}},
ParamValues: [][]string{[]string{""}, []string{""}},
},
{
ServiceParam: []string{"a"},
Err: nil,
Query: "a=",
ParamNames: []string{"a"},
ParamValues: [][]string{{""}},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParam: []string{"a", "b"},
Err: nil,
Query: "a=&b=x",
ParamNames: []string{"a", "b"},
ParamValues: [][]string{{""}, {"x"}},
ParamValues: [][]string{[]string{""}, []string{"x"}},
},
{
ServiceParam: []string{"a", "c"},
Err: nil,
Query: "a=b&c=d",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{{"b"}, {"d"}},
ParamValues: [][]string{[]string{"b"}, []string{"d"}},
},
{
ServiceParam: []string{"a", "c"},
Err: nil,
Query: "a=b&c=d&a=x",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{{"b", "x"}, {"d"}},
ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request[%d]", i), func(t *testing.T) {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
store := New(getServiceWithQuery(test.ServiceParam...))
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://host.com?%s", test.Query), nil)
err := store.GetQuery(*req)
err := store.ExtractQuery(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Fatalf("unexpected error <%s>", err)
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Fatalf("expected no GET parameters and got %d", len(store.Data))
t.Errorf("expected no GET parameters and got %d", len(store.Data))
t.FailNow()
}
// no param to check
@ -261,7 +266,8 @@ func TestExtractQuery(t *testing.T) {
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Fatalf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
}
for pi, pName := range test.ParamNames {
@ -270,35 +276,29 @@ func TestExtractQuery(t *testing.T) {
t.Run(pName, func(t *testing.T) {
param, isset := store.Data[pName]
if !isset {
t.Fatalf("param does not exist")
t.Errorf("param does not exist")
t.FailNow()
}
// single value, should return a single element
if len(values) == 1 {
cast, canCast := param.(string)
if !canCast {
t.Fatalf("should return a string (got '%v')", cast)
}
if values[0] != cast {
t.Fatalf("should return '%s' (got '%s')", values[0], cast)
}
return
}
// multiple values, should return a slice
cast, canCast := param.([]interface{})
if !canCast {
t.Fatalf("should return a []string (got '%v')", cast)
t.Errorf("should return a []string (got '%v')", cast)
t.FailNow()
}
if len(cast) != len(values) {
t.Fatalf("should return %d string(s) (got '%d')", len(values), len(cast))
t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast))
t.FailNow()
}
for vi, value := range values {
t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) {
if value != cast[vi] {
t.Fatalf("should return '%s' (got '%s')", value, cast[vi])
t.Errorf("should return '%s' (got '%s')", value, cast[vi])
t.FailNow()
}
})
}
})
@ -324,9 +324,11 @@ func TestStoreWithUrlEncodedFormParseError(t *testing.T) {
// defer req.Body.Close()
store := New(nil)
err := store.GetForm(*req)
err := store.ExtractForm(req)
if err == nil {
t.Fatalf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data))
t.Errorf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data))
t.FailNow()
}
}
func TestExtractFormUrlEncoded(t *testing.T) {
@ -357,14 +359,14 @@ func TestExtractFormUrlEncoded(t *testing.T) {
URLEncoded: "a",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{{""}},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParams: []string{"a"},
URLEncoded: "a&b",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{{""}},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParams: []string{"a", "missing"},
@ -378,35 +380,35 @@ func TestExtractFormUrlEncoded(t *testing.T) {
URLEncoded: "a&b",
Err: nil,
ParamNames: []string{"a", "b"},
ParamValues: [][]string{{""}, {""}},
ParamValues: [][]string{[]string{""}, []string{""}},
},
{
ServiceParams: []string{"a"},
Err: nil,
URLEncoded: "a=",
ParamNames: []string{"a"},
ParamValues: [][]string{{""}},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParams: []string{"a", "b"},
Err: nil,
URLEncoded: "a=&b=x",
ParamNames: []string{"a", "b"},
ParamValues: [][]string{{""}, {"x"}},
ParamValues: [][]string{[]string{""}, []string{"x"}},
},
{
ServiceParams: []string{"a", "c"},
Err: nil,
URLEncoded: "a=b&c=d",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{{"b"}, {"d"}},
ParamValues: [][]string{[]string{"b"}, []string{"d"}},
},
{
ServiceParams: []string{"a", "c"},
Err: nil,
URLEncoded: "a=b&c=d&a=x",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{{"b", "x"}, {"d"}},
ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}},
},
}
@ -418,20 +420,23 @@ func TestExtractFormUrlEncoded(t *testing.T) {
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.GetForm(*req)
err := store.ExtractForm(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Fatalf("unexpected error <%s>", err)
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Fatalf("expected no GET parameters and got %d", len(store.Data))
t.Errorf("expected no GET parameters and got %d", len(store.Data))
t.FailNow()
}
// no param to check
@ -439,7 +444,8 @@ func TestExtractFormUrlEncoded(t *testing.T) {
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Fatalf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
}
for pi, key := range test.ParamNames {
@ -448,35 +454,29 @@ func TestExtractFormUrlEncoded(t *testing.T) {
t.Run(key, func(t *testing.T) {
param, isset := store.Data[key]
if !isset {
t.Fatalf("param does not exist")
t.Errorf("param does not exist")
t.FailNow()
}
// single value, should return a single element
if len(values) == 1 {
cast, canCast := param.(string)
if !canCast {
t.Fatalf("should return a string (got '%v')", cast)
}
if values[0] != cast {
t.Fatalf("should return '%s' (got '%s')", values[0], cast)
}
return
}
// multiple values, should return a slice
cast, canCast := param.([]interface{})
if !canCast {
t.Fatalf("should return a []string (got '%v')", cast)
t.Errorf("should return a []interface{} (got '%v')", cast)
t.FailNow()
}
if len(cast) != len(values) {
t.Fatalf("should return %d string(s) (got '%d')", len(values), len(cast))
t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast))
t.FailNow()
}
for vi, value := range values {
t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) {
if value != cast[vi] {
t.Fatalf("should return '%s' (got '%s')", value, cast[vi])
t.Errorf("should return '%s' (got '%s')", value, cast[vi])
t.FailNow()
}
})
}
})
@ -563,20 +563,23 @@ func TestJsonParameters(t *testing.T) {
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.GetForm(*req)
err := store.ExtractForm(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Fatalf("unexpected error <%s>", err)
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Fatalf("expected no JSON parameters and got %d", len(store.Data))
t.Errorf("expected no JSON parameters and got %d", len(store.Data))
t.FailNow()
}
// no param to check
@ -584,7 +587,8 @@ func TestJsonParameters(t *testing.T) {
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Fatalf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
}
for pi, pName := range test.ParamNames {
@ -595,7 +599,8 @@ func TestJsonParameters(t *testing.T) {
param, isset := store.Data[key]
if !isset {
t.Fatalf("store should contain element with key '%s'", key)
t.Errorf("store should contain element with key '%s'", key)
t.FailNow()
return
}
@ -605,11 +610,13 @@ func TestJsonParameters(t *testing.T) {
paramValueType := reflect.TypeOf(param)
if valueType != paramValueType {
t.Fatalf("should be of type %v (got '%v')", valueType, paramValueType)
t.Errorf("should be of type %v (got '%v')", valueType, paramValueType)
t.FailNow()
}
if paramValue != value {
t.Fatalf("should return %v (got '%v')", value, paramValue)
t.Errorf("should return %v (got '%v')", value, paramValue)
t.FailNow()
}
})
@ -713,20 +720,23 @@ x
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.GetForm(*req)
err := store.ExtractForm(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Fatalf("expected error <%s>, got <%s>", test.Err, err)
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Fatalf("unexpected error <%s>", err)
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Fatalf("expected no JSON parameters and got %d", len(store.Data))
t.Errorf("expected no JSON parameters and got %d", len(store.Data))
t.FailNow()
}
// no param to check
@ -734,7 +744,8 @@ x
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Fatalf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
}
for pi, key := range test.ParamNames {
@ -744,7 +755,8 @@ x
param, isset := store.Data[key]
if !isset {
t.Fatalf("store should contain element with key '%s'", key)
t.Errorf("store should contain element with key '%s'", key)
t.FailNow()
return
}
@ -754,11 +766,13 @@ x
paramValueType := reflect.TypeOf(param)
if valueType != paramValueType {
t.Fatalf("should be of type %v (got '%v')", valueType, paramValueType)
t.Errorf("should be of type %v (got '%v')", valueType, paramValueType)
t.FailNow()
}
if paramValue != value {
t.Fatalf("should return %v (got '%v')", value, paramValue)
t.Errorf("should return %v (got '%v')", value, paramValue)
t.FailNow()
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@ -1,60 +0,0 @@
package aicra
import (
"encoding/json"
"net/http"
"github.com/xdrm-io/aicra/api"
)
// response for an service call
type response struct {
Data map[string]interface{}
Status int
err api.Err
}
// newResponse creates an empty response.
func newResponse() *response {
return &response{
Status: http.StatusOK,
Data: make(map[string]interface{}),
err: api.ErrFailure,
}
}
// WithError sets the response error
func (r *response) WithError(err api.Err) *response {
r.err = err
return r
}
// WithValue sets a response value
func (r *response) WithValue(name string, value interface{}) *response {
r.Data[name] = value
return r
}
// MarshalJSON generates the JSON representation of the response
//
// implements json.Marshaler
func (r *response) MarshalJSON() ([]byte, error) {
fmt := make(map[string]interface{})
for k, v := range r.Data {
fmt[k] = v
}
fmt["error"] = r.err
return json.Marshal(fmt)
}
// ServeHTTP writes the response representation back to the http.ResponseWriter
//
// implements http.Handler
func (res *response) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(res.err.Status)
encoded, err := json.Marshal(res)
if err == nil {
w.Write(encoded)
}
return err
}

View File

@ -1,95 +0,0 @@
package aicra
import (
"encoding/json"
"strings"
"testing"
"github.com/xdrm-io/aicra/api"
)
func printEscaped(raw string) string {
raw = strings.ReplaceAll(raw, "\n", "\\n")
raw = strings.ReplaceAll(raw, "\r", "\\r")
return raw
}
func TestResponseJSON(t *testing.T) {
t.Parallel()
tt := []struct {
name string
err api.Err
data map[string]interface{}
json string
}{
{
name: "empty success response",
err: api.ErrSuccess,
data: map[string]interface{}{},
json: `{"error":{"code":0,"reason":"all right"}}`,
},
{
name: "empty failure response",
err: api.ErrFailure,
data: map[string]interface{}{},
json: `{"error":{"code":1,"reason":"it failed"}}`,
},
{
name: "empty unknown error response",
err: api.ErrUnknown,
data: map[string]interface{}{},
json: `{"error":{"code":-1,"reason":"unknown error"}}`,
},
{
name: "success with data before err",
err: api.ErrSuccess,
data: map[string]interface{}{"a": 12},
json: `{"a":12,"error":{"code":0,"reason":"all right"}}`,
},
{
name: "success with data right before err",
err: api.ErrSuccess,
data: map[string]interface{}{"e": 12},
json: `{"e":12,"error":{"code":0,"reason":"all right"}}`,
},
{
name: "success with data right after err",
err: api.ErrSuccess,
data: map[string]interface{}{"f": 12},
json: `{"error":{"code":0,"reason":"all right"},"f":12}`,
},
{
name: "success with data after err",
err: api.ErrSuccess,
data: map[string]interface{}{"z": 12},
json: `{"error":{"code":0,"reason":"all right"},"z":12}`,
},
{
name: "success with data around err",
err: api.ErrSuccess,
data: map[string]interface{}{"d": "before", "f": "after"},
json: `{"d":"before","error":{"code":0,"reason":"all right"},"f":"after"}`,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
res := newResponse().WithError(tc.err)
for k, v := range tc.data {
res.WithValue(k, v)
}
raw, err := json.Marshal(res)
if err != nil {
t.Fatalf("cannot marshal to json: %s", err)
}
if string(raw) != tc.json {
t.Fatalf("mismatching json:\nexpect: %v\nactual: %v", printEscaped(tc.json), printEscaped(string(raw)))
}
})
}
}

91
server.go Normal file
View File

@ -0,0 +1,91 @@
package aicra
import (
"fmt"
"io"
"os"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/dynamic"
"git.xdrm.io/go/aicra/internal/config"
)
// Server represents an AICRA instance featuring: type checkers, services
type Server struct {
config *config.Server
handlers []*handler
}
// New creates a framework instance from a configuration file
func New(configPath string, dtypes ...datatype.T) (*Server, error) {
var (
err error
configFile io.ReadCloser
)
// 1. init instance
var i = &Server{
config: nil,
handlers: make([]*handler, 0),
}
// 2. open config file
configFile, err = os.Open(configPath)
if err != nil {
return nil, err
}
defer configFile.Close()
// 3. load configuration
i.config, err = config.Parse(configFile, dtypes...)
if err != nil {
return nil, err
}
return i, nil
}
// Handle sets a new handler for an HTTP method to a path
func (s *Server) Handle(method, path string, fn dynamic.HandlerFn) error {
// find associated service
var found *config.Service = nil
for _, service := range s.config.Services {
if method == service.Method && path == service.Pattern {
found = service
break
}
}
if found == nil {
return fmt.Errorf("%s '%s': %w", method, path, ErrNoServiceForHandler)
}
handler, err := createHandler(method, path, *found, fn)
if err != nil {
return err
}
s.handlers = append(s.handlers, handler)
return nil
}
// ToHTTPServer converts the server to a http server
func (s Server) ToHTTPServer() (*httpServer, error) {
// check if handlers are missing
for _, service := range s.config.Services {
found := false
for _, handler := range s.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, ErrNoHandlerForService)
}
}
// 2. cast to http server
httpServer := httpServer(s)
return &httpServer, nil
}

15
util.go Normal file
View File

@ -0,0 +1,15 @@
package aicra
import (
"log"
"net/http"
"git.xdrm.io/go/aicra/api"
)
var handledMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
// Prints an error as HTTP response
func logError(res *api.Response) {
log.Printf("[http.fail] %v\n", res)
}

View File

@ -1,24 +0,0 @@
package validator
import (
"reflect"
)
// AnyType makes the "any" type available in the aicra configuration
// It considers valid any value
type AnyType struct{}
// GoType returns the interface{} type
func (AnyType) GoType() reflect.Type {
return reflect.TypeOf(interface{}(nil))
}
// Validator that considers any value valid
func (AnyType) Validator(typename string, avail ...Type) ValidateFunc {
if typename != "any" {
return nil
}
return func(value interface{}) (interface{}, bool) {
return value, true
}
}

View File

@ -1,55 +0,0 @@
package validator
import (
"reflect"
)
// ValidateFunc returns whether a given value fulfills the datatype and casts
// the value into a go type.
//
// for example, if a validator checks for upper case strings, whether the value
// is a []byte, a string or a []rune, if the value matches is all upper-case, it
// will be cast into a go type, say, string.
type ValidateFunc func(value interface{}) (cast interface{}, valid bool)
// Type defines an available in/out parameter "type" for the aicra configuration
//
// A Type maps to a go type in order to generate the handler signature from the
// aicra configuration
//
// A Type returns a custom validator when the typename matches
type Type interface {
// Validator function when the typename matches. It must return nil when the
// typename does not match
//
// The `typename` argument has to match types used in your aicra configuration
// in parameter definitions ("in", "out") and in the "type" json field.
//
// basic example:
// - `IntType.Validator("string")`` should return nil
// - `IntType.Validator("int")`` should return its ValidateFunc
//
// The `typename` is not returned by a simple method i.e. `TypeName() string`
// because it allows for validation relative to the typename, for instance:
// - `VarcharType.Validator("varchar")` valides any string
// - `VarcharType.Validator("varchar(2)")` validates any string of 2
// characters
// - `VarcharType.Validator("varchar(1,3)")` validates any string
// with a length between 1 and 3
//
// The `avail` argument represents all other available Types. It allows a
// Type to use other available Types internally.
//
// recursive example: slices
// - `SliceType.Validator("[]int", avail...)` validates a slice containing
// values that are valide to the `IntType`
// - `SliceType.Validator("[]varchar", avail...)` validates a slice containing
// values that are valid to the `VarcharType`
//
// and so on.. this works for maps, structs, etc
Validator(typename string, avail ...Type) ValidateFunc
// GoType must return the go type associated with the output type of ValidateFunc.
// It is used to define handlers' signature from the configuration file.
GoType() reflect.Type
}