Compare commits

...

71 Commits

Author SHA1 Message Date
Adrien Marquès 418631e09d Merge branch 'feature/improve-readme' into 0.3.0
continuous-integration/drone/push Build is passing Details
2021-06-19 00:25:01 +02:00
Adrien Marquès 2d87052dda
fix: typos
continuous-integration/drone/push Build is passing Details
2021-06-19 00:24:26 +02:00
Adrien Marquès 610ab66ea8
readme: add logo, improve structure and explanations
continuous-integration/drone/push Build is passing Details
2021-06-19 00:17:37 +02:00
Adrien Marquès 3563d53365 Merge pull request 'feature: dynamic scope using input arguments' (#23) from feature/dynamic-scope into 0.3.0
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
Reviewed-on: #23
2021-05-19 12:06:19 +00:00
xdrm-brackets c35e2fdd9a
fix: do not use optional (nil) inputs for dynamic scope
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-18 17:45:07 +02:00
xdrm-brackets 8c2ebd916e
feat: add test coverage
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-18 16:30:20 +02:00
xdrm-brackets 2a17ba2f72
fix: allow non-Stringer using %v format (unsafe but does the job) 2021-05-18 16:24:31 +02:00
xdrm-brackets 346cc4e557
feat: add dynamic scope from request's input
- all occurences of '[abc]' where 'abc' is a valid input name ('name' field from json) is replaced with its value between square brackets
2021-05-18 16:06:49 +02:00
Adrien Marquès 214e2348aa Merge pull request 'feature: authentication middlewares' (#20) from feature/expose-scope into 0.3.0
continuous-integration/drone/push Build is passing Details
Reviewed-on: #20
2021-05-18 13:57:56 +00:00
Adrien Marquès 976b13bd38 Merge pull request 'fix: remove error status from json' (#22) from fix/json-err-status into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Reviewed-on: #22
2021-05-18 09:10:53 +00:00
xdrm-brackets 3bb02fcbb7
fix: remove error status from json
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-18 11:10:12 +02:00
xdrm-brackets 4a62df8029
feat: handle auth adapters
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-18 09:59:49 +02:00
xdrm-brackets 4f55302e8a
feat: add WithAuth() to builder using api.AuthAdapter interface 2021-05-18 09:36:33 +02:00
xdrm-brackets 18d809c4ca
feat: create api.Auth wrapping authorization management 2021-05-18 09:34:01 +02:00
Adrien Marquès e3d24ae1ef Merge pull request 'feature: add optional context to handlers' (#19) from feature/context into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
An optional first input argument of type api.Ctx to handlers to access standard request/response
2021-05-10 14:42:57 +00:00
Adrien Marquès af106acd3f refactor: test: dynamic function handler
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-04-19 23:34:31 +02:00
Adrien Marquès b88a4439c8 fixup: update comment for optional api.Ctx 2021-04-19 22:15:34 +02:00
Adrien Marquès e44dab4bc9 fixup: remove HasContext from spec 2021-04-19 19:55:00 +02:00
Adrien Marquès 1245861be7 test: builder
continuous-integration/drone/push Build is passing Details
2021-04-19 18:46:18 +02:00
Adrien Marquès d6f8457274 feat: pass optional context argument to handlers
continuous-integration/drone/push Build is passing Details
2021-04-18 19:31:54 +02:00
Adrien Marquès 939ab2e57d fixup: expose api context fields 2021-04-18 19:31:40 +02:00
Adrien Marquès 0a55c2ee13 feat: add optional api.Ctx first argument to handler checker 2021-04-18 19:25:31 +02:00
Adrien Marquès 24be7c294e test: dynamic func input 2021-04-18 18:26:37 +02:00
Adrien Marquès f334d19ef4 feat: add api context type 2021-04-18 18:14:30 +02:00
Adrien Marquès 08b825b38f Merge pull request 'feature: add http middleware capability' (#18) from feature/middleware into 0.3.0
continuous-integration/drone/push Build is passing Details
2021-04-18 16:08:10 +00:00
Adrien Marquès a693bbbf9b fix: reflect assign value to pointer of value in call argument
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-04-18 17:55:48 +02:00
Adrien Marquès 3986f7a022 fix: remove recoverer and body closer; must be users' responsability 2021-04-18 17:53:53 +02:00
Adrien Marquès 14ae59561c feat: encapsulate request handling into adapters 2021-04-18 16:50:02 +02:00
Adrien Marquès 96164127e1 feat: add Use() method to add adapters to the aicra builder 2021-04-18 16:49:46 +02:00
Adrien Marquès 87c15b91e5 feat: add middleware (Adapter) type 2021-04-18 16:49:24 +02:00
Adrien Marquès f3127edde1 Merge pull request 'improvements, fixes, update to go 1.16' (#16) from refactor/go1.16 into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2021-03-28 17:44:58 +00:00
Adrien Marquès 546130cfd0
update: readme
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-03-28 19:41:25 +02:00
Adrien Marquès 11aa9f0a0f
fix: global handler recoverer 2021-03-28 19:05:43 +02:00
Adrien Marquès 468a09be8d
update: rename 'Server' into 'Handler' 2021-03-28 19:03:16 +02:00
Adrien Marquès 10e59acdae
fix: test string-int concatenation warnings 2021-03-28 18:50:25 +02:00
Adrien Marquès 334f1fba21
feat: add builder helpers Get(), Post(), Put(), Delete() that proxies to Bind() 2021-03-28 18:50:04 +02:00
Adrien Marquès 6039fbb41f
update: api.Err system
- rename 'Error' to 'Err'
 - use struct instead of int as underlying type ; remove dependency on 2 maps for reason and HTTP status codes
 - remove useless json implementation
2021-03-28 18:49:23 +02:00
Adrien Marquès a9acfca089
update go.mod to go 1.16 2021-03-28 18:06:09 +02:00
Adrien Marquès fb69dbb903 Merge branch 'refactor-test' of go/aicra into 0.3.0
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-04-04 15:33:43 +00:00
Adrien Marquès 658c66d2db
update readme
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-04-04 17:33:08 +02:00
Adrien Marquès 3c453e7f89
remove api useless request, update default errors and bind status codes to errors
continuous-integration/drone/push Build is passing Details
2020-04-04 16:03:50 +02:00
Adrien Marquès d198086dd4
fix http error handlers 2020-04-04 16:03:12 +02:00
Adrien Marquès 30862195a1
config: refactor, simplify, test, remove redundant comments 2020-04-04 15:39:00 +02:00
Adrien Marquès 990bb86919
rework reqdata api and remove redundant comments 2020-04-04 14:56:15 +02:00
Adrien Marquès 35ede5e266
unexport config errors 2020-04-04 14:34:20 +02:00
Adrien Marquès 90472b8bf7
unexport dynfunc errors 2020-04-04 12:46:43 +02:00
Adrien Marquès df56496a16
dynfunc: normalize file names 2020-04-04 12:45:36 +02:00
Adrien Marquès caa57889b4
multipart: rename files and unexport errors 2020-04-04 12:43:55 +02:00
Adrien Marquès 4ba62e19c7
remove func.go and standardize main file name 2020-04-04 12:42:18 +02:00
Adrien Marquès 5cadfcf78b
unexport aicra errors 2020-04-04 12:40:21 +02:00
Adrien Marquès e0ea0c97c5
clarify datatype comments and standardize file name 2020-04-04 12:40:01 +02:00
Adrien Marquès 6319761731 Merge branch 'test/dynamic' of go/aicra into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
test dynfunc package; standardize and refactor api
2020-04-04 10:09:19 +00:00
Adrien Marquès 92da498d49
remove server logs and util file
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-04-04 12:06:31 +02:00
Adrien Marquès 60ef4717a8
clarity: aicra server request management 2020-04-04 12:05:17 +02:00
Adrien Marquès 5cc3d2d455
use http.Request instead of pointer 2020-04-04 12:03:29 +02:00
Adrien Marquès c5cdba8007
move aicra builder and server into their own files 2020-04-04 11:50:01 +02:00
Adrien Marquès 09362aad83
make 'dynfunc' internal 2020-04-04 11:49:33 +02:00
Adrien Marquès d69dd2508c
refactor aicra: meaningful defaults, stage renaming Builder.Build() -> Server 2020-04-04 11:46:37 +02:00
Adrien Marquès 1e0fb77d61
standardize and simplify the config package 2020-04-04 11:45:49 +02:00
Adrien Marquès b0e25b431c
ToHTTPServer now returns the exported field http.Handler instead of an unexported type 2020-04-04 10:39:02 +02:00
Adrien Marquès b1498e59c1
clarity rename: dynamic package to dynfunc 2020-04-04 10:36:52 +02:00
Adrien Marquès eb690cf862
add api errors for storage 2020-04-04 10:10:24 +02:00
Adrien Marquès e1606273dd
remove useless func type 2020-04-04 10:10:24 +02:00
Adrien Marquès 8fa18cd61b
enforce dynamic signature check: no input struct allowed when no input is specified 2020-04-04 10:02:48 +02:00
Adrien Marquès db4429b329
ignore empty param renames when creating the spec, not after 2020-03-29 19:33:26 +02:00
Adrien Marquès b48c1d07bf
test: spec add checkOutput() tests for : nil type (ignore type check) ; invalid last output (not api.Error)
continuous-integration/drone/push Build is passing Details
2020-03-29 19:31:08 +02:00
Adrien Marquès 307021bc88
test: spec checkOutput() method
continuous-integration/drone/push Build is passing Details
2020-03-29 19:23:13 +02:00
Adrien Marquès 261e25c127
fix: invert conversion check 2020-03-29 19:23:02 +02:00
Adrien Marquès 438e308f71
merge duplicate errors 2020-03-29 19:22:43 +02:00
Adrien Marquès 7e42c1b6d9
test: spec checkInput() method 2020-03-29 19:14:12 +02:00
Adrien Marquès 66985dfbd0
forbid unexported input/output name 2020-03-29 19:13:07 +02:00
46 changed files with 3088 additions and 1530 deletions

365
README.md
View File

@ -1,61 +1,79 @@
# | aicra |
<p align="center">
<a href="https://git.xdrm.io/go/aicra">
<img src="https://git.xdrm.io/go/aicra/raw/branch/feature/improve-readme/readme.assets/logo.png" alt="aicra logo" width="200" height="200">
</a>
</p>
[![Go version](https://img.shields.io/badge/go_version-1.10.3-blue.svg)](https://golang.org/doc/go1.10)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Report Card](https://goreportcard.com/badge/git.xdrm.io/go/aicra)](https://goreportcard.com/report/git.xdrm.io/go/aicra)
[![Go doc](https://godoc.org/git.xdrm.io/go/aicra?status.svg)](https://godoc.org/git.xdrm.io/go/aicra)
[![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra)
<h3 align="center">aicra</h3>
<p align="center">
Fast, intuitive, and powerful configuration-driven engine for faster and easier <em>REST</em> development.
</p>
**Aicra** is a *configuration-driven* **web framework** written in Go that allows you to create a fully featured REST API.
[![Go version](https://img.shields.io/badge/go_version-1.16-blue.svg)](https://golang.org/doc/go1.16) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Go Report Card](https://goreportcard.com/badge/git.xdrm.io/go/aicra)](https://goreportcard.com/report/git.xdrm.io/go/aicra) [![Go doc](https://godoc.org/git.xdrm.io/go/aicra?status.svg)](https://godoc.org/git.xdrm.io/go/aicra) [![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra)
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
## Presentation
`aicra` is a lightweight and idiomatic configuration-driven engine for building REST services. It's especially good at helping you write large APIs that remain maintainable as your project grows.
The aicra server fulfills the `net/http` [Server interface](https://golang.org/pkg/net/http/#Server).
The focus of the project is to allow you to build a fully-featured REST API in an elegant, comfortable and inexpensive way. This is achieved by using a single configuration file to drive the server. This one file describes your entire API: methods, uris, input data, expected output, permissions, etc.
Repetitive tasks are automatically processed by `aicra` based on your configuration, you're left with implementing your handlers (_usually business logic_).
> A example project is available [here](https://git.xdrm.io/go/tiny-url-ex)
### Table of contents
## Table of contents
<!-- toc -->
- [I/ Installation](#i-installation)
- [II/ Development](#ii-development)
* [1) Main executable](#1-main-executable)
* [2) API Configuration](#2-api-configuration)
- [Definition](#definition)
+ [Input Arguments](#input-arguments)
- [1. Input types](#1-input-types)
- [2. Global Format](#2-global-format)
- [III/ Change Log](#iii-change-log)
- [Installation](#installation)
- [What's automated](#whats-automated)
- [Getting started](#getting-started)
- [Configuration file](#configuration-file)
* [Services](#services)
* [Input and output parameters](#input-and-output-parameters)
* [Example](#example)
- [Writing your code](#writing-your-code)
- [Changelog](#changelog)
<!-- tocstop -->
### 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**.
## Installation
To install the aicra package, you need to install Go and set your Go workspace first.
> not tested under Go 1.14
1. you can use the below Go command to install aicra.
```bash
go get -u git.xdrm.io/go/aicra/cmd/aicra
$ go get -u git.xdrm.io/go/aicra
```
2. Import it in your code:
```go
import "git.xdrm.io/go/aicra"
```
The library should now be available as `git.xdrm.io/go/aicra` in your imports.
## What's automated
As the configuration file is here to make your life easier, let's take a quick look at what you do not have to do ; or in other words, what does `aicra` automates.
### II/ Development
Http requests are only accepted when they have the permissions you have defined. If unauthorized, the request is rejected with an error response.
Request data is automatically extracted and validated before it reaches your code. If a request has missing or invalid data an automatic error response is sent.
#### 1) Main executable
When launching the server, it ensures everything is ok and won't start until fixed. You will get errors for:
- handler signature does not match the configuration
- a configuration service has no handler
- a handler does not match any service
Your main executable will declare and run the aicra server, it might look quite like the code below.
The same applies if your configuration is invalid:
- unknown HTTP method
- invalid uri
- uri collision between 2 services
- missing fields
- unknown data type
- input name collision
## Getting started
Here is the minimal code to launch your aicra server assuming your configuration file is `api.json`.
```go
package main
@ -63,106 +81,105 @@ package main
import (
"log"
"net/http"
"os"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func main() {
builder := &aicra.Builder{}
// 1. select your datatypes (builtin, custom)
var dtypes []datatype.T
dtypes = append(dtypes, builtin.AnyDataType{})
dtypes = append(dtypes, builtin.BoolDataType{})
dtypes = append(dtypes, builtin.UintDataType{})
dtypes = append(dtypes, builtin.StringDataType{})
// register data validators
builder.AddType(builtin.BoolDataType{})
builder.AddType(builtin.UintDataType{})
builder.AddType(builtin.StringDataType{})
// 2. create the server from the configuration file
server, err := aicra.New("path/to/your/api/definition.json", dtypes...)
// load your configuration
config, err := os.Open("api.json")
if err != nil {
log.Fatalf("cannot built aicra server: %s\n", err)
log.Fatalf("cannot open config: %s", err)
}
err = builder.Setup(config)
config.Close() // free config file
if err != nil {
log.Fatalf("invalid config: %s", err)
}
// 3. bind your implementations
server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){
// ... process stuff ...
res.SetError(api.ErrorSuccess());
})
// 4. extract to http server
httpServer, err := server.ToHTTPServer()
// bind handlers
err = builder.Bind(http.MethodGet, "/user/{id}", getUserById)
if err != nil {
log.Fatalf("cannot get to http server: %s", err)
log.Fatalf("cannog bind GET /user/{id}: %s", err)
}
// ...
// 4. launch server
log.Fatal( http.ListenAndServe("localhost:8080", server) )
// build your services
handler, err := builder.Build()
if err != nil {
log.Fatalf("cannot build handler: %s", err)
}
http.ListenAndServe("localhost:8080", handler)
}
```
If you want to use HTTPS, you can configure your own `http.Server`.
```go
func main() {
server := &http.Server{
Addr: "localhost:8080",
TLSConfig: tls.Config{},
// ...
Handler: AICRAHandler,
}
server.ListenAndServe()
}
```
## Configuration file
#### 2) API Configuration
First of all, the configuration uses `json`.
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 :
> Quick note if you thought: "I hate JSON, I would have preferred yaml, or even xml !"
>
> - the **URI** variable `{id}` from your request route must be named `{id}`.
> - the variable `somevar` in the **Query** has to be names `GET@somevar`.
> I've had a hard time deciding and testing different formats including yaml and xml.
> But as it describes our entire api and is crucial for our server to keep working over updates; xml would have been too verbose with growth and yaml on the other side would have been too difficult to read. Json sits in the right spot for this.
**Example**
Let's take a quick look at the configuration format !
In this example we want 3 arguments :
> 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)
- 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`.
### Services
To begin with, the configuration file defines a list of services. Each one is defined by:
- `method` an HTTP method
- `path` an uri pattern (can contain variables)
- `info` a short description of what it does
- `scope` a list of the required permissions
- `in` a list of input arguments
- `out` a list of output arguments
```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
[
{
@ -171,9 +188,40 @@ In this example we want 3 arguments :
"scope": [["author"]],
"info": "updates an article",
"in": {
"{id}": { "info": "article id", "type": "int", "name": "article_id" },
"GET@title": { "info": "new article title", "type": "?string", "name": "title" },
"content": { "info": "new article content", "type": "string" }
"{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" }
},
"out": {
"title": { "info": "updated article title", "type": "string" },
"content": { "info": "updated article content", "type": "string" }
}
}
]
```
If a parameter is optional you just have to prefix its type with a question mark, by default all parameters are mandatory.
The format of the key of input arguments defines where it comes from:
1. `{param}` is an URI parameter that is extracted from the `"path"`
2. `GET@param` is an URL parameter that is extracted from the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
3. `param` is a body parameter that can be extracted from 3 formats independently:
- _url encoded_: data send in the body following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- _multipart_: data send in the body with a dedicated [format](https://tools.ietf.org/html/rfc2388#section-3). This format can be quite heavy but allows to transmit data as well as files.
- _JSON_: data sent in the body as a json object ; The _Content-Type_ header must be `application/json` for it to work.
### Example
```json
[
{
"method": "PUT",
"path": "/article/{id}",
"scope": [["author"]],
"info": "updates an article",
"in": {
"{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" }
},
"out": {
"id": { "info": "updated article id", "type": "uint" },
@ -184,32 +232,99 @@ In this example we want 3 arguments :
]
```
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(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] nested routes (*i.e. `/user/:id:` and `/user/post/:id:`*)
- [x] nested URL arguments (*i.e. `/user/:id:` and `/user/:id:/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] useful http methods: GET, POST, PUT, DELETE
- [x] manage URL, query and body arguments:
- [ ] add support for PATCH method
- [ ] add support for OPTIONS method
- [ ] it might be interesting to generate the list of allowed methods from the configuration
- [ ] add CORS support
- [x] manage request data extraction:
- [x] URL slash-separated strings
- [x] HTTP Query named parameters
- [x] manage array format
- [x] body parameters
- [x] multipart/form-data (variables and file uploads)
- [x] application/x-www-form-urlencoded
- [x] application/json
- [x] required vs. optional parameters with a default value
- [x] parameter renaming
- [x] generic type check (*i.e. implement custom types alongside built-in ones*)
- [ ] built-in types
- [x] `any` - wildcard matching all values
- [x] generic type check (*i.e. you can add custom types alongside built-in ones*)
- [x] built-in types
- [x] `any` - matches any value
- [x] `int` - see go types
- [x] `uint` - see go types
- [x] `float` - see go types
- [x] `string` - any text
- [x] `string(len)` - any string with a length of exactly `len` characters
- [x] `string(min, max)` - any string with a length between `min` and `max`
- [ ] `[a]` - array containing **only** elements matching `a` type
- [ ] `[a:b]` - map containing **only** keys of type `a` and values of type `b` (*a or b can be ommited*)
- [x] generic controllers implementation (shared objects)
- [ ] `[]a` - array containing **only** elements matching `a` type
- [ ] `a[b]` - map containing **only** keys of type `a` and values of type `b` (*a or b can be ommited*)
- [x] generic handler implementation
- [x] response interface
- [x] log bound resources when building the aicra server
- [x] fail on check for unimplemented resources at server boot.
- [x] fail on check for unavailable types in api.json at server boot.
- [x] generic errors that automatically formats into response
- [x] builtin errors
- [x] possibility to add custom errors
- [x] check for missing handlers when building the handler
- [x] check handlers not matching a route in the configuration at server boot
- [x] specific configuration format errors qt server boot
- [x] statically typed handlers - avoids having to check every input and its type (_which is used by context.Context for instance_)
- [x] using reflection to use structs as input and output arguments to match the configuration
- [x] check for input and output arguments structs at server boot
- [x] check for unavailable types in configuration at server boot
- [x] recover panics from handlers
- [ ] improve tests and coverage

13
api/adapter.go Normal file
View File

@ -0,0 +1,13 @@
package api
import "net/http"
// Adapter to encapsulate incoming requests
type Adapter func(http.HandlerFunc) http.HandlerFunc
// AuthHandlerFunc is http.HandlerFunc with additional Authorization information
type AuthHandlerFunc func(Auth, http.ResponseWriter, *http.Request)
// AuthAdapter to encapsulate incoming request with access to api.Auth
// to manage permissions
type AuthAdapter func(AuthHandlerFunc) AuthHandlerFunc

62
api/auth.go Normal file
View File

@ -0,0 +1,62 @@
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
}

108
api/auth_test.go Normal file
View File

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

17
api/context.go Normal file
View File

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

View File

@ -1,94 +1,84 @@
package api
import "net/http"
var (
// ErrorUnknown represents any error which cause is unknown.
// ErrUnknown represents any error which cause is unknown.
// It might also be used for debug purposes as this error
// has to be used the less possible
ErrorUnknown Error = -1
ErrUnknown = Err{-1, "unknown error", http.StatusOK}
// ErrorSuccess represents a generic successful service execution
ErrorSuccess Error = 0
// ErrSuccess represents a generic successful service execution
ErrSuccess = Err{0, "all right", http.StatusOK}
// ErrorFailure is the most generic error
ErrorFailure Error = 1
// ErrFailure is the most generic error
ErrFailure = Err{1, "it failed", http.StatusInternalServerError}
// ErrorNoMatchFound has to be set when trying to fetch data and there is no result
ErrorNoMatchFound Error = 2
// ErrNoMatchFound is set when trying to fetch data and there is no result
ErrNoMatchFound = Err{2, "resource not found", http.StatusOK}
// ErrorAlreadyExists has to be set when trying to insert data, but identifiers or
// ErrAlreadyExists is set when trying to insert data, but identifiers or
// unique fields already exists
ErrorAlreadyExists Error = 3
ErrAlreadyExists = Err{3, "already exists", http.StatusOK}
// ErrorConfig has to be set when there is a configuration error
ErrorConfig Error = 4
// ErrCreation is set when there is a creation/insert error
ErrCreation = Err{4, "create error", http.StatusOK}
// ErrorUpload has to be set when a file upload failed
ErrorUpload Error = 100
// ErrModification is set when there is an update/modification error
ErrModification = Err{5, "update error", http.StatusOK}
// ErrorDownload has to be set when a file download failed
ErrorDownload Error = 101
// ErrDeletion is set when there is a deletion/removal error
ErrDeletion = Err{6, "delete error", http.StatusOK}
// MissingDownloadHeaders has to be set when the implementation
// ErrTransaction is set when there is a transactional error
ErrTransaction = Err{7, "transactional error", http.StatusOK}
// ErrUpload is set when a file upload failed
ErrUpload = Err{100, "upload failed", http.StatusInternalServerError}
// ErrDownload is set when a file download failed
ErrDownload = Err{101, "download failed", http.StatusInternalServerError}
// MissingDownloadHeaders is set when the implementation
// of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its HEADER field
MissingDownloadHeaders Error = 102
MissingDownloadHeaders = Err{102, "download headers are missing", http.StatusBadRequest}
// ErrorMissingDownloadBody has to be set when the implementation
// ErrMissingDownloadBody is set when the implementation
// of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its BODY field
ErrorMissingDownloadBody Error = 103
ErrMissingDownloadBody = Err{103, "download body is missing", http.StatusBadRequest}
// ErrorUnknownService is set when there is no service matching
// ErrUnknownService is set when there is no service matching
// the http request URI.
ErrorUnknownService Error = 200
ErrUnknownService = Err{200, "unknown service", http.StatusServiceUnavailable}
// ErrorUncallableService is set when there the requested service's
// ErrUncallableService is set when there the requested service's
// implementation (plugin file) is not found/callable
ErrorUncallableService Error = 202
ErrUncallableService = Err{202, "uncallable service", http.StatusServiceUnavailable}
// ErrorNotImplemented is set when a handler is not implemented yet
ErrorNotImplemented Error = 203
// ErrNotImplemented is set when a handler is not implemented yet
ErrNotImplemented = Err{203, "not implemented", http.StatusNotImplemented}
// ErrorPermission is set when there is a permission error by default
// ErrPermission is set when there is a permission error by default
// the api returns a permission error when the current scope (built
// by middlewares) does not match the scope required in the config.
// You can add your own permission policy and use this error
ErrorPermission Error = 300
ErrPermission = Err{300, "permission error", http.StatusUnauthorized}
// ErrorToken has to be set (usually in authentication middleware) to tell
// ErrToken is set (usually in authentication middleware) to tell
// the user that this authentication token is expired or invalid
ErrorToken Error = 301
ErrToken = Err{301, "token error", http.StatusForbidden}
// ErrorMissingParam is set when a *required* parameter is missing from the
// ErrMissingParam is set when a *required* parameter is missing from the
// http request
ErrorMissingParam Error = 400
ErrMissingParam = Err{400, "missing parameter", http.StatusBadRequest}
// ErrorInvalidParam is set when a given parameter fails its type check as
// ErrInvalidParam is set when a given parameter fails its type check as
// defined in the config file.
ErrorInvalidParam Error = 401
ErrInvalidParam = Err{401, "invalid parameter", http.StatusBadRequest}
// ErrorInvalidDefaultParam is set when an optional parameter's default value
// ErrInvalidDefaultParam is set when an optional parameter's default value
// does not match its type.
ErrorInvalidDefaultParam Error = 402
ErrInvalidDefaultParam = Err{402, "invalid default param", http.StatusBadRequest}
)
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,42 +1,21 @@
package api
import (
"encoding/json"
"fmt"
)
// Error represents an http response error following the api format.
// Err represents an http response error following the api format.
// These are used by the services to set the *execution status*
// directly into the response as JSON alongside response output fields.
type Error int
// Error implements the error interface
func (e Error) Error() string {
// use unknown error if no reason
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.Error()
}
return fmt.Sprintf("[%d] %s", e, reason)
}
// 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 {
type Err struct {
// error code (unique)
Code int `json:"code"`
// error small description
Reason string `json:"reason"`
}{
Code: int(e),
Reason: reason,
}
return json.Marshal(formatted)
// associated HTTP status
Status int `json:"-"`
}
func (e Err) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Reason)
}

View File

@ -1,59 +0,0 @@
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
}

View File

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

View File

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

148
builder.go Normal file
View File

@ -0,0 +1,148 @@
package aicra
import (
"fmt"
"io"
"net/http"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/dynfunc"
)
// Builder for an aicra server
type Builder struct {
conf *config.Server
handlers []*apiHandler
adapters []api.Adapter
authAdapters []api.AuthAdapter
}
// represents an api handler (method-pattern combination)
type apiHandler struct {
Method string
Path string
dyn *dynfunc.Handler
}
// AddType adds an available datatype to the api definition
func (b *Builder) AddType(t datatype.T) error {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
return errLateType
}
if b.conf.Types == nil {
b.conf.Types = make([]datatype.T, 0)
}
b.conf.Types = append(b.conf.Types, t)
return nil
}
// With adds an http adapter (middleware)
func (b *Builder) With(adapter api.Adapter) {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.adapters == nil {
b.adapters = make([]api.Adapter, 0)
}
b.adapters = append(b.adapters, adapter)
}
// WithAuth adds an http adapter with auth capabilities (middleware)
func (b *Builder) WithAuth(adapter api.AuthAdapter) {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.authAdapters == nil {
b.authAdapters = make([]api.AuthAdapter, 0)
}
b.authAdapters = append(b.authAdapters, adapter)
}
// Setup the builder with its api definition file
// panics if already setup
func (b *Builder) Setup(r io.Reader) error {
if b.conf == nil {
b.conf = &config.Server{}
}
if b.conf.Services != nil {
panic(errAlreadySetup)
}
return b.conf.Parse(r)
}
// Bind a dynamic handler to a REST service (method and pattern)
func (b *Builder) Bind(method, path string, fn interface{}) error {
if b.conf.Services == nil {
return errNotSetup
}
// find associated service from config
var service *config.Service
for _, s := range b.conf.Services {
if method == s.Method && path == s.Pattern {
service = s
break
}
}
if service == nil {
return fmt.Errorf("%s '%s': %w", method, path, errUnknownService)
}
var dyn, err = dynfunc.Build(fn, *service)
if err != nil {
return fmt.Errorf("%s '%s' handler: %w", method, path, err)
}
b.handlers = append(b.handlers, &apiHandler{
Path: path,
Method: method,
dyn: dyn,
})
return nil
}
// Get is equivalent to Bind(http.MethodGet)
func (b *Builder) Get(path string, fn interface{}) error {
return b.Bind(http.MethodGet, path, fn)
}
// Post is equivalent to Bind(http.MethodPost)
func (b *Builder) Post(path string, fn interface{}) error {
return b.Bind(http.MethodPost, path, fn)
}
// Put is equivalent to Bind(http.MethodPut)
func (b *Builder) Put(path string, fn interface{}) error {
return b.Bind(http.MethodPut, path, fn)
}
// Delete is equivalent to Bind(http.MethodDelete)
func (b *Builder) Delete(path string, fn interface{}) error {
return b.Bind(http.MethodDelete, path, fn)
}
// Build a fully-featured HTTP server
func (b Builder) Build() (http.Handler, error) {
for _, service := range b.conf.Services {
var isHandled bool
for _, handler := range b.handlers {
if handler.Method == service.Method && handler.Path == service.Pattern {
isHandled = true
break
}
}
if !isHandled {
return nil, fmt.Errorf("%s '%s': %w", service.Method, service.Pattern, errMissingHandler)
}
}
return Handler(b), nil
}

262
builder_test.go Normal file
View File

@ -0,0 +1,262 @@
package aicra
import (
"errors"
"net/http"
"strings"
"testing"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func addBuiltinTypes(b *Builder) error {
if err := b.AddType(builtin.AnyDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.BoolDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.FloatDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.IntDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.StringDataType{}); err != nil {
return err
}
if err := b.AddType(builtin.UintDataType{}); err != nil {
return err
}
return nil
}
func TestAddType(t *testing.T) {
builder := &Builder{}
err := builder.AddType(builtin.BoolDataType{})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.Setup(strings.NewReader("[]"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
err = builder.AddType(builtin.FloatDataType{})
if err != errLateType {
t.Fatalf("expected <%v> got <%v>", errLateType, err)
}
}
func TestBind(t *testing.T) {
tcases := []struct {
Name string
Config string
HandlerMethod string
HandlerPath string
HandlerFn interface{} // not bound if nil
BindErr error
BuildErr error
}{
{
Name: "none required none provided",
Config: "[]",
HandlerMethod: "",
HandlerPath: "",
HandlerFn: nil,
BindErr: nil,
BuildErr: nil,
},
{
Name: "none required 1 provided",
Config: "[]",
HandlerMethod: "",
HandlerPath: "",
HandlerFn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: nil,
},
{
Name: "1 required none provided",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`,
HandlerMethod: "",
HandlerPath: "",
HandlerFn: nil,
BindErr: nil,
BuildErr: errMissingHandler,
},
{
Name: "1 required wrong method provided",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`,
HandlerMethod: http.MethodPost,
HandlerPath: "/path",
HandlerFn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: errMissingHandler,
},
{
Name: "1 required wrong path provided",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/paths",
HandlerFn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: errUnknownService,
BuildErr: errMissingHandler,
},
{
Name: "1 required valid provided",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
{
Name: "1 required with int",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {
"id": { "info": "info", "type": "int", "name": "Name" }
},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(struct{ Name int }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
{
Name: "1 required with uint",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {
"id": { "info": "info", "type": "uint", "name": "Name" }
},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(struct{ Name uint }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
{
Name: "1 required with string",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {
"id": { "info": "info", "type": "string", "name": "Name" }
},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(struct{ Name string }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
{
Name: "1 required with bool",
Config: `[
{
"method": "GET",
"path": "/path",
"scope": [[]],
"info": "info",
"in": {
"id": { "info": "info", "type": "bool", "name": "Name" }
},
"out": {}
}
]`,
HandlerMethod: http.MethodGet,
HandlerPath: "/path",
HandlerFn: func(struct{ Name bool }) (*struct{}, api.Err) { return nil, api.ErrSuccess },
BindErr: nil,
BuildErr: nil,
},
}
for _, tcase := range tcases {
t.Run(tcase.Name, func(t *testing.T) {
t.Parallel()
builder := &Builder{}
if err := addBuiltinTypes(builder); err != nil {
t.Fatalf("add built-in types: %s", err)
}
err := builder.Setup(strings.NewReader(tcase.Config))
if err != nil {
t.Fatalf("setup: unexpected error <%v>", err)
}
if tcase.HandlerFn != nil {
err := builder.Bind(tcase.HandlerMethod, tcase.HandlerPath, tcase.HandlerFn)
if !errors.Is(err, tcase.BindErr) {
t.Fatalf("bind: expected <%v> got <%v>", tcase.BindErr, err)
}
}
_, err = builder.Build()
if !errors.Is(err, tcase.BuildErr) {
t.Fatalf("build: expected <%v> got <%v>", tcase.BuildErr, err)
}
})
}
}

23
datatype/datatype.go Normal file
View File

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

View File

@ -1,15 +0,0 @@
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
}

View File

@ -1,48 +0,0 @@
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")

View File

@ -1,90 +0,0 @@
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())
}

View File

@ -1,119 +0,0 @@
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
}

View File

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

2
go.mod
View File

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

View File

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

511
handler_test.go Normal file
View File

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

116
http.go
View File

@ -1,116 +0,0 @@
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)
}

182
internal/config/config.go Normal file
View File

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

View File

@ -21,15 +21,15 @@ func TestLegalServiceName(t *testing.T) {
// empty
{
`[ { "method": "GET", "info": "a", "path": "" } ]`,
ErrInvalidPattern,
errInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "no-starting-slash" } ]`,
ErrInvalidPattern,
errInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "ending-slash/" } ]`,
ErrInvalidPattern,
errInvalidPattern,
},
{
`[ { "method": "GET", "info": "a", "path": "/" } ]`,
@ -45,42 +45,43 @@ func TestLegalServiceName(t *testing.T) {
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/s{braces}" } ]`,
ErrInvalidPatternBraceCapture,
errInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}a" } ]`,
ErrInvalidPatternBraceCapture,
errInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}" } ]`,
ErrUndefinedBraceCapture,
errUndefinedBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/s{braces}/abc" } ]`,
ErrInvalidPatternBraceCapture,
errInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}s/abc" } ]`,
ErrInvalidPatternBraceCapture,
errInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}/abc" } ]`,
ErrUndefinedBraceCapture,
errUndefinedBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{b{races}s/abc" } ]`,
ErrInvalidPatternBraceCapture,
errInvalidPatternBraceCapture,
},
{
`[ { "method": "GET", "info": "a", "path": "/invalid/{braces}/}abc" } ]`,
ErrInvalidPatternBraceCapture,
errInvalidPatternBraceCapture,
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Raw))
srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error())
@ -134,15 +135,16 @@ func TestAvailableMethods(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Raw))
srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
if test.ValidMethod && err != nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
}
if !test.ValidMethod && !errors.Is(err, ErrUnknownMethod) {
t.Errorf("expected error <%s> got <%s>", ErrUnknownMethod, err)
if !test.ValidMethod && !errors.Is(err, errUnknownMethod) {
t.Errorf("expected error <%s> got <%s>", errUnknownMethod, err)
t.FailNow()
}
})
@ -150,20 +152,22 @@ func TestAvailableMethods(t *testing.T) {
}
func TestParseEmpty(t *testing.T) {
t.Parallel()
reader := strings.NewReader(`[]`)
_, err := Parse(reader)
r := strings.NewReader(`[]`)
srv := &Server{}
err := srv.Parse(r)
if err != nil {
t.Errorf("unexpected error (got '%s')", err)
t.FailNow()
}
}
func TestParseJsonError(t *testing.T) {
reader := strings.NewReader(`{
r := strings.NewReader(`{
"GET": {
"info": "info
},
}`) // trailing ',' is invalid JSON
_, err := Parse(reader)
srv := &Server{}
err := srv.Parse(r)
if err == nil {
t.Errorf("expected error")
t.FailNow()
@ -205,15 +209,16 @@ func TestParseMissingMethodDescription(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Raw))
srv := &Server{}
err := srv.Parse(strings.NewReader(test.Raw))
if test.ValidDescription && err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
}
if !test.ValidDescription && !errors.Is(err, ErrMissingDescription) {
t.Errorf("expected error <%s> got <%s>", ErrMissingDescription, err)
if !test.ValidDescription && !errors.Is(err, errMissingDescription) {
t.Errorf("expected error <%s> got <%s>", errMissingDescription, err)
t.FailNow()
}
})
@ -223,7 +228,7 @@ func TestParseMissingMethodDescription(t *testing.T) {
func TestParamEmptyRenameNoRename(t *testing.T) {
t.Parallel()
reader := strings.NewReader(`[
r := strings.NewReader(`[
{
"method": "GET",
"path": "/",
@ -233,7 +238,9 @@ func TestParamEmptyRenameNoRename(t *testing.T) {
}
}
]`)
srv, err := Parse(reader, builtin.AnyDataType{})
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
err := srv.Parse(r)
if err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
@ -254,7 +261,7 @@ func TestParamEmptyRenameNoRename(t *testing.T) {
}
func TestOptionalParam(t *testing.T) {
t.Parallel()
reader := strings.NewReader(`[
r := strings.NewReader(`[
{
"method": "GET",
"path": "/",
@ -267,7 +274,10 @@ func TestOptionalParam(t *testing.T) {
}
}
]`)
srv, err := Parse(reader, builtin.AnyDataType{}, builtin.BoolDataType{})
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
err := srv.Parse(r)
if err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
@ -311,7 +321,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrMissingParamDesc,
errMissingParamDesc,
},
{ // invalid param name suffix
`[
@ -324,7 +334,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrMissingParamDesc,
errMissingParamDesc,
},
{ // missing param description
@ -338,7 +348,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrMissingParamDesc,
errMissingParamDesc,
},
{ // empty param description
`[
@ -351,7 +361,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrMissingParamDesc,
errMissingParamDesc,
},
{ // missing param type
@ -365,7 +375,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrMissingParamType,
errMissingParamType,
},
{ // empty param type
`[
@ -378,7 +388,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrMissingParamType,
errMissingParamType,
},
{ // invalid type (optional mark only)
`[
@ -392,7 +402,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
ErrMissingParamType,
errMissingParamType,
},
{ // valid description + valid type
`[
@ -434,7 +444,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
ErrParamNameConflict,
errParamNameConflict,
},
{ // rename conflict with name
`[
@ -449,7 +459,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
ErrParamNameConflict,
errParamNameConflict,
},
{ // rename conflict with rename
`[
@ -464,7 +474,7 @@ func TestParseParameters(t *testing.T) {
}
]`,
// 2 possible errors as map order is not deterministic
ErrParamNameConflict,
errParamNameConflict,
},
{ // both renamed with no conflict
@ -493,7 +503,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrMandatoryRename,
errMandatoryRename,
},
{
`[
@ -506,7 +516,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrMandatoryRename,
errMandatoryRename,
},
{
`[
@ -546,7 +556,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrIllegalOptionalURIParam,
errIllegalOptionalURIParam,
},
{ // URI parameter not specified
`[
@ -559,7 +569,7 @@ func TestParseParameters(t *testing.T) {
}
}
]`,
ErrUnspecifiedBraceCapture,
errUnspecifiedBraceCapture,
},
{ // URI parameter not defined
`[
@ -570,14 +580,16 @@ func TestParseParameters(t *testing.T) {
"in": { }
}
]`,
ErrUndefinedBraceCapture,
errUndefinedBraceCapture,
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Raw), builtin.AnyDataType{})
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
err := srv.Parse(strings.NewReader(test.Raw))
if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error())
@ -625,7 +637,7 @@ func TestServiceCollision(t *testing.T) {
"info": "info", "in": {}
}
]`,
ErrPatternCollision,
errPatternCollision,
},
{
`[
@ -660,7 +672,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
ErrPatternCollision,
errPatternCollision,
},
{
`[
@ -686,7 +698,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
ErrPatternCollision,
errPatternCollision,
},
{
`[
@ -699,7 +711,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
ErrPatternCollision,
errPatternCollision,
},
{
`[
@ -738,7 +750,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
ErrPatternCollision,
errPatternCollision,
},
{
`[
@ -777,7 +789,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
ErrPatternCollision,
errPatternCollision,
},
{
`[
@ -792,7 +804,7 @@ func TestServiceCollision(t *testing.T) {
}
}
]`,
ErrPatternCollision,
errPatternCollision,
},
{
`[
@ -814,7 +826,10 @@ func TestServiceCollision(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
_, err := Parse(strings.NewReader(test.Config), builtin.StringDataType{}, builtin.UintDataType{})
srv := &Server{}
srv.Types = append(srv.Types, builtin.StringDataType{})
srv.Types = append(srv.Types, builtin.UintDataType{})
err := srv.Parse(strings.NewReader(test.Config))
if err == nil && test.Error != nil {
t.Errorf("expected an error: '%s'", test.Error.Error())
@ -862,6 +877,36 @@ func TestMatchSimple(t *testing.T) {
"/a",
false,
},
{ // root url
`[ {
"method": "GET",
"path": "/a",
"info": "info",
"in": {}
} ]`,
"/",
false,
},
{
`[ {
"method": "GET",
"path": "/a",
"info": "info",
"in": {}
} ]`,
"/",
false,
},
{
`[ {
"method": "GET",
"path": "/",
"info": "info",
"in": {}
} ]`,
"/",
true,
},
{
`[ {
"method": "GET",
@ -951,7 +996,11 @@ func TestMatchSimple(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv, err := Parse(strings.NewReader(test.Config), builtin.AnyDataType{}, builtin.IntDataType{}, builtin.BoolDataType{})
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.IntDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
err := srv.Parse(strings.NewReader(test.Config))
if err != nil {
t.Errorf("unexpected error: '%s'", err)
@ -978,3 +1027,80 @@ func TestMatchSimple(t *testing.T) {
}
}
func TestFindPriority(t *testing.T) {
t.Parallel()
tests := []struct {
Config string
URL string
MatchingDesc string
}{
{
`[
{ "method": "GET", "path": "/a", "info": "s1" },
{ "method": "GET", "path": "/", "info": "s2" }
]`,
"/",
"s2",
},
{
`[
{ "method": "GET", "path": "/", "info": "s2" },
{ "method": "GET", "path": "/a", "info": "s1" }
]`,
"/",
"s2",
},
{
`[
{ "method": "GET", "path": "/a", "info": "s1" },
{ "method": "GET", "path": "/", "info": "s2" }
]`,
"/a",
"s1",
},
{
`[
{ "method": "GET", "path": "/a/b/c", "info": "s1" },
{ "method": "GET", "path": "/a/b", "info": "s2" }
]`,
"/a/b/c",
"s1",
},
{
`[
{ "method": "GET", "path": "/a/b/c", "info": "s1" },
{ "method": "GET", "path": "/a/b", "info": "s2" }
]`,
"/a/b/",
"s2",
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) {
srv := &Server{}
srv.Types = append(srv.Types, builtin.AnyDataType{})
srv.Types = append(srv.Types, builtin.IntDataType{})
srv.Types = append(srv.Types, builtin.BoolDataType{})
err := srv.Parse(strings.NewReader(test.Config))
if err != nil {
t.Errorf("unexpected error: '%s'", err)
t.FailNow()
}
req := httptest.NewRequest(http.MethodGet, test.URL, nil)
service := srv.Find(req)
if service == nil {
t.Errorf("expected to find a service")
t.FailNow()
}
if service.Description != test.MatchingDesc {
t.Errorf("expected description '%s', got '%s'", test.MatchingDesc, service.Description)
t.FailNow()
}
})
}
}

View File

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

View File

@ -1,15 +0,0 @@
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,28 +1,39 @@
package config
import (
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// Validate implements the validator interface
func (param *Parameter) Validate(datatypes ...datatype.T) error {
// missing description
// Parameter represents a parameter definition (from api.json)
type Parameter struct {
Description string `json:"info"`
Type string `json:"type"`
Rename string `json:"name,omitempty"`
Optional bool
// ExtractType is the type the Validator will cast into
ExtractType reflect.Type
// Validator is inferred from the "type" property
Validator datatype.Validator
}
func (param *Parameter) validate(datatypes ...datatype.T) error {
if len(param.Description) < 1 {
return ErrMissingParamDesc
return errMissingParamDesc
}
// invalid type
if len(param.Type) < 1 || param.Type == "?" {
return ErrMissingParamType
return errMissingParamType
}
// optional type transform
// optional type
if param.Type[0] == '?' {
param.Optional = true
param.Type = param.Type[1:]
}
// assign the datatype
// find validator
for _, dtype := range datatypes {
param.Validator = dtype.Build(param.Type, datatypes...)
param.ExtractType = dtype.Type()
@ -31,8 +42,7 @@ func (param *Parameter) Validate(datatypes ...datatype.T) error {
}
}
if param.Validator == nil {
return ErrUnknownDataType
return errUnknownDataType
}
return nil
}

View File

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

View File

@ -11,22 +11,44 @@ import (
var braceRegex = regexp.MustCompile(`^{([a-z_-]+)}$`)
var queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`)
var availableHTTPMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
// Service definition
type Service struct {
Method string `json:"method"`
Pattern string `json:"path"`
Scope [][]string `json:"scope"`
Description string `json:"info"`
Input map[string]*Parameter `json:"in"`
Output map[string]*Parameter `json:"out"`
// Captures contains references to URI parameters from the `Input` map. The format
// of these parameter names is "{paramName}"
Captures []*BraceCapture
// Query contains references to HTTP Query parameters from the `Input` map.
// Query parameters names are "GET@paramName", this map contains escaped names (e.g. "paramName")
Query map[string]*Parameter
// Form references form parameters from the `Input` map (all but Captures and Query).
Form map[string]*Parameter
}
// BraceCapture links to the related URI parameter
type BraceCapture struct {
Name string
Index int
Ref *Parameter
}
// Match returns if this service would handle this HTTP request
func (svc *Service) Match(req *http.Request) bool {
// method
if req.Method != svc.Method {
return false
}
// check path
if !svc.matchPattern(req.RequestURI) {
return false
}
// check and extract input
// todo: check if input match and extract models
return true
}
@ -35,13 +57,12 @@ func (svc *Service) matchPattern(uri string) bool {
uriparts := SplitURL(uri)
parts := SplitURL(svc.Pattern)
// fail if size differ
if len(uriparts) != len(parts) {
return false
}
// root url '/'
if len(parts) == 0 {
if len(parts) == 0 && len(uriparts) == 0 {
return true
}
@ -76,7 +97,7 @@ func (svc *Service) matchPattern(uri string) bool {
}
// Validate implements the validator interface
func (svc *Service) Validate(datatypes ...datatype.T) error {
func (svc *Service) validate(datatypes ...datatype.T) error {
// check method
err := svc.isMethodAvailable()
if err != nil {
@ -92,7 +113,7 @@ func (svc *Service) Validate(datatypes ...datatype.T) error {
// check description
if len(strings.Trim(svc.Description, " \t\r\n")) < 1 {
return fmt.Errorf("field 'description': %w", ErrMissingDescription)
return fmt.Errorf("field 'description': %w", errMissingDescription)
}
// check input parameters
@ -104,7 +125,7 @@ func (svc *Service) Validate(datatypes ...datatype.T) error {
// fail if a brace capture remains undefined
for _, capture := range svc.Captures {
if capture.Ref == nil {
return fmt.Errorf("field 'in': %s: %w", capture.Name, ErrUndefinedBraceCapture)
return fmt.Errorf("field 'in': %s: %w", capture.Name, errUndefinedBraceCapture)
}
}
@ -123,7 +144,7 @@ func (svc *Service) isMethodAvailable() error {
return nil
}
}
return ErrUnknownMethod
return errUnknownMethod
}
func (svc *Service) isPatternValid() error {
@ -131,13 +152,13 @@ func (svc *Service) isPatternValid() error {
// empty pattern
if length < 1 {
return ErrInvalidPattern
return errInvalidPattern
}
if length > 1 {
// pattern not starting with '/' or ending with '/'
if svc.Pattern[0] != '/' || svc.Pattern[length-1] == '/' {
return ErrInvalidPattern
return errInvalidPattern
}
}
@ -145,7 +166,7 @@ func (svc *Service) isPatternValid() error {
parts := SplitURL(svc.Pattern)
for i, part := range parts {
if len(part) < 1 {
return ErrInvalidPattern
return errInvalidPattern
}
// if brace capture
@ -166,7 +187,7 @@ func (svc *Service) isPatternValid() error {
// fail on invalid format
if strings.ContainsAny(part, "{}") {
return ErrInvalidPatternBraceCapture
return errInvalidPatternBraceCapture
}
}
@ -185,7 +206,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// for each parameter
for paramName, param := range svc.Input {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
return fmt.Errorf("%s: %w", paramName, errIllegalParamName)
}
// fail if brace capture does not exists in pattern
@ -202,7 +223,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
}
}
if !found {
return fmt.Errorf("%s: %w", paramName, ErrUnspecifiedBraceCapture)
return fmt.Errorf("%s: %w", paramName, errUnspecifiedBraceCapture)
}
iscapture = true
@ -225,7 +246,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// fail if capture or query without rename
if len(param.Rename) < 1 && (iscapture || isquery) {
return fmt.Errorf("%s: %w", paramName, ErrMandatoryRename)
return fmt.Errorf("%s: %w", paramName, errMandatoryRename)
}
// use param name if no rename
@ -233,14 +254,14 @@ func (svc *Service) validateInput(types []datatype.T) error {
param.Rename = paramName
}
err := param.Validate(types...)
err := param.validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
}
// capture parameter cannot be optional
if iscapture && param.Optional {
return fmt.Errorf("%s: %w", paramName, ErrIllegalOptionalURIParam)
return fmt.Errorf("%s: %w", paramName, errIllegalOptionalURIParam)
}
// fail on name/rename conflict
@ -254,7 +275,7 @@ func (svc *Service) validateInput(types []datatype.T) error {
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if param.Rename == param2.Rename || paramName == param2.Rename || paramName2 == param.Rename {
return fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
return fmt.Errorf("%s: %w", paramName, errParamNameConflict)
}
}
@ -275,7 +296,7 @@ func (svc *Service) validateOutput(types []datatype.T) error {
// for each parameter
for paramName, param := range svc.Output {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
return fmt.Errorf("%s: %w", paramName, errIllegalParamName)
}
// use param name if no rename
@ -283,13 +304,13 @@ func (svc *Service) validateOutput(types []datatype.T) error {
param.Rename = paramName
}
err := param.Validate(types...)
err := param.validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
}
if param.Optional {
return fmt.Errorf("%s: %w", paramName, ErrOptionalOption)
return fmt.Errorf("%s: %w", paramName, errOptionalOption)
}
// fail on name/rename conflict
@ -303,7 +324,7 @@ func (svc *Service) validateOutput(types []datatype.T) error {
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if param.Rename == param2.Rename || paramName == param2.Rename || paramName2 == param.Rename {
return fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
return fmt.Errorf("%s: %w", paramName, errParamNameConflict)
}
}

View File

@ -1,63 +0,0 @@
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

@ -0,0 +1,50 @@
package dynfunc
// cerr allows you to create constant "const" error with type boxing.
type cerr string
func (err cerr) Error() string {
return string(err)
}
// errHandlerNotFunc - handler is not a func
const errHandlerNotFunc = cerr("handler must be a func")
// errNoServiceForHandler - no service matching this handler
const errNoServiceForHandler = cerr("no service found for this handler")
// errMissingHandlerArgumentParam - missing params arguments for handler
const errMissingHandlerArgumentParam = cerr("missing handler argument : parameter struct")
// errUnexpectedInput - input argument is not expected
const errUnexpectedInput = cerr("unexpected input struct")
// errMissingHandlerOutput - missing output for handler
const errMissingHandlerOutput = cerr("handler must have at least 1 output")
// errMissingHandlerOutputError - missing error output for handler
const errMissingHandlerOutputError = cerr("handler must have its last output of type api.Err")
// errMissingRequestArgument - missing request argument for handler
const errMissingRequestArgument = cerr("handler first argument must be of type api.Request")
// errMissingParamArgument - missing parameters argument for handler
const errMissingParamArgument = cerr("handler second argument must be a struct")
// errUnexportedName - argument is unexported in struct
const errUnexportedName = cerr("unexported name")
// errMissingParamOutput - missing output argument for handler
const errMissingParamOutput = cerr("handler first output must be a *struct")
// errMissingParamFromConfig - missing a parameter in handler struct
const errMissingParamFromConfig = cerr("missing a parameter from configuration")
// errMissingOutputFromConfig - missing a parameter in handler struct
const errMissingOutputFromConfig = cerr("missing a parameter from configuration")
// errWrongParamTypeFromConfig - a configuration parameter type is invalid in the handler param struct
const errWrongParamTypeFromConfig = cerr("invalid struct field type")
// errMissingHandlerErrorOutput - missing handler output error
const errMissingHandlerErrorOutput = cerr("last output must be of type api.Err")

147
internal/dynfunc/handler.go Normal file
View File

@ -0,0 +1,147 @@
package dynfunc
import (
"fmt"
"log"
"reflect"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
)
// Handler represents a dynamic api handler
type Handler struct {
spec *signature
fn interface{}
// whether fn uses api.Ctx as 1st argument
hasContext bool
// index in input arguments where the data struct must be
dataIndex int
}
// Build a handler from a service configuration and a dynamic function
//
// @fn must have as a signature : `func(inputStruct) (*outputStruct, api.Err)`
// - `inputStruct` is a struct{} containing a field for each service input (with valid reflect.Type)
// - `outputStruct` is a struct{} containing a field for each service output (with valid reflect.Type)
//
// Special cases:
// - a first optional input parameter of type `api.Ctx` can be added
// - it there is no input, `inputStruct` must be omitted
// - it there is no output, `outputStruct` must be omitted
func Build(fn interface{}, service config.Service) (*Handler, error) {
h := &Handler{
spec: signatureFromService(service),
fn: fn,
}
impl := reflect.TypeOf(fn)
if impl.Kind() != reflect.Func {
return nil, errHandlerNotFunc
}
h.hasContext = impl.NumIn() >= 1 && reflect.TypeOf(api.Ctx{}).AssignableTo(impl.In(0))
if h.hasContext {
h.dataIndex = 1
}
if err := h.spec.checkInput(impl, h.dataIndex); err != nil {
return nil, fmt.Errorf("input: %w", err)
}
if err := h.spec.checkOutput(impl); err != nil {
return nil, fmt.Errorf("output: %w", err)
}
return h, nil
}
// Handle binds input @data into the dynamic function and returns map output
func (h *Handler) Handle(ctx api.Ctx, data map[string]interface{}) (map[string]interface{}, api.Err) {
var ert = reflect.TypeOf(api.Err{})
var fnv = reflect.ValueOf(h.fn)
callArgs := []reflect.Value{}
// bind context if used in handler
if h.hasContext {
callArgs = append(callArgs, reflect.ValueOf(ctx))
}
// bind input data
if fnv.Type().NumIn() > h.dataIndex {
// create zero value struct
callStructPtr := reflect.New(fnv.Type().In(0))
callStruct := callStructPtr.Elem()
// set each field
for name := range h.spec.Input {
field := callStruct.FieldByName(name)
if !field.CanSet() {
continue
}
// get value from @data
value, provided := data[name]
if !provided {
continue
}
var refvalue = reflect.ValueOf(value)
// T to pointer of T
if field.Kind() == reflect.Ptr {
var ptrType = field.Type().Elem()
if !refvalue.Type().ConvertibleTo(ptrType) {
log.Printf("Cannot convert %v into *%v", refvalue.Type(), ptrType)
return nil, api.ErrUncallableService
}
ptr := reflect.New(ptrType)
ptr.Elem().Set(reflect.ValueOf(value).Convert(ptrType))
field.Set(ptr)
continue
}
if !reflect.ValueOf(value).Type().ConvertibleTo(field.Type()) {
log.Printf("Cannot convert %v into %v", reflect.ValueOf(value).Type(), field.Type())
return nil, api.ErrUncallableService
}
field.Set(refvalue.Convert(field.Type()))
}
callArgs = append(callArgs, callStruct)
}
// call the HandlerFn
output := fnv.Call(callArgs)
// no output OR pointer to output struct is nil
outdata := make(map[string]interface{})
if len(h.spec.Output) < 1 || output[0].IsNil() {
var structerr = output[len(output)-1].Convert(ert)
return outdata, api.Err{
Code: int(structerr.FieldByName("Code").Int()),
Reason: structerr.FieldByName("Reason").String(),
Status: int(structerr.FieldByName("Status").Int()),
}
}
// extract struct from pointer
returnStruct := output[0].Elem()
for name := range h.spec.Output {
field := returnStruct.FieldByName(name)
outdata[name] = field.Interface()
}
// extract api.Err
var structerr = output[len(output)-1].Convert(ert)
return outdata, api.Err{
Code: int(structerr.FieldByName("Code").Int()),
Reason: structerr.FieldByName("Reason").String(),
Status: int(structerr.FieldByName("Status").Int()),
}
}

View File

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

View File

@ -0,0 +1,148 @@
package dynfunc
import (
"fmt"
"reflect"
"strings"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
)
// signature represents input/output arguments for a dynamic function
type signature struct {
Input map[string]reflect.Type
Output map[string]reflect.Type
}
// builds a spec from the configuration service
func signatureFromService(service config.Service) *signature {
s := &signature{
Input: make(map[string]reflect.Type),
Output: make(map[string]reflect.Type),
}
for _, param := range service.Input {
if len(param.Rename) < 1 {
continue
}
// make a pointer if optional
if param.Optional {
s.Input[param.Rename] = reflect.PtrTo(param.ExtractType)
continue
}
s.Input[param.Rename] = param.ExtractType
}
for _, param := range service.Output {
if len(param.Rename) < 1 {
continue
}
s.Output[param.Rename] = param.ExtractType
}
return s
}
// checks for HandlerFn input arguments
func (s *signature) checkInput(impl reflect.Type, index int) error {
var requiredInput, structIndex = index, index
if len(s.Input) > 0 { // arguments struct
requiredInput++
}
// missing arguments
if impl.NumIn() > requiredInput {
return errUnexpectedInput
}
// none required
if len(s.Input) == 0 {
return nil
}
// too much arguments
if impl.NumIn() != requiredInput {
return errMissingHandlerArgumentParam
}
// arg must be a struct
structArg := impl.In(structIndex)
if structArg.Kind() != reflect.Struct {
return errMissingParamArgument
}
// check for invalid param
for name, ptype := range s.Input {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, errUnexportedName)
}
field, exists := structArg.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, errMissingParamFromConfig)
}
if !ptype.AssignableTo(field.Type) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, errWrongParamTypeFromConfig, field.Type, ptype)
}
}
return nil
}
// checks for HandlerFn output arguments
func (s signature) checkOutput(impl reflect.Type) error {
if impl.NumOut() < 1 {
return errMissingHandlerOutput
}
// last output must be api.Err
errOutput := impl.Out(impl.NumOut() - 1)
if !errOutput.AssignableTo(reflect.TypeOf(api.ErrUnknown)) {
return errMissingHandlerErrorOutput
}
// no output -> ok
if len(s.Output) == 0 {
return nil
}
if impl.NumOut() != 2 {
return errMissingParamOutput
}
// fail if first output is not a pointer to struct
structOutputPtr := impl.Out(0)
if structOutputPtr.Kind() != reflect.Ptr {
return errMissingParamOutput
}
structOutput := structOutputPtr.Elem()
if structOutput.Kind() != reflect.Struct {
return errMissingParamOutput
}
// fail on invalid output
for name, ptype := range s.Output {
if name[0] == strings.ToLower(name)[0] {
return fmt.Errorf("%s: %w", name, errUnexportedName)
}
field, exists := structOutput.FieldByName(name)
if !exists {
return fmt.Errorf("%s: %w", name, errMissingOutputFromConfig)
}
// ignore types evalutating to nil
if ptype == nil {
continue
}
if !field.Type.ConvertibleTo(ptype) {
return fmt.Errorf("%s: %w (%s instead of %s)", name, errWrongParamTypeFromConfig, field.Type, ptype)
}
}
return nil
}

View File

@ -0,0 +1,397 @@
package dynfunc
import (
"errors"
"fmt"
"reflect"
"testing"
"git.xdrm.io/go/aicra/api"
)
func TestInputCheck(t *testing.T) {
tcases := []struct {
Name string
Input map[string]reflect.Type
Fn interface{}
FnCtx interface{}
Err error
}{
{
Name: "no input 0 given",
Input: map[string]reflect.Type{},
Fn: func() {},
FnCtx: func(api.Ctx) {},
Err: nil,
},
{
Name: "no input 1 given",
Input: map[string]reflect.Type{},
Fn: func(int) {},
FnCtx: func(api.Ctx, int) {},
Err: errUnexpectedInput,
},
{
Name: "no input 2 given",
Input: map[string]reflect.Type{},
Fn: func(int, string) {},
FnCtx: func(api.Ctx, int, string) {},
Err: errUnexpectedInput,
},
{
Name: "1 input 0 given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() {},
FnCtx: func(api.Ctx) {},
Err: errMissingHandlerArgumentParam,
},
{
Name: "1 input non-struct given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(int) {},
FnCtx: func(api.Ctx, int) {},
Err: errMissingParamArgument,
},
{
Name: "unexported input",
Input: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{}) {},
FnCtx: func(api.Ctx, struct{}) {},
Err: errUnexportedName,
},
{
Name: "1 input empty struct given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{}) {},
FnCtx: func(api.Ctx, struct{}) {},
Err: errMissingParamFromConfig,
},
{
Name: "1 input invalid given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{ Test1 string }) {},
FnCtx: func(api.Ctx, struct{ Test1 string }) {},
Err: errWrongParamTypeFromConfig,
},
{
Name: "1 input valid given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func(struct{ Test1 int }) {},
FnCtx: func(api.Ctx, struct{ Test1 int }) {},
Err: nil,
},
{
Name: "1 input ptr empty struct given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(struct{}) {},
FnCtx: func(api.Ctx, struct{}) {},
Err: errMissingParamFromConfig,
},
{
Name: "1 input ptr invalid given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(struct{ Test1 string }) {},
FnCtx: func(api.Ctx, struct{ Test1 string }) {},
Err: errWrongParamTypeFromConfig,
},
{
Name: "1 input ptr invalid ptr type given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(struct{ Test1 *string }) {},
FnCtx: func(api.Ctx, struct{ Test1 *string }) {},
Err: errWrongParamTypeFromConfig,
},
{
Name: "1 input ptr valid given",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(int)),
},
Fn: func(struct{ Test1 *int }) {},
FnCtx: func(api.Ctx, struct{ Test1 *int }) {},
Err: nil,
},
{
Name: "1 valid string",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(string("")),
},
Fn: func(struct{ Test1 string }) {},
FnCtx: func(api.Ctx, struct{ Test1 string }) {},
Err: nil,
},
{
Name: "1 valid uint",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(uint(0)),
},
Fn: func(struct{ Test1 uint }) {},
FnCtx: func(api.Ctx, struct{ Test1 uint }) {},
Err: nil,
},
{
Name: "1 valid float64",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(float64(0)),
},
Fn: func(struct{ Test1 float64 }) {},
FnCtx: func(api.Ctx, struct{ Test1 float64 }) {},
Err: nil,
},
{
Name: "1 valid []byte",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf([]byte("")),
},
Fn: func(struct{ Test1 []byte }) {},
FnCtx: func(api.Ctx, struct{ Test1 []byte }) {},
Err: nil,
},
{
Name: "1 valid []rune",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf([]rune("")),
},
Fn: func(struct{ Test1 []rune }) {},
FnCtx: func(api.Ctx, struct{ Test1 []rune }) {},
Err: nil,
},
{
Name: "1 valid *string",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(string)),
},
Fn: func(struct{ Test1 *string }) {},
FnCtx: func(api.Ctx, struct{ Test1 *string }) {},
Err: nil,
},
{
Name: "1 valid *uint",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(uint)),
},
Fn: func(struct{ Test1 *uint }) {},
FnCtx: func(api.Ctx, struct{ Test1 *uint }) {},
Err: nil,
},
{
Name: "1 valid *float64",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new(float64)),
},
Fn: func(struct{ Test1 *float64 }) {},
FnCtx: func(api.Ctx, struct{ Test1 *float64 }) {},
Err: nil,
},
{
Name: "1 valid *[]byte",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new([]byte)),
},
Fn: func(struct{ Test1 *[]byte }) {},
FnCtx: func(api.Ctx, struct{ Test1 *[]byte }) {},
Err: nil,
},
{
Name: "1 valid *[]rune",
Input: map[string]reflect.Type{
"Test1": reflect.TypeOf(new([]rune)),
},
Fn: func(struct{ Test1 *[]rune }) {},
FnCtx: func(api.Ctx, struct{ Test1 *[]rune }) {},
Err: nil,
},
}
for _, tcase := range tcases {
t.Run(tcase.Name, func(t *testing.T) {
t.Parallel()
// mock spec
s := signature{
Input: tcase.Input,
Output: nil,
}
t.Run("with-context", func(t *testing.T) {
err := s.checkInput(reflect.TypeOf(tcase.FnCtx), 1)
if err == nil && tcase.Err != nil {
t.Errorf("expected an error: '%s'", tcase.Err.Error())
t.FailNow()
}
if err != nil && tcase.Err == nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
}
if err != nil && tcase.Err != nil {
if !errors.Is(err, tcase.Err) {
t.Errorf("expected the error <%s> got <%s>", tcase.Err, err)
t.FailNow()
}
}
})
t.Run("without-context", func(t *testing.T) {
err := s.checkInput(reflect.TypeOf(tcase.Fn), 0)
if err == nil && tcase.Err != nil {
t.Errorf("expected an error: '%s'", tcase.Err.Error())
t.FailNow()
}
if err != nil && tcase.Err == nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
}
if err != nil && tcase.Err != nil {
if !errors.Is(err, tcase.Err) {
t.Errorf("expected the error <%s> got <%s>", tcase.Err, err)
t.FailNow()
}
}
})
})
}
}
func TestOutputCheck(t *testing.T) {
tcases := []struct {
Output map[string]reflect.Type
Fn interface{}
Err error
}{
// no input -> missing api.Err
{
Output: map[string]reflect.Type{},
Fn: func() {},
Err: errMissingHandlerOutput,
},
// no input -> with last type not api.Err
{
Output: map[string]reflect.Type{},
Fn: func() bool { return true },
Err: errMissingHandlerErrorOutput,
},
// no input -> with api.Err
{
Output: map[string]reflect.Type{},
Fn: func() api.Err { return api.ErrSuccess },
Err: nil,
},
// func can have output if not specified
{
Output: map[string]reflect.Type{},
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
// missing output struct in func
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() api.Err { return api.ErrSuccess },
Err: errMissingParamOutput,
},
// output not a pointer
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (int, api.Err) { return 0, api.ErrSuccess },
Err: errMissingParamOutput,
},
// output not a pointer to struct
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*int, api.Err) { return nil, api.ErrSuccess },
Err: errMissingParamOutput,
},
// unexported param name
{
Output: map[string]reflect.Type{
"test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: errUnexportedName,
},
// output field missing
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{}, api.Err) { return nil, api.ErrSuccess },
Err: errMissingParamFromConfig,
},
// output field invalid type
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 string }, api.Err) { return nil, api.ErrSuccess },
Err: errWrongParamTypeFromConfig,
},
// output field valid type
{
Output: map[string]reflect.Type{
"Test1": reflect.TypeOf(int(0)),
},
Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
// ignore type check on nil type
{
Output: map[string]reflect.Type{
"Test1": nil,
},
Fn: func() (*struct{ Test1 int }, api.Err) { return nil, api.ErrSuccess },
Err: nil,
},
}
for i, tcase := range tcases {
t.Run(fmt.Sprintf("case.%d", i), func(t *testing.T) {
t.Parallel()
// mock spec
s := signature{
Input: nil,
Output: tcase.Output,
}
err := s.checkOutput(reflect.TypeOf(tcase.Fn))
if err == nil && tcase.Err != nil {
t.Errorf("expected an error: '%s'", tcase.Err.Error())
t.FailNow()
}
if err != nil && tcase.Err == nil {
t.Errorf("unexpected error: '%s'", err.Error())
t.FailNow()
}
if err != nil && tcase.Err != nil {
if !errors.Is(err, tcase.Err) {
t.Errorf("expected the error <%s> got <%s>", tcase.Err, err)
t.FailNow()
}
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -131,7 +131,7 @@ func TestStoreWithUri(t *testing.T) {
store := New(service)
req := httptest.NewRequest(http.MethodGet, "http://host.com"+test.URI, nil)
err := store.ExtractURI(req)
err := store.GetURI(*req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
@ -242,7 +242,7 @@ func TestExtractQuery(t *testing.T) {
store := New(getServiceWithQuery(test.ServiceParam...))
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://host.com?%s", test.Query), nil)
err := store.ExtractQuery(req)
err := store.GetQuery(*req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
@ -324,7 +324,7 @@ func TestStoreWithUrlEncodedFormParseError(t *testing.T) {
// defer req.Body.Close()
store := New(nil)
err := store.ExtractForm(req)
err := store.GetForm(*req)
if err == nil {
t.Errorf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data))
t.FailNow()
@ -420,7 +420,7 @@ func TestExtractFormUrlEncoded(t *testing.T) {
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req)
err := store.GetForm(*req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
@ -563,7 +563,7 @@ func TestJsonParameters(t *testing.T) {
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req)
err := store.GetForm(*req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
@ -720,7 +720,7 @@ x
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req)
err := store.GetForm(*req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {

BIN
readme.assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -1,91 +0,0 @@
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
View File

@ -1,15 +0,0 @@
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)
}