Compare commits

..

No commits in common. "0.3.0" and "v0.3.0" have entirely different histories.

46 changed files with 1535 additions and 3093 deletions

365
README.md
View File

@ -1,79 +1,61 @@
<p align="center">
<a href="https://git.xdrm.io/go/aicra">
<img src="https://git.xdrm.io/go/aicra/raw/branch/feature/improve-readme/readme.assets/logo.png" alt="aicra logo" width="200" height="200">
</a>
</p>
# | aicra |
<h3 align="center">aicra</h3>
[![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 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)
<p align="center">
Fast, intuitive, and powerful configuration-driven engine for faster and easier <em>REST</em> development.
</p>
[![Go version](https://img.shields.io/badge/go_version-1.16-blue.svg)](https://golang.org/doc/go1.16) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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)
**Aicra** is a *configuration-driven* **web framework** written in Go that allows you to create a fully featured REST API.
## Presentation
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
`aicra` is a lightweight and idiomatic configuration-driven engine for building REST services. It's especially good at helping you write large APIs that remain maintainable as your project grows.
The focus of the project is to allow you to build a fully-featured REST API in an elegant, comfortable and inexpensive way. This is achieved by using a single configuration file to drive the server. This one file describes your entire API: methods, uris, input data, expected output, permissions, etc.
The aicra server fulfills the `net/http` [Server interface](https://golang.org/pkg/net/http/#Server).
Repetitive tasks are automatically processed by `aicra` based on your configuration, you're left with implementing your handlers (_usually business logic_).
## Table of contents
> 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 git.xdrm.io/go/aicra
```
2. Import it in your code:
```go
import "git.xdrm.io/go/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
@ -81,105 +63,106 @@ package main
import (
"log"
"net/http"
"os"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func main() {
builder := &aicra.Builder{}
// register data validators
builder.AddType(builtin.BoolDataType{})
builder.AddType(builtin.UintDataType{})
builder.AddType(builtin.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)
}
// bind handlers
err = builder.Bind(http.MethodGet, "/user/{id}", getUserById)
if err != nil {
log.Fatalf("cannog bind GET /user/{id}: %s", err)
}
// ...
// 3. bind your implementations
server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){
// ... process stuff ...
res.SetError(api.ErrorSuccess());
})
// build your services
handler, err := builder.Build()
// 4. extract to http server
httpServer, err := server.ToHTTPServer()
if err != nil {
log.Fatalf("cannot build handler: %s", err)
log.Fatalf("cannot get to http server: %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
[
{
@ -188,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" },
@ -232,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(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,13 +0,0 @@
package api
import "net/http"
// Adapter to encapsulate incoming requests
type Adapter func(http.HandlerFunc) http.HandlerFunc
// AuthHandlerFunc is http.HandlerFunc with additional Authorization information
type AuthHandlerFunc func(Auth, http.ResponseWriter, *http.Request)
// AuthAdapter to encapsulate incoming request with access to api.Auth
// to manage permissions
type AuthAdapter func(AuthHandlerFunc) AuthHandlerFunc

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,108 +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: "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,17 +0,0 @@
package api
import (
"net/http"
)
// Ctx contains additional information for handlers
//
// usually input/output arguments built by aicra are sufficient
// but the Ctx lets you manage your request from scratch if required
//
// If required, set api.Ctx as the first argument of your handler; if you
// don't need it, only use standard input arguments and it will be ignored
type Ctx struct {
Res http.ResponseWriter
Req *http.Request
}

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()
}
return fmt.Sprintf("[%d] %s", e, reason)
}
func (e Err) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, 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
}
}

View File

@ -13,7 +13,7 @@ type Response struct {
Data ResponseData
Status int
Headers http.Header
err Err
err Error
}
// EmptyResponse creates an empty response.
@ -21,17 +21,18 @@ func EmptyResponse() *Response {
return &Response{
Status: http.StatusOK,
Data: make(ResponseData),
err: ErrFailure,
err: ErrorFailure,
Headers: make(http.Header),
}
}
// WithError sets the error
func (res *Response) WithError(err Err) *Response {
// 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()
}
@ -41,23 +42,36 @@ 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.err.Status)
w.WriteHeader(res.Status)
encoded, err := json.Marshal(res)
if err != nil {
return err
}
w.Write(encoded)
return nil
}

View File

@ -1,148 +0,0 @@
package aicra
import (
"fmt"
"io"
"net/http"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/dynfunc"
)
// Builder for an aicra server
type Builder struct {
conf *config.Server
handlers []*apiHandler
adapters []api.Adapter
authAdapters []api.AuthAdapter
}
// represents an api handler (method-pattern combination)
type apiHandler struct {
Method string
Path string
dyn *dynfunc.Handler
}
// AddType adds an available datatype to the api definition
func (b *Builder) AddType(t datatype.T) error {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
return errLateType
}
if b.conf.Types == nil {
b.conf.Types = make([]datatype.T, 0)
}
b.conf.Types = append(b.conf.Types, t)
return nil
}
// With adds an http adapter (middleware)
func (b *Builder) With(adapter api.Adapter) {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.adapters == nil {
b.adapters = make([]api.Adapter, 0)
}
b.adapters = append(b.adapters, adapter)
}
// WithAuth adds an http adapter with auth capabilities (middleware)
func (b *Builder) WithAuth(adapter api.AuthAdapter) {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.authAdapters == nil {
b.authAdapters = make([]api.AuthAdapter, 0)
}
b.authAdapters = append(b.authAdapters, adapter)
}
// 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 {
panic(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.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,262 +0,0 @@
package aicra
import (
"errors"
"net/http"
"strings"
"testing"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func addBuiltinTypes(b *Builder) error {
if err := b.AddType(builtin.AnyDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.BoolDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.FloatDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.IntDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.StringDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.UintDataType{}); err != nil {
return err
}
return nil
}
func TestAddType(t *testing.T) {
builder := &Builder{}
err := builder.AddType(builtin.BoolDataType{})
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.AddType(builtin.FloatDataType{})
if err != errLateType {
t.Fatalf("expected <%v> got <%v>", errLateType, err)
}
}
func TestBind(t *testing.T) {
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() (*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() (*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() (*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() (*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(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(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(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(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)
}
})
}
}

View File

@ -1,23 +0,0 @@
package datatype
import (
"reflect"
)
// Validator returns whether a given value fulfills the datatype
// and casts the value into a common 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 the validator's checks, it will be cast it into
// a common go type, say, string.
type Validator func(value interface{}) (cast interface{}, valid bool)
// T represents a datatype. The Build function returns a Validator if
// it manages types with the name `typeDefinition` (from the configuration field "type"); else it or returns NIL if the type
// definition does not match this datatype; the registry is passed to allow recursive datatypes (e.g. slices, structs, etc)
// The datatype's validator (when input is valid) must return a cast's go type matching the `Type() reflect.Type`
type T interface {
Type() reflect.Type
Build(typeDefinition string, registry ...T) Validator
}

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")

2
go.mod
View File

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

View File

@ -2,150 +2,31 @@ package aicra
import (
"fmt"
"net/http"
"strings"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/dynamic"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/reqdata"
)
// Handler wraps the builder to handle requests
type Handler Builder
// ServeHTTP implements http.Handler and wraps it in middlewares (adapters)
func (s Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var h = http.HandlerFunc(s.resolve)
for _, adapter := range s.adapters {
h = adapter(h)
}
h(w, r)
type handler struct {
Method string
Path string
dynHandler *dynamic.Handler
}
func (s Handler) resolve(w http.ResponseWriter, r *http.Request) {
// 1. find a matching service from config
var service = s.conf.Find(r)
if service == nil {
handleError(api.ErrUnknownService, 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)
// 2. extract request data
var input, err = extractInput(service, *r)
dynHandler, err := dynamic.Build(fn, service)
if err != nil {
handleError(api.ErrMissingParam, w, r)
return
return nil, fmt.Errorf("%s '%s' handler: %w", method, path, err)
}
// 3. find a matching handler
var handler *apiHandler
for _, h := range s.handlers {
if h.Method == service.Method && h.Path == service.Pattern {
handler = h
}
}
// 4. fail on no matching handler
if handler == nil {
handleError(api.ErrUncallableService, w, r)
return
}
// replace format '[a]' in scope where 'a' is an existing input's name
scope := make([][]string, len(service.Scope))
for a, list := range service.Scope {
scope[a] = make([]string, len(list))
for b, perm := range list {
scope[a][b] = perm
for name, value := range input.Data {
var (
token = fmt.Sprintf("[%s]", name)
replacement = ""
)
if value != nil {
replacement = fmt.Sprintf("[%v]", value)
}
scope[a][b] = strings.ReplaceAll(scope[a][b], token, replacement)
}
}
}
var auth = api.Auth{
Required: scope,
Active: []string{},
}
// 5. run auth-aware middlewares
var h = api.AuthHandlerFunc(func(a api.Auth, w http.ResponseWriter, r *http.Request) {
if !a.Granted() {
handleError(api.ErrPermission, w, r)
return
}
s.handle(input, handler, service, w, r)
})
for _, adapter := range s.authAdapters {
h = adapter(h)
}
h(auth, w, r)
}
func (s *Handler) handle(input *reqdata.T, handler *apiHandler, service *config.Service, w http.ResponseWriter, r *http.Request) {
// 5. pass execution to the handler
ctx := api.Ctx{Res: w, Req: r}
var outData, outErr = handler.dyn.Handle(ctx, input.Data)
// 6. build res from returned data
var res = api.EmptyResponse().WithError(outErr)
for key, value := range outData {
// find original name from 'rename' field
for name, param := range service.Output {
if param.Rename == key {
res.SetData(name, value)
}
}
}
// 7. apply headers
w.Header().Set("Content-Type", "application/json; charset=utf-8")
for key, values := range res.Headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
res.ServeHTTP(w, r)
}
func handleError(err api.Err, w http.ResponseWriter, r *http.Request) {
var response = api.EmptyResponse().WithError(err)
response.ServeHTTP(w, r)
}
func extractInput(service *config.Service, req http.Request) (*reqdata.T, error) {
var dataset = reqdata.New(service)
// URI data
var err = dataset.GetURI(req)
if err != nil {
return nil, err
}
// query data
err = dataset.GetQuery(req)
if err != nil {
return nil, err
}
// form/json data
err = dataset.GetForm(req)
if err != nil {
return nil, err
}
return dataset, nil
return &handler{
Path: path,
Method: method,
dynHandler: dynHandler,
}, nil
}

View File

@ -1,511 +0,0 @@
package aicra_test
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func addBuiltinTypes(b *aicra.Builder) error {
if err := b.AddType(builtin.AnyDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.BoolDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.FloatDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.IntDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.StringDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.UintDataType{}); err != nil {
return err
}
return nil
}
func TestWith(t *testing.T) {
builder := &aicra.Builder{}
if err := addBuiltinTypes(builder); err != nil {
t.Fatalf("unexpected error <%v>", err)
}
// build @n middlewares that take data from context and increment it
n := 1024
type ckey int
const key ckey = 0
middleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
newr := r
// first time -> store 1
value := r.Context().Value(key)
if value == nil {
newr = r.WithContext(context.WithValue(r.Context(), key, int(1)))
next(w, newr)
return
}
// get value and increment
cast, ok := value.(int)
if !ok {
t.Fatalf("value is not an int")
}
cast++
newr = r.WithContext(context.WithValue(r.Context(), key, cast))
next(w, newr)
}
}
// add middleware @n times
for i := 0; i < n; i++ {
builder.With(middleware)
}
config := strings.NewReader(`[ { "method": "GET", "path": "/path", "scope": [[]], "info": "info", "in": {}, "out": {} } ]`)
err := builder.Setup(config)
if err != nil {
t.Fatalf("setup: unexpected error <%v>", err)
}
pathHandler := func(ctx api.Ctx) (*struct{}, api.Err) {
// write value from middlewares into response
value := ctx.Req.Context().Value(key)
if value == nil {
t.Fatalf("nothing found in context")
}
cast, ok := value.(int)
if !ok {
t.Fatalf("cannot cast context data to int")
}
// write to response
ctx.Res.Write([]byte(fmt.Sprintf("#%d#", cast)))
return nil, api.ErrSuccess
}
if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil {
t.Fatalf("bind: unexpected error <%v>", err)
}
handler, err := builder.Build()
if err != nil {
t.Fatalf("build: unexpected error <%v>", err)
}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{})
// test request
handler.ServeHTTP(response, request)
if response.Body == nil {
t.Fatalf("response has no body")
}
token := fmt.Sprintf("#%d#", n)
if !strings.Contains(response.Body.String(), token) {
t.Fatalf("expected '%s' to be in response <%s>", token, response.Body.String())
}
}
func TestWithAuth(t *testing.T) {
tt := []struct {
name string
manifest string
permissions []string
granted bool
}{
{
name: "provide only requirement A",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A"},
granted: true,
},
{
name: "missing requirement",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{},
granted: false,
},
{
name: "missing requirements",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{},
granted: false,
},
{
name: "missing some requirements",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A"},
granted: false,
},
{
name: "provide requirements",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A", "B"},
granted: true,
},
{
name: "missing OR requirements",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"C"},
granted: false,
},
{
name: "provide 1 OR requirement",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A"},
granted: true,
},
{
name: "provide both OR requirements",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A", "B"},
granted: true,
},
{
name: "missing composite OR requirements",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{},
granted: false,
},
{
name: "missing partial composite OR requirements",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A", "C"},
granted: false,
},
{
name: "provide 1 composite OR requirement",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A", "B", "C"},
granted: true,
},
{
name: "provide both composite OR requirements",
manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`,
permissions: []string{"A", "B", "C", "D"},
granted: true,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
builder := &aicra.Builder{}
if err := addBuiltinTypes(builder); err != nil {
t.Fatalf("unexpected error <%v>", err)
}
// tester middleware (last executed)
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
if a.Granted() == tc.granted {
return
}
if a.Granted() {
t.Fatalf("unexpected granted auth")
} else {
t.Fatalf("expected granted auth")
}
}
})
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
a.Active = tc.permissions
next(a, w, r)
}
})
err := builder.Setup(strings.NewReader(tc.manifest))
if err != nil {
t.Fatalf("setup: unexpected error <%v>", err)
}
pathHandler := func(ctx api.Ctx) (*struct{}, api.Err) {
return nil, api.ErrNotImplemented
}
if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil {
t.Fatalf("bind: unexpected error <%v>", err)
}
handler, err := builder.Build()
if err != nil {
t.Fatalf("build: unexpected error <%v>", err)
}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{})
// test request
handler.ServeHTTP(response, request)
if response.Body == nil {
t.Fatalf("response has no body")
}
})
}
}
func TestDynamicScope(t *testing.T) {
tt := []struct {
name string
manifest string
path string
handler interface{}
url string
body string
permissions []string
granted bool
}{
{
name: "replace one granted",
manifest: `[
{
"method": "POST",
"path": "/path/{id}",
"info": "info",
"scope": [["user[Input1]"]],
"in": {
"{id}": { "info": "info", "name": "Input1", "type": "uint" }
},
"out": {}
}
]`,
path: "/path/{id}",
handler: func(struct{ Input1 uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
url: "/path/123",
body: ``,
permissions: []string{"user[123]"},
granted: true,
},
{
name: "replace one mismatch",
manifest: `[
{
"method": "POST",
"path": "/path/{id}",
"info": "info",
"scope": [["user[Input1]"]],
"in": {
"{id}": { "info": "info", "name": "Input1", "type": "uint" }
},
"out": {}
}
]`,
path: "/path/{id}",
handler: func(struct{ Input1 uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
url: "/path/666",
body: ``,
permissions: []string{"user[123]"},
granted: false,
},
{
name: "replace one valid dot separated",
manifest: `[
{
"method": "POST",
"path": "/path/{id}",
"info": "info",
"scope": [["prefix.user[User].suffix"]],
"in": {
"{id}": { "info": "info", "name": "User", "type": "uint" }
},
"out": {}
}
]`,
path: "/path/{id}",
handler: func(struct{ User uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
url: "/path/123",
body: ``,
permissions: []string{"prefix.user[123].suffix"},
granted: true,
},
{
name: "replace two valid dot separated",
manifest: `[
{
"method": "POST",
"path": "/prefix/{pid}/user/{uid}",
"info": "info",
"scope": [["prefix[Prefix].user[User].suffix"]],
"in": {
"{pid}": { "info": "info", "name": "Prefix", "type": "uint" },
"{uid}": { "info": "info", "name": "User", "type": "uint" }
},
"out": {}
}
]`,
path: "/prefix/{pid}/user/{uid}",
handler: func(struct {
Prefix uint
User uint
}) (*struct{}, api.Err) {
return nil, api.ErrSuccess
},
url: "/prefix/123/user/456",
body: ``,
permissions: []string{"prefix[123].user[456].suffix"},
granted: true,
},
{
name: "replace two invalid dot separated",
manifest: `[
{
"method": "POST",
"path": "/prefix/{pid}/user/{uid}",
"info": "info",
"scope": [["prefix[Prefix].user[User].suffix"]],
"in": {
"{pid}": { "info": "info", "name": "Prefix", "type": "uint" },
"{uid}": { "info": "info", "name": "User", "type": "uint" }
},
"out": {}
}
]`,
path: "/prefix/{pid}/user/{uid}",
handler: func(struct {
Prefix uint
User uint
}) (*struct{}, api.Err) {
return nil, api.ErrSuccess
},
url: "/prefix/123/user/666",
body: ``,
permissions: []string{"prefix[123].user[456].suffix"},
granted: false,
},
{
name: "replace three valid dot separated",
manifest: `[
{
"method": "POST",
"path": "/prefix/{pid}/user/{uid}/suffix/{sid}",
"info": "info",
"scope": [["prefix[Prefix].user[User].suffix[Suffix]"]],
"in": {
"{pid}": { "info": "info", "name": "Prefix", "type": "uint" },
"{uid}": { "info": "info", "name": "User", "type": "uint" },
"{sid}": { "info": "info", "name": "Suffix", "type": "uint" }
},
"out": {}
}
]`,
path: "/prefix/{pid}/user/{uid}/suffix/{sid}",
handler: func(struct {
Prefix uint
User uint
Suffix uint
}) (*struct{}, api.Err) {
return nil, api.ErrSuccess
},
url: "/prefix/123/user/456/suffix/789",
body: ``,
permissions: []string{"prefix[123].user[456].suffix[789]"},
granted: true,
},
{
name: "replace three invalid dot separated",
manifest: `[
{
"method": "POST",
"path": "/prefix/{pid}/user/{uid}/suffix/{sid}",
"info": "info",
"scope": [["prefix[Prefix].user[User].suffix[Suffix]"]],
"in": {
"{pid}": { "info": "info", "name": "Prefix", "type": "uint" },
"{uid}": { "info": "info", "name": "User", "type": "uint" },
"{sid}": { "info": "info", "name": "Suffix", "type": "uint" }
},
"out": {}
}
]`,
path: "/prefix/{pid}/user/{uid}/suffix/{sid}",
handler: func(struct {
Prefix uint
User uint
Suffix uint
}) (*struct{}, api.Err) {
return nil, api.ErrSuccess
},
url: "/prefix/123/user/666/suffix/789",
body: ``,
permissions: []string{"prefix[123].user[456].suffix[789]"},
granted: false,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
builder := &aicra.Builder{}
if err := addBuiltinTypes(builder); err != nil {
t.Fatalf("unexpected error <%v>", err)
}
// tester middleware (last executed)
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
if a.Granted() == tc.granted {
return
}
if a.Granted() {
t.Fatalf("unexpected granted auth")
} else {
t.Fatalf("expected granted auth")
}
}
})
// update permissions
builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc {
return func(a api.Auth, w http.ResponseWriter, r *http.Request) {
a.Active = tc.permissions
next(a, w, r)
}
})
err := builder.Setup(strings.NewReader(tc.manifest))
if err != nil {
t.Fatalf("setup: unexpected error <%v>", err)
}
if err := builder.Bind(http.MethodPost, tc.path, tc.handler); err != nil {
t.Fatalf("bind: unexpected error <%v>", err)
}
handler, err := builder.Build()
if err != nil {
t.Fatalf("build: unexpected error <%v>", err)
}
response := httptest.NewRecorder()
body := strings.NewReader(tc.body)
request := httptest.NewRequest(http.MethodPost, tc.url, body)
// test request
handler.ServeHTTP(response, request)
if response.Body == nil {
t.Fatalf("response has no body")
}
})
}
}

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"
"git.xdrm.io/go/aicra/datatype"
)
// Server definition
type Server struct {
Types []datatype.T
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 (srv *Server) Parse(r io.Reader) error {
err := json.NewDecoder(r).Decode(&srv.Services)
if err != nil {
return fmt.Errorf("%s: %w", errRead, err)
}
err = srv.validate()
if err != nil {
return fmt.Errorf("%s: %w", errFormat, err)
}
return 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)
}
}
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 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 (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]
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

@ -21,15 +21,15 @@ func TestLegalServiceName(t *testing.T) {
// empty
{
`[ { "method": "GET", "info": "a", "path": "" } ]`,
errInvalidPattern,
ErrInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "no-starting-slash" } ]`,
errInvalidPattern,
ErrInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "ending-slash/" } ]`,
errInvalidPattern,
ErrInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "/" } ]`,
@ -45,43 +45,42 @@ func TestLegalServiceName(t *testing.T) {
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/s{braces}" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}a" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}" } ]`,
errUndefinedBraceCapture,
ErrUndefinedBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/s{braces}/abc" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}s/abc" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}/abc" } ]`,
errUndefinedBraceCapture,
ErrUndefinedBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{b{races}s/abc" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}/}abc" } ]`,
errInvalidPatternBraceCapture,
ErrInvalidPatternBraceCapture,
},
}
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,16 +134,15 @@ 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())
t.FailNow()
}
if !test.ValidMethod && !errors.Is(err, errUnknownMethod) {
t.Errorf("expected error <%s> got <%s>", errUnknownMethod, err)
if !test.ValidMethod && !errors.Is(err, ErrUnknownMethod) {
t.Errorf("expected error <%s> got <%s>", ErrUnknownMethod, err)
t.FailNow()
}
})
@ -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()
@ -209,16 +205,15 @@ 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)
t.FailNow()
}
if !test.ValidDescription && !errors.Is(err, errMissingDescription) {
t.Errorf("expected error <%s> got <%s>", errMissingDescription, err)
if !test.ValidDescription && !errors.Is(err, ErrMissingDescription) {
t.Errorf("expected error <%s> got <%s>", ErrMissingDescription, err)
t.FailNow()
}
})
@ -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.Types = append(srv.Types, builtin.AnyDataType{})
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.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
err := srv.Parse(r)
srv, err := Parse(reader, builtin.AnyDataType{}, builtin.BoolDataType{})
if err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
@ -321,7 +311,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamDesc,
ErrMissingParamDesc,
},
{ // invalid param name suffix
`[
@ -334,7 +324,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamDesc,
ErrMissingParamDesc,
},
{ // missing param description
@ -348,7 +338,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamDesc,
ErrMissingParamDesc,
},
{ // empty param description
`[
@ -361,7 +351,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamDesc,
ErrMissingParamDesc,
},
{ // missing param type
@ -375,7 +365,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamType,
ErrMissingParamType,
},
{ // empty param type
`[
@ -388,7 +378,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMissingParamType,
ErrMissingParamType,
},
{ // invalid type (optional mark only)
`[
@ -402,7 +392,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
errMissingParamType,
ErrMissingParamType,
},
{ // valid description + valid type
`[
@ -444,7 +434,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
errParamNameConflict,
ErrParamNameConflict,
},
{ // rename conflict with name
`[
@ -459,7 +449,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
errParamNameConflict,
ErrParamNameConflict,
},
{ // rename conflict with rename
`[
@ -474,7 +464,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
errParamNameConflict,
ErrParamNameConflict,
},
{ // both renamed with no conflict
@ -503,7 +493,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMandatoryRename,
ErrMandatoryRename,
},
{
`[
@ -516,7 +506,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errMandatoryRename,
ErrMandatoryRename,
},
{
`[
@ -556,7 +546,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errIllegalOptionalURIParam,
ErrIllegalOptionalURIParam,
},
{ // URI parameter not specified
`[
@ -569,7 +559,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
errUnspecifiedBraceCapture,
ErrUnspecifiedBraceCapture,
},
{ // URI parameter not defined
`[
@ -580,16 +570,14 @@ func TestParseParameters(t *testing.T) {
"in": { }
}
]`,
errUndefinedBraceCapture,
ErrUndefinedBraceCapture,
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
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())
@ -637,7 +625,7 @@ func TestServiceCollision(t *testing.T) {
"info": "info", "in": {}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -672,7 +660,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -698,7 +686,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -711,7 +699,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -750,7 +738,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -789,7 +777,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -804,7 +792,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
errPatternCollision,
ErrPatternCollision,
},
{
`[
@ -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.Types = append(srv.Types, builtin.StringDataType{})
srv.Types = append(srv.Types, builtin.UintDataType{})
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.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.IntDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
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.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.IntDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
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

@ -3,57 +3,58 @@ package config
// 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)
}
// errRead - read error
const errRead = cerr("cannot read config")
// ErrRead - a problem ocurred when trying to read the configuration file
const ErrRead = cerr("cannot read config")
// errUnknownMethod - unknown http method
const errUnknownMethod = cerr("unknown HTTP method")
// ErrUnknownMethod - invalid http method
const ErrUnknownMethod = cerr("unknown HTTP method")
// errFormat - invalid format
const errFormat = cerr("invalid config format")
// ErrFormat - a invalid format has been detected
const ErrFormat = cerr("invalid config format")
// errPatternCollision - collision between 2 services' patterns
const errPatternCollision = cerr("pattern collision")
// ErrPatternCollision - there is a collision between 2 services' patterns (same method)
const ErrPatternCollision = cerr("pattern collision")
// errInvalidPattern - malformed service pattern
const errInvalidPattern = cerr("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
const errInvalidPatternBraceCapture = cerr("invalid uri parameter")
// ErrInvalidPatternBraceCapture - a service pattern brace capture is invalid
const ErrInvalidPatternBraceCapture = cerr("invalid uri capturing braces")
// errUnspecifiedBraceCapture - missing path brace capture
const errUnspecifiedBraceCapture = cerr("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
const errUndefinedBraceCapture = cerr("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
const errMandatoryRename = cerr("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
const errMissingDescription = cerr("missing description")
// ErrMissingDescription - a service is missing its description
const ErrMissingDescription = cerr("missing description")
// errIllegalOptionalURIParam - uri parameter cannot optional
const errIllegalOptionalURIParam = cerr("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
const errOptionalOption = cerr("output cannot be optional")
// ErrOptionalOption - an output is optional
const ErrOptionalOption = cerr("output cannot be optional")
// errMissingParamDesc - missing parameter description
const errMissingParamDesc = cerr("missing parameter description")
// ErrMissingParamDesc - a parameter is missing its description
const ErrMissingParamDesc = cerr("missing parameter description")
// errUnknownDataType - unknown parameter datatype
const errUnknownDataType = cerr("unknown parameter datatype")
// ErrUnknownDataType - a parameter has an unknown datatype name
const ErrUnknownDataType = cerr("unknown data type")
// errIllegalParamName - illegal parameter name
const errIllegalParamName = cerr("illegal parameter name")
// ErrIllegalParamName - a parameter has an illegal name
const ErrIllegalParamName = cerr("illegal parameter name")
// errMissingParamType - missing parameter type
const errMissingParamType = cerr("missing parameter type")
// ErrMissingParamType - a parameter has an illegal type
const ErrMissingParamType = cerr("missing parameter type")
// errParamNameConflict - name/rename conflict
const errParamNameConflict = cerr("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,39 +1,28 @@
package config
import (
"reflect"
"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
// ExtractType is the type the Validator will cast into
ExtractType reflect.Type
// Validator is inferred from the "type" property
Validator datatype.Validator
}
func (param *Parameter) validate(datatypes ...datatype.T) error {
// Validate implements the validator interface
func (param *Parameter) Validate(datatypes ...datatype.T) error {
// missing description
if len(param.Description) < 1 {
return errMissingParamDesc
return ErrMissingParamDesc
}
// invalid type
if len(param.Type) < 1 || param.Type == "?" {
return errMissingParamType
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.Build(param.Type, datatypes...)
param.ExtractType = dtype.Type()
@ -42,7 +31,8 @@ func (param *Parameter) validate(datatypes ...datatype.T) error {
}
}
if param.Validator == nil {
return errUnknownDataType
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

@ -11,44 +11,22 @@ import (
var braceRegex = regexp.MustCompile(`^{([a-z_-]+)}$`)
var queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`)
var 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
}
// Match returns if this service would handle this HTTP request
func (svc *Service) Match(req *http.Request) bool {
// method
if req.Method != svc.Method {
return false
}
// check path
if !svc.matchPattern(req.RequestURI) {
return false
}
// check and extract input
// todo: check if input match and extract models
return true
}
@ -57,12 +35,13 @@ func (svc *Service) matchPattern(uri string) bool {
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
}
@ -97,7 +76,7 @@ func (svc *Service) matchPattern(uri string) bool {
}
// Validate implements the validator interface
func (svc *Service) validate(datatypes ...datatype.T) error {
func (svc *Service) Validate(datatypes ...datatype.T) error {
// check method
err := svc.isMethodAvailable()
if err != nil {
@ -113,7 +92,7 @@ func (svc *Service) validate(datatypes ...datatype.T) error {
// check description
if len(strings.Trim(svc.Description, " \t\r\n")) < 1 {
return fmt.Errorf("field 'description': %w", errMissingDescription)
return fmt.Errorf("field 'description': %w", ErrMissingDescription)
}
// check input parameters
@ -125,7 +104,7 @@ func (svc *Service) validate(datatypes ...datatype.T) error {
// 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)
return fmt.Errorf("field 'in': %s: %w", capture.Name, ErrUndefinedBraceCapture)
}
}
@ -144,7 +123,7 @@ func (svc *Service) isMethodAvailable() error {
return nil
}
}
return errUnknownMethod
return ErrUnknownMethod
}
func (svc *Service) isPatternValid() error {
@ -152,13 +131,13 @@ func (svc *Service) isPatternValid() error {
// empty pattern
if length < 1 {
return errInvalidPattern
return ErrInvalidPattern
}
if length > 1 {
// pattern not starting with '/' or ending with '/'
if svc.Pattern[0] != '/' || svc.Pattern[length-1] == '/' {
return errInvalidPattern
return ErrInvalidPattern
}
}
@ -166,7 +145,7 @@ func (svc *Service) isPatternValid() error {
parts := SplitURL(svc.Pattern)
for i, part := range parts {
if len(part) < 1 {
return errInvalidPattern
return ErrInvalidPattern
}
// if brace capture
@ -187,7 +166,7 @@ func (svc *Service) isPatternValid() error {
// fail on invalid format
if strings.ContainsAny(part, "{}") {
return errInvalidPatternBraceCapture
return ErrInvalidPatternBraceCapture
}
}
@ -206,7 +185,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// for each parameter
for paramName, param := range svc.Input {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, errIllegalParamName)
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
}
// fail if brace capture does not exists in pattern
@ -223,7 +202,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
}
}
if !found {
return fmt.Errorf("%s: %w", paramName, errUnspecifiedBraceCapture)
return fmt.Errorf("%s: %w", paramName, ErrUnspecifiedBraceCapture)
}
iscapture = true
@ -246,7 +225,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// fail if capture or query without rename
if len(param.Rename) < 1 && (iscapture || isquery) {
return fmt.Errorf("%s: %w", paramName, errMandatoryRename)
return fmt.Errorf("%s: %w", paramName, ErrMandatoryRename)
}
// use param name if no rename
@ -254,14 +233,14 @@ func (svc *Service) validateInput(types []datatype.T) error {
param.Rename = paramName
}
err := param.validate(types...)
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)
return fmt.Errorf("%s: %w", paramName, ErrIllegalOptionalURIParam)
}
// fail on name/rename conflict
@ -275,7 +254,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// 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 fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
}
}
@ -296,7 +275,7 @@ func (svc *Service) validateOutput(types []datatype.T) error {
// for each parameter
for paramName, param := range svc.Output {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, errIllegalParamName)
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
}
// use param name if no rename
@ -304,13 +283,13 @@ func (svc *Service) validateOutput(types []datatype.T) error {
param.Rename = paramName
}
err := param.validate(types...)
err := param.Validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
}
if param.Optional {
return fmt.Errorf("%s: %w", paramName, errOptionalOption)
return fmt.Errorf("%s: %w", paramName, ErrOptionalOption)
}
// fail on name/rename conflict
@ -324,7 +303,7 @@ func (svc *Service) validateOutput(types []datatype.T) error {
// 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 fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
}
}

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,50 +0,0 @@
package dynfunc
// cerr allows you to create constant "const" error with type boxing.
type cerr string
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")
// errUnexpectedInput - input argument is not expected
const errUnexpectedInput = cerr("unexpected input 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.Err")
// 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")
// errUnexportedName - argument is unexported in struct
const errUnexportedName = cerr("unexported name")
// 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")
// errMissingHandlerErrorOutput - missing handler output error
const errMissingHandlerErrorOutput = cerr("last output must be of type api.Err")

View File

@ -1,147 +0,0 @@
package dynfunc
import (
"fmt"
"log"
"reflect"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
)
// Handler represents a dynamic api handler
type Handler struct {
spec *signature
fn interface{}
// whether fn uses api.Ctx as 1st argument
hasContext bool
// index in input arguments where the data struct must be
dataIndex int
}
// Build a handler from a service configuration and a dynamic function
//
// @fn must have as a signature : `func(inputStruct) (*outputStruct, api.Err)`
// - `inputStruct` is a struct{} containing a field for each service input (with valid reflect.Type)
// - `outputStruct` is a struct{} containing a field for each service output (with valid reflect.Type)
//
// Special cases:
// - a first optional input parameter of type `api.Ctx` can be added
// - it there is no input, `inputStruct` must be omitted
// - it there is no output, `outputStruct` must be omitted
func Build(fn interface{}, service config.Service) (*Handler, error) {
h := &Handler{
spec: signatureFromService(service),
fn: fn,
}
impl := reflect.TypeOf(fn)
if impl.Kind() != reflect.Func {
return nil, errHandlerNotFunc
}
h.hasContext = impl.NumIn() >= 1 && reflect.TypeOf(api.Ctx{}).AssignableTo(impl.In(0))
if h.hasContext {
h.dataIndex = 1
}
if err := h.spec.checkInput(impl, h.dataIndex); err != nil {
return nil, fmt.Errorf("input: %w", err)
}
if err := h.spec.checkOutput(impl); err != nil {
return nil, fmt.Errorf("output: %w", err)
}
return h, nil
}
// Handle binds input @data into the dynamic function and returns map output
func (h *Handler) Handle(ctx api.Ctx, data map[string]interface{}) (map[string]interface{}, api.Err) {
var ert = reflect.TypeOf(api.Err{})
var fnv = reflect.ValueOf(h.fn)
callArgs := []reflect.Value{}
// bind context if used in handler
if h.hasContext {
callArgs = append(callArgs, reflect.ValueOf(ctx))
}
// bind input data
if fnv.Type().NumIn() > h.dataIndex {
// 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, 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 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() {
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.spec.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,173 +0,0 @@
package dynfunc
import (
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/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() (*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(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(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(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(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(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 dataIndex = 0
if tcase.HasContext {
dataIndex = 1
}
var handler = &Handler{
spec: &signature{Input: tcase.Spec.Input, Output: tcase.Spec.Output},
fn: tcase.Fn,
dataIndex: dataIndex,
hasContext: tcase.HasContext,
}
// 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(api.Ctx{}, 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,148 +0,0 @@
package dynfunc
import (
"fmt"
"reflect"
"strings"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
)
// signature represents input/output arguments for a dynamic function
type signature struct {
Input map[string]reflect.Type
Output map[string]reflect.Type
}
// builds a spec from the configuration service
func signatureFromService(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.ExtractType)
continue
}
s.Input[param.Rename] = param.ExtractType
}
for _, param := range service.Output {
if len(param.Rename) < 1 {
continue
}
s.Output[param.Rename] = param.ExtractType
}
return s
}
// checks for HandlerFn input arguments
func (s *signature) checkInput(impl reflect.Type, index int) error {
var requiredInput, structIndex = index, index
if len(s.Input) > 0 { // arguments struct
requiredInput++
}
// missing arguments
if impl.NumIn() > requiredInput {
return errUnexpectedInput
}
// none required
if len(s.Input) == 0 {
return nil
}
// too much arguments
if impl.NumIn() != requiredInput {
return errMissingHandlerArgumentParam
}
// arg must be a struct
structArg := impl.In(structIndex)
if structArg.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 := 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 signature) checkOutput(impl reflect.Type) error {
if impl.NumOut() < 1 {
return errMissingHandlerOutput
}
// last output must be api.Err
errOutput := impl.Out(impl.NumOut() - 1)
if !errOutput.AssignableTo(reflect.TypeOf(api.ErrUnknown)) {
return errMissingHandlerErrorOutput
}
// no output -> ok
if len(s.Output) == 0 {
return nil
}
if impl.NumOut() != 2 {
return errMissingParamOutput
}
// fail if first output is not a pointer to struct
structOutputPtr := impl.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 {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, errUnexportedName)
}
field, exists := structOutput.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, errMissingOutputFromConfig)
}
// 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,397 +0,0 @@
package dynfunc
import (
"errors"
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/aicra/api"
)
func TestInputCheck(t *testing.T) {
tcases := []struct {
Name string
Input map[string]reflect.Type
Fn interface{}
FnCtx interface{}
Err error
}{
{
Name: "no input 0 given",
Input: map[string]reflect.Type{},
Fn: func() {},
FnCtx: func(api.Ctx) {},
Err: nil,
},
{
Name: "no input 1 given",
Input: map[string]reflect.Type{},
Fn: func(int) {},
FnCtx: func(api.Ctx, int) {},
Err: errUnexpectedInput,
},
{
Name: "no input 2 given",
Input: map[string]reflect.Type{},
Fn: func(int, string) {},
FnCtx: func(api.Ctx, int, string) {},
Err: errUnexpectedInput,
},
{
Name: "1 input 0 given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() {},
FnCtx: func(api.Ctx) {},
Err: errMissingHandlerArgumentParam,
},
{
Name: "1 input non-struct given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(int) {},
FnCtx: func(api.Ctx, int) {},
Err: errMissingParamArgument,
},
{
Name: "unexported input",
Input: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{}) {},
FnCtx: func(api.Ctx, struct{}) {},
Err: errUnexportedName,
},
{
Name: "1 input empty struct given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{}) {},
FnCtx: func(api.Ctx, struct{}) {},
Err: errMissingParamFromConfig,
},
{
Name: "1 input invalid given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{ Test1 string }) {},
FnCtx: func(api.Ctx, struct{ Test1 string }) {},
Err: errWrongParamTypeFromConfig,
},
{
Name: "1 input valid given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{ Test1 int }) {},
FnCtx: func(api.Ctx, struct{ Test1 int }) {},
Err: nil,
},
{
Name: "1 input ptr empty struct given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(struct{}) {},
FnCtx: func(api.Ctx, struct{}) {},
Err: errMissingParamFromConfig,
},
{
Name: "1 input ptr invalid given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(struct{ Test1 string }) {},
FnCtx: func(api.Ctx, 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(struct{ Test1 *string }) {},
FnCtx: func(api.Ctx, struct{ Test1 *string }) {},
Err: errWrongParamTypeFromConfig,
},
{
Name: "1 input ptr valid given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(struct{ Test1 *int }) {},
FnCtx: func(api.Ctx, struct{ Test1 *int }) {},
Err: nil,
},
{
Name: "1 valid string",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(string("")),
},
Fn: func(struct{ Test1 string }) {},
FnCtx: func(api.Ctx, struct{ Test1 string }) {},
Err: nil,
},
{
Name: "1 valid uint",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(uint(0)),
},
Fn: func(struct{ Test1 uint }) {},
FnCtx: func(api.Ctx, struct{ Test1 uint }) {},
Err: nil,
},
{
Name: "1 valid float64",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(float64(0)),
},
Fn: func(struct{ Test1 float64 }) {},
FnCtx: func(api.Ctx, struct{ Test1 float64 }) {},
Err: nil,
},
{
Name: "1 valid []byte",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf([]byte("")),
},
Fn: func(struct{ Test1 []byte }) {},
FnCtx: func(api.Ctx, struct{ Test1 []byte }) {},
Err: nil,
},
{
Name: "1 valid []rune",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf([]rune("")),
},
Fn: func(struct{ Test1 []rune }) {},
FnCtx: func(api.Ctx, struct{ Test1 []rune }) {},
Err: nil,
},
{
Name: "1 valid *string",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(string)),
},
Fn: func(struct{ Test1 *string }) {},
FnCtx: func(api.Ctx, struct{ Test1 *string }) {},
Err: nil,
},
{
Name: "1 valid *uint",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(uint)),
},
Fn: func(struct{ Test1 *uint }) {},
FnCtx: func(api.Ctx, struct{ Test1 *uint }) {},
Err: nil,
},
{
Name: "1 valid *float64",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(float64)),
},
Fn: func(struct{ Test1 *float64 }) {},
FnCtx: func(api.Ctx, struct{ Test1 *float64 }) {},
Err: nil,
},
{
Name: "1 valid *[]byte",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new([]byte)),
},
Fn: func(struct{ Test1 *[]byte }) {},
FnCtx: func(api.Ctx, struct{ Test1 *[]byte }) {},
Err: nil,
},
{
Name: "1 valid *[]rune",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new([]rune)),
},
Fn: func(struct{ Test1 *[]rune }) {},
FnCtx: func(api.Ctx, struct{ Test1 *[]rune }) {},
Err: nil,
},
}
for _, tcase := range tcases {
t.Run(tcase.Name, func(t *testing.T) {
t.Parallel()
// mock spec
s := signature{
Input: tcase.Input,
Output: nil,
}
t.Run("with-context", func(t *testing.T) {
err := s.checkInput(reflect.TypeOf(tcase.FnCtx), 1)
if err == nil && tcase.Err != nil {
t.Errorf("expected an error: '%s'", tcase.Err.Error())
t.FailNow()
}
if err != nil && tcase.Err == nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
}
if err != nil && tcase.Err != nil {
if !errors.Is(err, tcase.Err) {
t.Errorf("expected the error <%s> got <%s>", tcase.Err, err)
t.FailNow()
}
}
})
t.Run("without-context", func(t *testing.T) {
err := s.checkInput(reflect.TypeOf(tcase.Fn), 0)
if err == nil && tcase.Err != nil {
t.Errorf("expected an error: '%s'", tcase.Err.Error())
t.FailNow()
}
if err != nil && tcase.Err == nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
}
if err != nil && tcase.Err != nil {
if !errors.Is(err, tcase.Err) {
t.Errorf("expected the error <%s> got <%s>", tcase.Err, err)
t.FailNow()
}
}
})
})
}
}
func TestOutputCheck(t *testing.T) {
tcases := []struct {
Output map[string]reflect.Type
Fn interface{}
Err error
}{
// no input -> missing api.Err
{
Output: map[string]reflect.Type{},
Fn: func() {},
Err: errMissingHandlerOutput,
},
// no input -> with last type not api.Err
{
Output: map[string]reflect.Type{},
Fn: func() bool { return true },
Err: errMissingHandlerErrorOutput,
},
// no input -> with api.Err
{
Output: map[string]reflect.Type{},
Fn: func() api.Err { return api.ErrSuccess },
Err: nil,
},
// func can have output if not specified
{
Output: map[string]reflect.Type{},
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
// missing output struct in func
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() api.Err { return api.ErrSuccess },
Err: errMissingParamOutput,
},
// output not a pointer
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (int, api.Err) { return 0, api.ErrSuccess },
Err: errMissingParamOutput,
},
// output not a pointer to struct
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*int, api.Err) { return nil, api.ErrSuccess },
Err: errMissingParamOutput,
},
// unexported param name
{
Output: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: errUnexportedName,
},
// output field missing
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: errMissingParamFromConfig,
},
// output field invalid type
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 string }, api.Err) { return nil, api.ErrSuccess },
Err: errWrongParamTypeFromConfig,
},
// output field valid type
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
// ignore type check on nil type
{
Output: map[string]reflect.Type{
"Test1": nil,
},
Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
}
for i, tcase := range tcases {
t.Run(fmt.Sprintf("case.%d", i), func(t *testing.T) {
t.Parallel()
// mock spec
s := signature{
Input: nil,
Output: tcase.Output,
}
err := s.checkOutput(reflect.TypeOf(tcase.Fn))
if err == nil && tcase.Err != nil {
t.Errorf("expected an error: '%s'", tcase.Err.Error())
t.FailNow()
}
if err != nil && tcase.Err == nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
}
if err != nil && tcase.Err != nil {
if !errors.Is(err, tcase.Err) {
t.Errorf("expected the error <%s> got <%s>", tcase.Err, err)
t.FailNow()
}
}
})
}
}

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

@ -13,29 +13,33 @@ import (
"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,97 +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 {
value, exist := query[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// 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"):
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"):
return i.parseUrlencoded(req)
case strings.HasPrefix(ct, "multipart/form-data; boundary="):
return i.parseMultipart(req)
default:
return nil
}
// parse urlencoded
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
return i.parseUrlencoded(req)
}
// parse multipart
if strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
return i.parseMultipart(req)
}
// 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
}
@ -149,7 +178,8 @@ 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
}
@ -157,19 +187,26 @@ func (i *T) parseUrlencoded(req http.Request) error {
for name, param := range i.service.Form {
value, exist := req.PostForm[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// 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
}
@ -178,37 +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 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
}
@ -220,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

@ -131,7 +131,7 @@ 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) {
@ -242,7 +242,7 @@ func TestExtractQuery(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) {
@ -324,7 +324,7 @@ func TestStoreWithUrlEncodedFormParseError(t *testing.T) {
// defer req.Body.Close()
store := New(nil)
err := store.GetForm(*req)
err := store.ExtractForm(req)
if err == nil {
t.Errorf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data))
t.FailNow()
@ -420,7 +420,7 @@ 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) {
@ -563,7 +563,7 @@ 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) {
@ -720,7 +720,7 @@ 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) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

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)
}