Compare commits

..

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

64 changed files with 1916 additions and 4892 deletions

11
.drone.yml Normal file
View File

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

View File

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

378
README.md
View File

@ -1,83 +1,61 @@
<p align="center"> # | aicra |
<a href="https://github.com/xdrm-io/aicra">
<img src="https://github.com/xdrm-io/aicra/raw/0.4.0/readme.assets/logo.png" alt="aicra logo" width="200" height="200">
</a>
</p>
<h3 align="center">aicra</h3>
<p align="center">
Fast, intuitive, and powerful configuration-driven engine for faster and easier <em>REST</em> development.
</p>
[![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) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go version](https://img.shields.io/badge/go_version-1.16-blue.svg)](https://golang.org/doc/go1.16) [![Go Report Card](https://goreportcard.com/badge/git.xdrm.io/go/aicra)](https://goreportcard.com/report/git.xdrm.io/go/aicra)
[![Go doc](https://pkg.go.dev/badge/github.com/xdrm-io/aicra)](https://pkg.go.dev/github.com/xdrm-io/aicra) [![Go doc](https://godoc.org/git.xdrm.io/go/aicra?status.svg)](https://godoc.org/git.xdrm.io/go/aicra)
[![Go Report Card](https://goreportcard.com/badge/github.com/xdrm-io/aicra)](https://goreportcard.com/report/github.com/xdrm-io/aicra) [![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra)
[![Build status](https://github.com/xdrm-io/aicra/actions/workflows/go.yml/badge.svg)](https://github.com/xdrm-io/aicra/actions/workflows/go.yml)
## Presentation
`aicra` is a lightweight and idiomatic configuration-driven engine for building REST services. It's especially good at helping you write large APIs that remain maintainable as your project grows. **Aicra** is a *configuration-driven* **web framework** written in Go that allows you to create a fully featured REST API.
The focus of the project is to allow you to build a fully-featured REST API in an elegant, comfortable and inexpensive way. This is achieved by using a single configuration file to drive the server. This one file describes your entire API: methods, uris, input data, expected output, permissions, etc. The whole management is done for you from a configuration file describing your API, you're left with implementing :
- handlers
- optionnally middle-wares (_e.g. authentication, csrf_)
- and optionnally your custom type checkers to check input parameters
Repetitive tasks are automatically processed by `aicra` based on your configuration, you're left with implementing your handlers (_usually business logic_).
## Table of contents The aicra server fulfills the `net/http` [Server interface](https://golang.org/pkg/net/http/#Server).
> A example project is available [here](https://git.xdrm.io/go/tiny-url-ex)
### Table of contents
<!-- toc --> <!-- toc -->
- [Installation](#installation) - [I/ Installation](#i-installation)
- [What's automated](#whats-automated) - [II/ Development](#ii-development)
- [Getting started](#getting-started) * [1) Main executable](#1-main-executable)
- [Configuration file](#configuration-file) * [2) API Configuration](#2-api-configuration)
* [Services](#services) - [Definition](#definition)
* [Input and output parameters](#input-and-output-parameters) + [Input Arguments](#input-arguments)
* [Example](#example) - [1. Input types](#1-input-types)
- [Writing your code](#writing-your-code) - [2. Global Format](#2-global-format)
- [Changelog](#changelog) - [III/ Change Log](#iii-change-log)
<!-- tocstop --> <!-- 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 ```bash
$ go get -u github.com/xdrm-io/aicra go get -u git.xdrm.io/go/aicra/cmd/aicra
```
2. Import it in your code:
```go
import "github.com/xdrm-io/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: #### 1) Main executable
- handler signature does not match the configuration
- a configuration service has no handler
- a handler does not match any service
The same applies if your configuration is invalid: Your main executable will declare and run the aicra server, it might look quite like the code below.
- 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`.
```go ```go
package main package main
@ -85,110 +63,106 @@ package main
import ( import (
"log" "log"
"net/http" "net/http"
"os"
"github.com/xdrm-io/aicra" "git.xdrm.io/go/aicra"
"github.com/xdrm-io/aicra/api" "git.xdrm.io/go/aicra/datatype"
"github.com/xdrm-io/aicra/validator/builtin" "git.xdrm.io/go/aicra/datatype/builtin"
) )
func main() { func main() {
builder := &aicra.Builder{}
// add custom type validators // 1. select your datatypes (builtin, custom)
builder.Validate(validator.BoolDataType{}) var dtypes []datatype.T
builder.Validate(validator.UintDataType{}) dtypes = append(dtypes, builtin.AnyDataType{})
builder.Validate(validator.StringDataType{}) dtypes = append(dtypes, builtin.BoolDataType{})
dtypes = append(dtypes, builtin.UintDataType{})
dtypes = append(dtypes, builtin.StringDataType{})
// load your configuration // 2. create the server from the configuration file
config, err := os.Open("api.json") server, err := aicra.New("path/to/your/api/definition.json", dtypes...)
if err != nil { if err != nil {
log.Fatalf("cannot open config: %s", err) log.Fatalf("cannot built aicra server: %s\n", err)
}
err = builder.Setup(config)
config.Close() // free config file
if err != nil {
log.Fatalf("invalid config: %s", err)
} }
// add http middlewares (logger) // 3. bind your implementations
builder.With(func(next http.Handler) http.Handler{ /* ... */ }) server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){
// ... process stuff ...
res.SetError(api.ErrorSuccess());
})
// add contextual middlewares (authentication) // 4. extract to http server
builder.WithContext(func(next http.Handler) http.Handler{ /* ... */ }) httpServer, err := server.ToHTTPServer()
// bind handlers
err = builder.Bind(http.MethodGet, "/user/{id}", getUserById)
if err != nil { if err != nil {
log.Fatalf("cannog bind GET /user/{id}: %s", err) log.Fatalf("cannot get to http server: %s", err)
} }
// build your services // 4. launch server
handler, err := builder.Build() log.Fatal( http.ListenAndServe("localhost:8080", server) )
if err != nil {
log.Fatalf("cannot build handler: %s", err)
}
http.ListenAndServe("localhost:8080", handler)
} }
``` ```
If you want to use HTTPS, you can configure your own `http.Server`.
```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. > - the **URI** variable `{id}` from your request route must be named `{id}`.
> 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 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 ```json
[ [
{ {
@ -197,40 +171,9 @@ Input and output parameters share the same format, featuring:
"scope": [["author"]], "scope": [["author"]],
"info": "updates an article", "info": "updates an article",
"in": { "in": {
"{id}": { "info": "...", "type": "int", "name": "id" }, "{id}": { "info": "article id", "type": "int", "name": "article_id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" }, "GET@title": { "info": "new article title", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" } "content": { "info": "new article content", "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" }
}, },
"out": { "out": {
"id": { "info": "updated article id", "type": "uint" }, "id": { "info": "updated article id", "type": "uint" },
@ -241,99 +184,32 @@ The format of the key of input arguments defines where it comes from:
] ]
``` ```
1. `{id}` is extracted from the end of the URI and is a number compliant with the `int` type checker. It is renamed `ID`, this new name will be sent to the handler.
2. `GET@title` is extracted from the query (_e.g. [http://host/uri?get-var=value](http://host/uri?get-var=value)_). It must be a valid `string` or not given at all (the `?` at the beginning of the type tells that the argument is **optional**) ; it will be named `title`.
3. `content` can be extracted from json, multipart or url-encoded data; it makes no difference and only give clients a choice over the technology to use. If not renamed, the variable will be given to the handler with its original name `content`.
### III/ Change Log
## Writing your code
Besides your main package where you launch your server, you will need to create handlers matching services from the configuration.
The code below implements a simple handler.
```go
// "in": {
// "Input1": { "info": "...", "type": "int" },
// "Input2": { "info": "...", "type": "?string" }
// },
type req struct{
Input1 int
Input2 *string // optional are pointers
}
// "out": {
// "Output1": { "info": "...", "type": "string" },
// "Output2": { "info": "...", "type": "bool" }
// }
type res struct{
Output1 string
Output2 bool
}
func myHandler(ctx context.Context, r req) (*res, api.Err) {
err := doSomething()
if err != nil {
return nil, api.ErrFailure
}
return &res{"out1", true}, api.ErrSuccess
}
```
If your handler signature does not match the configuration exactly, the server will print out the error and won't start.
The `api.Err` type automatically maps to HTTP status codes and error descriptions that will be sent to the client as json; clients have to manage the same format for every response.
```json
{
"error": {
"code": 0,
"reason": "all right"
}
}
```
## Changelog
- [x] human-readable json configuration - [x] human-readable json configuration
- [x] nested routes (*i.e. `/user/{id}` and `/user/post/{id}`*) - [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 URL arguments (*i.e. `/user/:id:` and `/user/:id:/post/:id:`*)
- [x] useful http methods: GET, POST, PUT, DELETE - [x] useful http methods: GET, POST, PUT, DELETE
- [ ] add support for PATCH method - [x] manage URL, query and body arguments:
- [ ] add support for OPTIONS method
- [ ] it might be interesting to generate the list of allowed methods from the configuration
- [ ] add CORS support
- [x] manage request data extraction:
- [x] URL slash-separated strings
- [x] HTTP Query named parameters
- [x] manage array format
- [x] body parameters
- [x] multipart/form-data (variables and file uploads) - [x] multipart/form-data (variables and file uploads)
- [x] application/x-www-form-urlencoded - [x] application/x-www-form-urlencoded
- [x] application/json - [x] application/json
- [x] required vs. optional parameters with a default value - [x] required vs. optional parameters with a default value
- [x] parameter renaming - [x] parameter renaming
- [x] generic type check (*i.e. you can add custom types alongside built-in ones*) - [x] generic type check (*i.e. implement custom types alongside built-in ones*)
- [x] built-in types - [ ] built-in types
- [x] `any` - matches any value - [x] `any` - wildcard matching all values
- [x] `int` - see go types - [x] `int` - see go types
- [x] `uint` - see go types - [x] `uint` - see go types
- [x] `float` - see go types - [x] `float` - see go types
- [x] `string` - any text - [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` - [x] `string(min, max)` - any string with a length between `min` and `max`
- [ ] `[]a` - array containing **only** elements matching `a` type - [ ] `[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*) - [ ] `[a:b]` - map containing **only** keys of type `a` and values of type `b` (*a or b can be ommited*)
- [x] generic handler implementation - [x] generic controllers implementation (shared objects)
- [x] response interface - [x] response interface
- [x] generic errors that automatically formats into response - [x] log bound resources when building the aicra server
- [x] builtin errors - [x] fail on check for unimplemented resources at server boot.
- [x] possibility to add custom errors - [x] fail on check for unavailable types in api.json at server boot.
- [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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,84 +1,94 @@
package api package api
import "net/http"
var ( 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 // It might also be used for debug purposes as this error
// has to be used the less possible // has to be used the less possible
ErrUnknown = Err{-1, "unknown error", http.StatusOK} ErrorUnknown Error = -1
// ErrSuccess represents a generic successful service execution // ErrorSuccess represents a generic successful service execution
ErrSuccess = Err{0, "all right", http.StatusOK} ErrorSuccess Error = 0
// ErrFailure is the most generic error // ErrorFailure is the most generic error
ErrFailure = Err{1, "it failed", http.StatusInternalServerError} ErrorFailure Error = 1
// ErrNoMatchFound is set when trying to fetch data and there is no result // ErrorNoMatchFound has to be set when trying to fetch data and there is no result
ErrNoMatchFound = Err{2, "resource not found", http.StatusOK} 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 // unique fields already exists
ErrAlreadyExists = Err{3, "already exists", http.StatusOK} ErrorAlreadyExists Error = 3
// ErrCreation is set when there is a creation/insert error // ErrorConfig has to be set when there is a configuration error
ErrCreation = Err{4, "create error", http.StatusOK} ErrorConfig Error = 4
// ErrModification is set when there is an update/modification error // ErrorUpload has to be set when a file upload failed
ErrModification = Err{5, "update error", http.StatusOK} ErrorUpload Error = 100
// ErrDeletion is set when there is a deletion/removal error // ErrorDownload has to be set when a file download failed
ErrDeletion = Err{6, "delete error", http.StatusOK} ErrorDownload Error = 101
// ErrTransaction is set when there is a transactional error // MissingDownloadHeaders has to be set when the implementation
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
// of a service of type 'download' (which returns a file instead of // of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its HEADER field // 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 // of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its BODY field // 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. // 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 // 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 // ErrorNotImplemented is set when a handler is not implemented yet
ErrNotImplemented = Err{203, "not implemented", http.StatusNotImplemented} 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 // the api returns a permission error when the current scope (built
// by middlewares) does not match the scope required in the config. // by middlewares) does not match the scope required in the config.
// You can add your own permission policy and use this error // 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 // 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 // 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. // 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. // 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 package api
import ( import (
"encoding/json"
"fmt" "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* // These are used by the services to set the *execution status*
// directly into the response as JSON alongside response output fields. // directly into the response as JSON alongside response output fields.
type Err struct { type Error int
// error code (unique)
Code int `json:"code"` // Error implements the error interface
// error small description func (e Error) Error() string {
Reason string `json:"reason"` // use unknown error if no reason
// associated HTTP status reason, ok := errorReasons[e]
Status int `json:"-"` if !ok {
return ErrorUnknown.Error()
} }
func (e Err) Error() string { return fmt.Sprintf("[%d] %s", e, reason)
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
}
}

77
api/response.go Normal file
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

15
datatype/types.go Normal file
View File

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

48
dynamic/errors.go Normal file
View File

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

90
dynamic/handler.go Normal file
View File

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

119
dynamic/spec.go Normal file
View File

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

17
dynamic/types.go Normal file
View File

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

View File

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

4
go.mod
View File

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

View File

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

File diff suppressed because it is too large Load Diff

116
http.go Normal file
View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -196,8 +196,8 @@ func TestNoName(t *testing.T) {
return return
} }
if err = mpr.Parse(); err != errMissingDataName { if err = mpr.Parse(); err != ErrMissingDataName {
t.Errorf("expected the error <%s>, got <%s>", errMissingDataName, err) t.Errorf("expected the error <%s>, got <%s>", ErrMissingDataName, err)
return return
} }
}) })
@ -238,8 +238,8 @@ func TestNoHeader(t *testing.T) {
return return
} }
if err = mpr.Parse(); err != errNoHeader { if err = mpr.Parse(); err != ErrNoHeader {
t.Errorf("expected the error <%s>, got <%s>", errNoHeader, err) t.Errorf("expected the error <%s>, got <%s>", ErrNoHeader, err)
return return
} }
}) })
@ -274,8 +274,8 @@ facebook.com
t.Fatalf("unexpected error <%s>", err) t.Fatalf("unexpected error <%s>", err)
} }
if err = mpr.Parse(); err != errDataNameConflict { if err = mpr.Parse(); err != ErrDataNameConflict {
t.Fatalf("expected the error <%s>, got <%s>", errDataNameConflict, err) 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. // cerr allows you to create constant "const" error with type boxing.
type cerr string type cerr string
// Error implements the error builtin interface.
func (err cerr) Error() string { func (err cerr) Error() string {
return string(err) return string(err)
} }
// errMissingDataName is set when a multipart variable/file has no name="..." // ErrMissingDataName is set when a multipart variable/file has no name="..."
const errMissingDataName = cerr("data has no name") const ErrMissingDataName = cerr("data has no name")
// errDataNameConflict is set when a multipart variable/file name is already used // ErrDataNameConflict is set when a multipart variable/file name is already used
const errDataNameConflict = cerr("data name conflict") const ErrDataNameConflict = cerr("data name conflict")
// errNoHeader is set when a multipart variable/file has no (valid) header // ErrNoHeader is set when a multipart variable/file has no (valid) header
const errNoHeader = cerr("data has no header") const ErrNoHeader = cerr("data has no header")
// Component represents a multipart variable/file // Component represents a multipart variable/file
type Component struct { type Component struct {

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

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

View File

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

91
server.go Normal file
View File

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

15
util.go Normal file
View File

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

View File

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

View File

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