Compare commits

...

63 Commits

Author SHA1 Message Date
Adrien Marquès 4877d0ea23 Merge branch 'feature/dynamic-handler-signature' of go/aicra into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
handlers are now managed by the `dynamic` package :
 - handler's signature is `func(inputStruct) (outputStruct, api.Error)`
   - `inputStruct` contains input fields using the name from the field `name`, optional fields are pointers
   - `outputStruct` contains output fields following the same rules as `inputStruct` except optional types are disallowed
   - if no in input, `inputStruct` can be omitted, resulting in `func() (outputStruct, api.Error)`
   - if no output, `outputStruct` can be omitted, resulting in `func(inputStruct) api.Error`
   - as a result, if both input and output are empty; handler signature is `func() api.Error`

datatypes interface contains a `Type() reflect.Type` method to tell what type the result will be cast into :
 - handler `inputStruct` fields are checked against datatypes to check if `datatype.Type()` is convertible to `inputStruct.field`
 - same for output even if no cast is made; it serves as a guard to make sure the contract (config) is satisfied by the implementation.

config parses the `out` field to check for conflicts and find datatypes.
2020-03-29 15:10:04 +00:00
Adrien Marquès 8a0a20294c
rename output fields to original name (not rename)
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-03-29 17:01:24 +02:00
Adrien Marquès d7acf771ad
implement the dynamic handler : fill input struct, do the call, fill return struct 2020-03-29 17:01:02 +02:00
Adrien Marquès a5424d8941
parse output in internal/config to find the datatype's reflect.Type() 2020-03-29 16:59:32 +02:00
Adrien Marquès a3daab7de4
dynamic handler output struct must be a pointer; no more a regular struct 2020-03-29 16:58:53 +02:00
Adrien Marquès 00e2a96c79
fix: ErrNoMatchFound error 2020-03-29 16:22:32 +02:00
Adrien Marquès e7dd1e7a56
migrate handler from api to aicra; check for service when setting handler 2020-03-29 15:04:12 +02:00
Adrien Marquès 081e48002f
create dynamic package to handle reflection at runtime to check for handlers signature 2020-03-29 15:00:22 +02:00
Adrien Marquès 974f58fb8e
parse 'out' for internal config
continuous-integration/drone/push Build is passing Details
2020-03-29 14:18:38 +02:00
Adrien Marquès ca2be1415d
enforce 'name' for capture or query parameters 2020-03-29 14:18:05 +02:00
Adrien Marquès b15bb578ce
delegate from internal.service to parameter.Validate() 2020-03-29 14:12:47 +02:00
Adrien Marquès 76cc2f5279
replace datatype.Kind() with Type() 2020-03-28 19:11:23 +01:00
Adrien Marquès 8cfa2235d6
add Kind() method to datatype.T interface and to config parameter 2020-03-28 18:48:27 +01:00
Adrien Marquès cb7f22e03d
remove useless readme assets
continuous-integration/drone/push Build is passing Details
2020-03-28 15:45:56 +01:00
Adrien Marquès d3e8d48bc3 Merge branch 'refactor/const-api-errors' of go/aicra into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
allow constants for api.Error and update api.Handler signature

- api.EmtyResponse().WithError(api.Error) is the new interface for api.Response
- handlers return an api.Response that is wrapped into the final response
- server.HandleFunc becomes server.Handle
2020-03-28 14:01:08 +00:00
Adrien Marquès af09466013
migrate api.response to const errors; make HandlerFn return an api.Error; rename http HandlFunc to Handle;
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-03-28 14:57:28 +01:00
Adrien Marquès 5504e4b3ec
make api errors int; allow for const defaults 2020-03-28 14:51:49 +01:00
Adrien Marquès 2f9534a3b0 Merge branch 'refactor/config-validator' of go/aicra into 0.3.0
continuous-integration/drone/push Build is passing Details
add validator interface to unify and for readability in internal/config
2020-03-28 11:33:34 +00:00
Adrien Marquès 49cf06d5d8
implement validator interface for config.server; refactor
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-03-28 12:31:44 +01:00
Adrien Marquès af3ffa7d6a
implement validator interface for config.service; rename for readability 2020-03-28 12:30:57 +01:00
Adrien Marquès dac9aa4298
implement validator interface for config.parameter 2020-03-28 12:28:58 +01:00
Adrien Marquès 54705b7472
create validator interface for config 2020-03-28 12:26:11 +01:00
Adrien Marquès 5f3aa5967d
provide datatype registry to every type to allow for recursive datatypes : slices, maps, structs 2020-03-22 16:50:10 +01:00
Adrien Marquès eef94ff998
also check method when finding missing handlers 2020-03-22 16:37:43 +01:00
Adrien Marquès 97cf19d7b4
update readme
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2020-03-22 15:03:35 +01:00
Adrien Marquès d57f60c710
set content-type to json before writing response
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2020-03-22 14:05:47 +01:00
Adrien Marquès ad6de97979
fix: actually get service handler 2020-03-22 14:05:32 +01:00
Adrien Marquès 2ee48560b6
make ToHTTPServer() check for missing handlers
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
- check for missing handlers in ToHTTPServer()
 - rename HTTP() to ToHTTPServer()
 - remove logs
2020-03-21 16:53:19 +01:00
Adrien Marquès a15a5c1f7a
fix: register mismatch when no brace capture
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2020-03-21 15:58:05 +01:00
Adrien Marquès 5fe983c486
display both services in pattern collision error messages 2020-03-21 15:57:35 +01:00
Adrien Marquès 3017cc5ba9
add method tests for pattern collision
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2020-03-21 15:52:07 +01:00
Adrien Marquès 9c3166397f
add tests for service collision
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2020-03-21 15:49:07 +01:00
Adrien Marquès e3adbf48ca
consider collision only if every part is matching 2020-03-21 15:48:59 +01:00
Adrien Marquès 0e6dfbe580
make pattern collission error message explicit
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2020-03-21 15:15:33 +01:00
Adrien Marquès d6c22b5ff0
adapt server to previous api changes
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2020-03-21 14:49:36 +01:00
Adrien Marquès 9a5b0dd6e3
remove Parameter type, only keep method parseParameter() 2020-03-21 14:45:39 +01:00
Adrien Marquès 149ec9a9a0
internal/reqdata Parse() does not return errors anymore 2020-03-21 14:20:26 +01:00
Adrien Marquès 3a258400c0
remove api useless errors : UnkonwnMethod and UncallableMethod 2020-03-21 14:19:35 +01:00
Adrien Marquès 9475fe4526
use private errors do avoid overlapping types among packages 2020-03-21 14:19:14 +01:00
Adrien Marquès 3606f9984d
update tests for internal/reqdata set
continuous-integration/drone/push Build is failing Details
2020-03-20 22:36:15 +01:00
Adrien Marquès 7b812c6648
get data from multipart components 2020-03-20 22:35:53 +01:00
Adrien Marquès dc34d9a81a
wrap multipart errors in dedicated error : ErrInvalidMultipart 2020-03-20 22:35:30 +01:00
Adrien Marquès cdbe4cceac
ignore io.EOF while parsing multipart 2020-03-20 22:27:01 +01:00
Adrien Marquès 03d5e87c37
wrap json parser into dedicated error : ErrInvalidJSON 2020-03-20 22:26:43 +01:00
Adrien Marquès c7aa87c660
ignore EOF when parsing form as json 2020-03-20 22:18:34 +01:00
Adrien Marquès 0f62fc25a0
use request.URL.RequestURI() insteaf of request.RequestURI() ; it is not the same 2020-03-20 22:09:38 +01:00
Adrien Marquès 8c539370aa
remove cerr 2020-03-16 12:53:48 +01:00
Adrien Marquès acd0e73438
remove typecheck 2020-03-16 12:53:37 +01:00
Adrien Marquès b38a9a8111
refactor internal/reqdata to work with thew new config 2020-03-16 12:50:30 +01:00
Adrien Marquès 93b31b9718
keep references to Form parameters 2020-03-16 11:48:44 +01:00
Adrien Marquès 12417f7f1c
reference query parameters in config.Service 2020-03-16 11:32:37 +01:00
Adrien Marquès e7f10723a6
check for undefined brace captures + make tests parallel 2020-03-16 10:56:26 +01:00
Adrien Marquès c32b038da2
make splitURL public 2020-03-16 09:26:10 +01:00
Adrien Marquès 1b4922693b
move config -> internal.config and config.datatype to datatype 2020-03-16 09:20:00 +01:00
Adrien Marquès 4e0d669029
make config.Service members public 2020-03-16 09:01:51 +01:00
Adrien Marquès 2c1b9cf5ff
make captures public 2020-03-15 01:38:49 +01:00
Adrien Marquès d1ab4fefb0
add brace captures and check between param and pattern (keep them so no need to check them at each req) 2020-03-15 01:37:28 +01:00
Adrien Marquès 32aff3e07f
bcupdate: add service.Match, parameter.assignDataType, service.matchPattern, server.collide
continuous-integration/drone/push Build is failing Details
- datatype-s are required as arguments in Parse(), datatypes are built into the config parameters
 - collision detection compares : http method, pattern (both fixed, one/both captures)
 - test service.Match(); more to test
 - some refactor and fix tests
2020-03-15 00:27:54 +01:00
Adrien Marquès 6a144a9a93
rename Builder into semantic DataType 2020-03-14 16:16:30 +01:00
Adrien Marquès 511070196b
add validator to service input parameter
continuous-integration/drone/push Build is failing Details
2020-03-14 16:14:04 +01:00
Adrien Marquès e12c52b88f
migrate typecheck.builtin into config.datatype.builtin 2020-03-14 16:13:38 +01:00
Adrien Marquès 003fe4d2e7
create config.datatype replacing typecheck 2020-03-14 16:13:05 +01:00
Adrien Marquès a6f5083f0d
bcupdate: make config flat, rewrite, simplify, test 2020-03-14 15:24:17 +01:00
56 changed files with 3395 additions and 2827 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

174
README.md
View File

@ -10,9 +10,9 @@
**Aicra** is a *configuration-driven* **web framework** written in Go that allows you to create a fully featured REST API.
The whole management is done for you from a configuration file describing your API, you're left with implementing :
- controllers
- handlers
- optionnally middle-wares (_e.g. authentication, csrf_)
- and optionnally type checkers to check input parameters
- and optionnally your custom type checkers to check input parameters
The aicra server fulfills the `net/http` [Server interface](https://golang.org/pkg/net/http/#Server).
@ -28,12 +28,12 @@ The aicra server fulfills the `net/http` [Server interface](https://golang.org/p
- [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)
* [1) Main executable](#1-main-executable)
* [2) API Configuration](#2-api-configuration)
- [Definition](#definition)
+ [Input Arguments](#input-arguments)
- [1. Input types](#1-input-types)
- [2. Global Format](#2-global-format)
- [III/ Change Log](#iii-change-log)
<!-- tocstop -->
@ -55,40 +55,49 @@ The library should now be available as `git.xdrm.io/go/aicra` in your imports.
#### 1) Main executable
The main executable will declare and run the aicra server, it might look quite like the code below.
Your main executable will declare and run the aicra server, it might look quite like the code below.
```go
package main
import (
"log"
"net/http"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/typecheck/builtin"
"git.xdrm.io/go/aicra/api"
import (
"log"
"net/http"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func main() {
// 1. build server
server, err := aicra.New("path/to/your/api/definition.json");
if err != nil {
log.Fatalf("Cannot build the aicra server: %v\n", err)
}
// 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{})
// 2. add type checkers
server.Checkers.Add( builtin.NewAny() );
server.Checkers.Add( builtin.NewString() );
server.Checkers.Add( builtin.NewFloat64() );
// 2. create the server from the configuration file
server, err := aicra.New("path/to/your/api/definition.json", dtypes...)
if err != nil {
log.Fatalf("cannot built aicra server: %s\n", err)
}
// 3. bind your implementations
server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){
// ... process stuff ...
res.SetError(api.ErrorSuccess());
})
// 3. bind your implementations
server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){
// ... process stuff ...
res.SetError(api.ErrorSuccess());
})
// 4. launch server
log.Fatal( http.ListenAndServe("localhost:8181", server) )
// 4. extract to http server
httpServer, err := server.ToHTTPServer()
if err != nil {
log.Fatalf("cannot get to http server: %s", err)
}
// 4. launch server
log.Fatal( http.ListenAndServe("localhost:8080", server) )
}
```
@ -96,33 +105,24 @@ func main() {
#### 2) API Configuration
The whole project behavior is described inside a json file (_e.g. usually api.json_) file. 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 :
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 :
- resource routes and their methods
- routes and their methods
- every input for each method (called *argument*)
- every output for each method
- scope permissions (list of permissions needed for clients to use which method)
- scope permissions (list of permissions needed by clients)
- input policy :
- type of argument (_i.e. for type checkers_)
- required/optional
- default value
- variable renaming
- type of argument (_i.e. for data types_)
- required/optional
- variable renaming
###### Definition
At the root of the json file are available 5 field names :
The root of the json file must be an array containing your requests definitions.
1. `GET` - to define what to do when receiving a request with a GET HTTP method at the root URI
2. `POST` - to define what to do when receiving a request with a POST HTTP method at the root URI
3. `PUT` - to define what to do when receiving a request with a PUT HTTP method at the root URI
4. `DELETE` - to define what to do when receiving a request with a DELETE HTTP method at the root URI
5. `/` - to define children URIs ; each will have the same available fields
For each method you will have to create fields described in the table above.
For each, you will have to create fields described in the table above.
| field path | description | example |
| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
@ -138,7 +138,7 @@ For each method you will have to create fields described in the table above.
Input arguments defines what data from the HTTP request the method needs. Aicra is able to extract 3 types of data :
- **URI** - Slash-separated strings right after the resource URI. For instance, if your controller is bound to the `/user` URI, you can use the *URI slot* right after to send the user ID ; Now a client can send requests to the URI `/user/:id` where `:id` is a number sent by the client. This kind of input cannot be extracted by name, but rather by index in the URL (_begins at 0_).
- **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.
@ -150,38 +150,38 @@ Input arguments defines what data from the HTTP request the method needs. Aicra
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 must be <u>prefixed</u> when requesting **URI** or **Query** input types.
> Variable names from **URI** or **Query** must be named accordingly :
>
> - The first **URI** data has to be named `URL#0`, the second one `URL#1` and so on...
> - The variable named `somevar` in the **Query** has to be named `GET@somvar` in the configuration.
> - the **URI** variable `{id}` from your request route must be named `{id}`.
> - the variable `somevar` in the **Query** has to be names `GET@somevar`.
**Example**
In this example we want 3 arguments :
- the 1^st^ one is send at the end of the URI and is a number compliant with the `int` type checker (else the controller will not be run). It is renamed `uri-param`, this new name will be sent to the controller.
- 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 `int` or not given at all (the `?` at the beginning of the type tells that the argument is **optional**) ; it will be named `get-param`.
- 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 controller with the name `multipart-var`.
- 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`.
```json
"in": {
// arg 1
"URL#0": {
"info": "some integer in the URI",
"type": "int",
"name": "uri-param"
},
// arg 2
"GET@get-var": {
"info": "some Query OPTIONAL variable",
"type": "?int",
"name": "get-param"
},
// arg 3
"multipart-var": { /* ... */ }
}
[
{
"method": "PUT",
"path": "/article/{id}",
"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" }
},
"out": {
"id": { "info": "updated article id", "type": "uint" },
"title": { "info": "updated article title", "type": "string" },
"content": { "info": "updated article content", "type": "string" }
}
}
]
```
@ -190,26 +190,26 @@ In this example we want 3 arguments :
- [x] human-readable json configuration
- [x] nested routes (*i.e. `/user/:id:` and `/user/post/:id:`*)
- [ ] nested URL arguments (*i.e. `/user/:id:` and `/user/:id:/post/:id:`*)
- [x] nested URL arguments (*i.e. `/user/:id:` and `/user/:id:/post/:id:`*)
- [x] useful http methods: GET, POST, PUT, DELETE
- [x] manage URL, query and body arguments:
- [x] multipart/form-data (variables and file uploads)
- [x] application/x-www-form-urlencoded
- [x] application/json
- [x] 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] `int` - see go types
- [x] `uint` - see go types
- [x] `float` - see go types
- [x] `string` - any text
- [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] `any` - wildcard matching all values
- [x] `int` - see go types
- [x] `uint` - see go types
- [x] `float` - see go types
- [x] `string` - any text
- [x] `string(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)
- [x] response interface
- [x] log bound resources when building the aicra server
- [ ] fail on check for unimplemented resources at server boot.
- [ ] fail on check for unavailable types in api.json at server boot.
- [x] fail on check for unimplemented resources at server boot.
- [x] fail on check for unavailable types in api.json at server boot.

View File

@ -1,78 +1,94 @@
package api
var (
// ErrorSuccess represents a generic successful service execution
ErrorSuccess = func() Error { return Error{0, "all right", nil} }
// ErrorFailure is the most generic error
ErrorFailure = func() Error { return Error{1, "it failed", nil} }
// ErrorUnknown represents any error which cause is unknown.
// It might also be used for debug purposes as this error
// has to be used the less possible
ErrorUnknown = func() Error { return Error{-1, "", nil} }
ErrorUnknown Error = -1
// ErrorSuccess represents a generic successful service execution
ErrorSuccess Error = 0
// ErrorFailure is the most generic error
ErrorFailure Error = 1
// ErrorNoMatchFound has to be set when trying to fetch data and there is no result
ErrorNoMatchFound = func() Error { return Error{2, "no resource found", nil} }
ErrorNoMatchFound Error = 2
// ErrorAlreadyExists has to be set when trying to insert data, but identifiers or
// unique fields already exists
ErrorAlreadyExists = func() Error { return Error{3, "resource already exists", nil} }
ErrorAlreadyExists Error = 3
// ErrorConfig has to be set when there is a configuration error
ErrorConfig = func() Error { return Error{4, "configuration error", nil} }
ErrorConfig Error = 4
// ErrorUpload has to be set when a file upload failed
ErrorUpload = func() Error { return Error{100, "upload failed", nil} }
ErrorUpload Error = 100
// ErrorDownload has to be set when a file download failed
ErrorDownload = func() Error { return Error{101, "download failed", nil} }
ErrorDownload Error = 101
// MissingDownloadHeaders has to be set when the implementation
// of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its HEADER field
MissingDownloadHeaders = func() Error { return Error{102, "download headers are missing", nil} }
MissingDownloadHeaders Error = 102
// ErrorMissingDownloadBody has to be set when the implementation
// of a service of type 'download' (which returns a file instead of
// a set or output fields) is missing its BODY field
ErrorMissingDownloadBody = func() Error { return Error{103, "download body is missing", nil} }
ErrorMissingDownloadBody Error = 103
// ErrorUnknownService is set when there is no service matching
// the http request URI.
ErrorUnknownService = func() Error { return Error{200, "unknown service", nil} }
// ErrorUnknownMethod is set when there is no method matching the
// request's http method
ErrorUnknownMethod = func() Error { return Error{201, "unknown method", nil} }
ErrorUnknownService Error = 200
// ErrorUncallableService is set when there the requested service's
// implementation (plugin file) is not found/callable
ErrorUncallableService = func() Error { return Error{202, "uncallable service", nil} }
ErrorUncallableService Error = 202
// ErrorUncallableMethod is set when there the requested service's
// implementation does not features the requested method
ErrorUncallableMethod = func() Error { return Error{203, "uncallable method", nil} }
// ErrorNotImplemented is set when a handler is not implemented yet
ErrorNotImplemented Error = 203
// ErrorPermission is set when there is a permission error by default
// the api returns a permission error when the current scope (built
// by middlewares) does not match the scope required in the config.
// You can add your own permission policy and use this error
ErrorPermission = func() Error { return Error{300, "permission error", nil} }
ErrorPermission Error = 300
// ErrorToken has to be set (usually in authentication middleware) to tell
// the user that this authentication token is expired or invalid
ErrorToken = func() Error { return Error{301, "token error", nil} }
ErrorToken Error = 301
// ErrorMissingParam is set when a *required* parameter is missing from the
// http request
ErrorMissingParam = func() Error { return Error{400, "missing parameter", nil} }
ErrorMissingParam Error = 400
// ErrorInvalidParam is set when a given parameter fails its type check as
// defined in the config file.
ErrorInvalidParam = func() Error { return Error{401, "invalid parameter", nil} }
ErrorInvalidParam Error = 401
// ErrorInvalidDefaultParam is set when an optional parameter's default value
// does not match its type.
ErrorInvalidDefaultParam = func() Error { return Error{402, "invalid default param", nil} }
ErrorInvalidDefaultParam Error = 402
)
var errorReasons = map[Error]string{
ErrorUnknown: "unknown error",
ErrorSuccess: "all right",
ErrorFailure: "it failed",
ErrorNoMatchFound: "resource not found",
ErrorAlreadyExists: "already exists",
ErrorConfig: "configuration error",
ErrorUpload: "upload failed",
ErrorDownload: "download failed",
MissingDownloadHeaders: "download headers are missing",
ErrorMissingDownloadBody: "download body is missing",
ErrorUnknownService: "unknown service",
ErrorUncallableService: "uncallable service",
ErrorNotImplemented: "not implemented",
ErrorPermission: "permission error",
ErrorToken: "token error",
ErrorMissingParam: "missing parameter",
ErrorInvalidParam: "invalid parameter",
ErrorInvalidDefaultParam: "invalid default param",
}

View File

@ -1,40 +1,42 @@
package api
import (
"encoding/json"
"fmt"
)
// Error represents an http response error following the api format.
// These are used by the services to set the *execution status*
// directly into the response as JSON alongside response output fields.
type Error struct {
Code int `json:"code"`
Reason string `json:"reason"`
Arguments []interface{} `json:"arguments"`
}
type Error int
// SetArguments set one or multiple arguments to the error
// to be displayed back to API caller
func (e *Error) SetArguments(arg0 interface{}, args ...interface{}) {
// 1. clear arguments */
e.Arguments = make([]interface{}, 0)
// 2. add arg[0]
e.Arguments = append(e.Arguments, arg0)
// 3. add optional other arguments
for _, arg := range args {
e.Arguments = append(e.Arguments, arg)
}
}
// Implements 'error'
// Error implements the error interface
func (e Error) Error() string {
if e.Arguments == nil || len(e.Arguments) < 1 {
return fmt.Sprintf("[%d] %s", e.Code, e.Reason)
// use unknown error if no reason
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.Error()
}
return fmt.Sprintf("[%d] %s (%v)", e.Code, e.Reason, e.Arguments)
return fmt.Sprintf("[%d] %s", e, reason)
}
// MarshalJSON implements encoding/json.Marshaler interface
func (e Error) MarshalJSON() ([]byte, error) {
// use unknown error if no reason
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.MarshalJSON()
}
// format to proper struct
formatted := struct {
Code int `json:"code"`
Reason string `json:"reason"`
}{
Code: int(e),
Reason: reason,
}
return json.Marshal(formatted)
}

View File

@ -1,39 +0,0 @@
package api
import (
"strings"
)
// HandlerFunc manages an API request
type HandlerFunc func(Request, *Response)
// Handler is an API handler ready to be bound
type Handler struct {
path string
method string
handle HandlerFunc
}
// NewHandler builds a handler from its http method and path
func NewHandler(method, path string, handlerFunc HandlerFunc) *Handler {
return &Handler{
path: path,
method: strings.ToUpper(method),
handle: handlerFunc,
}
}
// Handle fires a handler
func (h *Handler) Handle(req Request, res *Response) {
h.handle(req, res)
}
// GetMethod returns the handler's HTTP method
func (h *Handler) GetMethod() string {
return h.method
}
// GetPath returns the handler's path
func (h *Handler) GetPath() string {
return h.path
}

View File

@ -2,15 +2,21 @@ package api
import (
"fmt"
"git.xdrm.io/go/aicra/internal/cerr"
)
// 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.Error("request parameter 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.Error("request parameter does not fulfills type")
const ErrReqParamNotType = cerr("request parameter does not fulfills type")
// RequestParam defines input parameters of an api request
type RequestParam map[string]interface{}

View File

@ -16,29 +16,20 @@ type Response struct {
err Error
}
// NewResponse creates an empty response. An optional error can be passed as its first argument.
func NewResponse(errors ...Error) *Response {
res := &Response{
// EmptyResponse creates an empty response.
func EmptyResponse() *Response {
return &Response{
Status: http.StatusOK,
Data: make(ResponseData),
err: ErrorFailure(),
err: ErrorFailure,
Headers: make(http.Header),
}
// optional error
if len(errors) == 1 {
res.err = errors[0]
}
return res
}
// SetError sets the error from a base error with error arguments.
func (res *Response) SetError(baseError Error, arguments ...interface{}) {
if len(arguments) > 0 {
baseError.SetArguments(arguments[0], arguments[1:]...)
}
res.err = baseError
// WithError sets the error from a base error with error arguments.
func (res *Response) WithError(err Error) *Response {
res.err = err
return res
}
// Error implements the error interface and dispatches to internal error.

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

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

View File

@ -4,26 +4,13 @@ import (
"fmt"
"testing"
"git.xdrm.io/go/aicra/typecheck/builtin"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestAny_New(t *testing.T) {
t.Parallel()
inst := interface{}(builtin.NewAny())
switch cast := inst.(type) {
case *builtin.Any:
return
default:
t.Errorf("expect %T ; got %T", &builtin.Any{}, cast)
}
}
func TestAny_AvailableTypes(t *testing.T) {
t.Parallel()
inst := builtin.NewAny()
dt := builtin.AnyDataType{}
tests := []struct {
Type string
@ -39,9 +26,9 @@ func TestAny_AvailableTypes(t *testing.T) {
}
for _, test := range tests {
checker := inst.Checker(test.Type)
validator := dt.Build(test.Type)
if checker == nil {
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
}
@ -60,8 +47,8 @@ func TestAny_AlwaysTrue(t *testing.T) {
const typeName = "any"
checker := builtin.NewAny().Checker(typeName)
if checker == nil {
validator := builtin.AnyDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
}
@ -76,7 +63,7 @@ func TestAny_AlwaysTrue(t *testing.T) {
for i, value := range values {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
if !checker(value) {
if _, isValid := validator(value); !isValid {
t.Errorf("expect value to be valid")
t.Fail()
}

40
datatype/builtin/bool.go Normal file
View File

@ -0,0 +1,40 @@
package builtin
import (
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// BoolDataType is what its name tells
type BoolDataType struct{}
// Type returns the type of data
func (BoolDataType) Type() reflect.Type {
return reflect.TypeOf(true)
}
// Build returns the validator
func (BoolDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "bool" {
return nil
}
return func(value interface{}) (interface{}, bool) {
switch cast := value.(type) {
case bool:
return cast, true
case string:
strVal := string(cast)
return strVal == "true", strVal == "true" || strVal == "false"
case []byte:
strVal := string(cast)
return strVal == "true", strVal == "true" || strVal == "false"
default:
return false, false
}
}
}

View File

@ -4,26 +4,13 @@ import (
"fmt"
"testing"
"git.xdrm.io/go/aicra/typecheck/builtin"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestBool_New(t *testing.T) {
t.Parallel()
inst := interface{}(builtin.NewBool())
switch cast := inst.(type) {
case *builtin.Bool:
return
default:
t.Errorf("expect %T ; got %T", &builtin.Bool{}, cast)
}
}
func TestBool_AvailableTypes(t *testing.T) {
t.Parallel()
inst := builtin.NewBool()
dt := builtin.BoolDataType{}
tests := []struct {
Type string
@ -39,8 +26,8 @@ func TestBool_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
checker := inst.Checker(test.Type)
if checker == nil {
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()
@ -62,8 +49,8 @@ func TestBool_Values(t *testing.T) {
const typeName = "bool"
checker := builtin.NewBool().Checker(typeName)
if checker == nil {
validator := builtin.BoolDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
}
@ -98,7 +85,7 @@ func TestBool_Values(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
if checker(test.Value) {
if _, isValid := validator(test.Value); isValid {
if !test.Valid {
t.Errorf("expect value to be invalid")
t.Fail()

53
datatype/builtin/float.go Normal file
View File

@ -0,0 +1,53 @@
package builtin
import (
"encoding/json"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// FloatDataType is what its name tells
type FloatDataType struct{}
// Type returns the type of data
func (FloatDataType) Type() reflect.Type {
return reflect.TypeOf(float64(0))
}
// Build returns the validator
func (FloatDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "float64" && typeName != "float" {
return nil
}
return func(value interface{}) (interface{}, bool) {
switch cast := value.(type) {
case int:
return float64(cast), true
case uint:
return float64(cast), true
case float64:
return cast, true
// serialized string -> try to convert to float
case []byte:
num := json.Number(cast)
floatVal, err := num.Float64()
return floatVal, err == nil
case string:
num := json.Number(cast)
floatVal, err := num.Float64()
return floatVal, err == nil
// unknown type
default:
return 0, false
}
}
}

View File

@ -5,26 +5,13 @@ import (
"math"
"testing"
"git.xdrm.io/go/aicra/typecheck/builtin"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestFloat64_New(t *testing.T) {
t.Parallel()
inst := interface{}(builtin.NewFloat64())
switch cast := inst.(type) {
case *builtin.Float64:
return
default:
t.Errorf("expect %T ; got %T", &builtin.Float64{}, cast)
}
}
func TestFloat64_AvailableTypes(t *testing.T) {
t.Parallel()
inst := builtin.NewFloat64()
dt := builtin.FloatDataType{}
tests := []struct {
Type string
@ -46,8 +33,8 @@ func TestFloat64_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
checker := inst.Checker(test.Type)
if checker == nil {
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()
@ -69,8 +56,8 @@ func TestFloat64_Values(t *testing.T) {
const typeName = "float"
checker := builtin.NewFloat64().Checker(typeName)
if checker == nil {
validator := builtin.FloatDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
}
@ -110,7 +97,7 @@ func TestFloat64_Values(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
if checker(test.Value) {
if _, isValid := validator(test.Value); isValid {
if !test.Valid {
t.Errorf("expect value to be invalid")
t.Fail()

58
datatype/builtin/int.go Normal file
View File

@ -0,0 +1,58 @@
package builtin
import (
"encoding/json"
"math"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// IntDataType is what its name tells
type IntDataType struct{}
// Type returns the type of data
func (IntDataType) Type() reflect.Type {
return reflect.TypeOf(int(0))
}
// Build returns the validator
func (IntDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "int" {
return nil
}
return func(value interface{}) (interface{}, bool) {
switch cast := value.(type) {
case int:
return cast, true
case uint:
overflows := cast > math.MaxInt64
return int(cast), !overflows
case float64:
intVal := int(cast)
overflows := cast < float64(math.MinInt64) || cast > float64(math.MaxInt64)
return intVal, cast == float64(intVal) && !overflows
// serialized string -> try to convert to float
case string:
num := json.Number(cast)
intVal, err := num.Int64()
return int(intVal), err == nil
// serialized string -> try to convert to float
case []byte:
num := json.Number(cast)
intVal, err := num.Int64()
return int(intVal), err == nil
// unknown type
default:
return 0, false
}
}
}

View File

@ -5,26 +5,13 @@ import (
"math"
"testing"
"git.xdrm.io/go/aicra/typecheck/builtin"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestInt_New(t *testing.T) {
t.Parallel()
inst := interface{}(builtin.NewInt())
switch cast := inst.(type) {
case *builtin.Int:
return
default:
t.Errorf("expect %T ; got %T", &builtin.Int{}, cast)
}
}
func TestInt_AvailableTypes(t *testing.T) {
t.Parallel()
inst := builtin.NewInt()
dt := builtin.IntDataType{}
tests := []struct {
Type string
@ -40,8 +27,8 @@ func TestInt_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
checker := inst.Checker(test.Type)
if checker == nil {
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()
@ -63,8 +50,8 @@ func TestInt_Values(t *testing.T) {
const typeName = "int"
checker := builtin.NewInt().Checker(typeName)
if checker == nil {
validator := builtin.IntDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
}
@ -110,7 +97,7 @@ func TestInt_Values(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
if checker(test.Value) {
if _, isValid := validator(test.Value); isValid {
if !test.Valid {
t.Errorf("expect value to be invalid")
t.Fail()

View File

@ -1,31 +1,33 @@
package builtin
import (
"reflect"
"regexp"
"strconv"
"git.xdrm.io/go/aicra/typecheck"
"git.xdrm.io/go/aicra/datatype"
)
var fixedLengthRegex = regexp.MustCompile(`^string\((\d+)\)$`)
var variableLengthRegex = regexp.MustCompile(`^string\((\d+), ?(\d+)\)$`)
// String checks if a value is a string
type String struct{}
// StringDataType is what its name tells
type StringDataType struct{}
// NewString returns a bare string type checker
func NewString() *String {
return &String{}
// Type returns the type of data
func (StringDataType) Type() reflect.Type {
return reflect.TypeOf(string(""))
}
// Checker returns the checker function. Availables type names are : `string`, `string(length)` and `string(minLength, maxLength)`.
func (s String) Checker(typeName string) typecheck.CheckerFunc {
isSimpleString := typeName == "string"
// Build returns the validator.
// availables type names are : `string`, `string(length)` and `string(minLength, maxLength)`.
func (s StringDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
simple := typeName == "string"
fixedLengthMatches := fixedLengthRegex.FindStringSubmatch(typeName)
variableLengthMatches := variableLengthRegex.FindStringSubmatch(typeName)
// nothing if type not handled
if !isSimpleString && fixedLengthMatches == nil && variableLengthMatches == nil {
if !simple && fixedLengthMatches == nil && variableLengthMatches == nil {
return nil
}
@ -53,10 +55,10 @@ func (s String) Checker(typeName string) typecheck.CheckerFunc {
max = exMax
}
return func(value interface{}) bool {
return func(value interface{}) (interface{}, bool) {
// preprocessing error
if mustFail {
return false
return "", false
}
// check type
@ -68,21 +70,21 @@ func (s String) Checker(typeName string) typecheck.CheckerFunc {
}
if !isString {
return false
return "", false
}
if isSimpleString {
return true
if simple {
return strValue, true
}
// check length against previously extracted length
l := len(strValue)
return l >= min && l <= max
return strValue, l >= min && l <= max
}
}
// getFixedLength returns the fixed length from regex matches and a success state.
func (String) getFixedLength(regexMatches []string) (int, bool) {
func (StringDataType) getFixedLength(regexMatches []string) (int, bool) {
// incoherence error
if regexMatches == nil || len(regexMatches) < 2 {
return 0, false
@ -98,7 +100,7 @@ func (String) getFixedLength(regexMatches []string) (int, bool) {
}
// getVariableLength returns the length min and max from regex matches and a success state.
func (String) getVariableLength(regexMatches []string) (int, int, bool) {
func (StringDataType) getVariableLength(regexMatches []string) (int, int, bool) {
// incoherence error
if regexMatches == nil || len(regexMatches) < 3 {
return 0, 0, false

View File

@ -4,26 +4,13 @@ import (
"fmt"
"testing"
"git.xdrm.io/go/aicra/typecheck/builtin"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestString_New(t *testing.T) {
t.Parallel()
inst := interface{}(builtin.NewString())
switch cast := inst.(type) {
case *builtin.String:
return
default:
t.Errorf("expect %T ; got %T", &builtin.String{}, cast)
}
}
func TestString_AvailableTypes(t *testing.T) {
t.Parallel()
inst := builtin.NewString()
dt := builtin.StringDataType{}
tests := []struct {
Type string
@ -66,9 +53,9 @@ func TestString_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
checker := inst.Checker(test.Type)
validator := dt.Build(test.Type)
if checker == nil {
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
}
@ -88,8 +75,8 @@ func TestString_AnyLength(t *testing.T) {
const typeName = "string"
checker := builtin.NewString().Checker(typeName)
if checker == nil {
validator := builtin.StringDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
}
@ -107,7 +94,7 @@ func TestString_AnyLength(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
if checker(test.Value) {
if _, isValid := validator(test.Value); isValid {
if !test.Valid {
t.Errorf("expect value to be invalid")
t.Fail()
@ -146,14 +133,14 @@ func TestString_FixedLength(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
checker := builtin.NewString().Checker(test.Type)
if checker == nil {
validator := builtin.StringDataType{}.Build(test.Type)
if validator == nil {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()
return
}
if checker(test.Value) {
if _, isValid := validator(test.Value); isValid {
if !test.Valid {
t.Errorf("expect value to be invalid")
t.Fail()
@ -207,14 +194,14 @@ func TestString_VariableLength(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
checker := builtin.NewString().Checker(test.Type)
if checker == nil {
validator := builtin.StringDataType{}.Build(test.Type)
if validator == nil {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()
return
}
if checker(test.Value) {
if _, isValid := validator(test.Value); isValid {
if !test.Valid {
t.Errorf("expect value to be invalid")
t.Fail()

64
datatype/builtin/uint.go Normal file
View File

@ -0,0 +1,64 @@
package builtin
import (
"encoding/json"
"math"
"reflect"
"git.xdrm.io/go/aicra/datatype"
)
// UintDataType is what its name tells
type UintDataType struct{}
// Type returns the type of data
func (UintDataType) Type() reflect.Type {
return reflect.TypeOf(uint(0))
}
// Build returns the validator
func (UintDataType) Build(typeName string, registry ...datatype.T) datatype.Validator {
// nothing if type not handled
if typeName != "uint" {
return nil
}
return func(value interface{}) (interface{}, bool) {
switch cast := value.(type) {
case int:
return uint(cast), cast >= 0
case uint:
return cast, true
case float64:
uintVal := uint(cast)
overflows := cast < 0 || cast > math.MaxUint64
return uintVal, cast == float64(uintVal) && !overflows
// serialized string -> try to convert to float
case string:
num := json.Number(cast)
floatVal, err := num.Float64()
if err != nil {
return 0, false
}
overflows := floatVal < 0 || floatVal > math.MaxUint64
return uint(floatVal), !overflows
case []byte:
num := json.Number(cast)
floatVal, err := num.Float64()
if err != nil {
return 0, false
}
overflows := floatVal < 0 || floatVal > math.MaxUint64
return uint(floatVal), !overflows
// unknown type
default:
return 0, false
}
}
}

View File

@ -5,26 +5,13 @@ import (
"math"
"testing"
"git.xdrm.io/go/aicra/typecheck/builtin"
"git.xdrm.io/go/aicra/datatype/builtin"
)
func TestUint_New(t *testing.T) {
t.Parallel()
inst := interface{}(builtin.NewUint())
switch cast := inst.(type) {
case *builtin.Uint:
return
default:
t.Errorf("expect %T ; got %T", &builtin.Uint{}, cast)
}
}
func TestUint_AvailableTypes(t *testing.T) {
t.Parallel()
inst := builtin.NewUint()
dt := builtin.UintDataType{}
tests := []struct {
Type string
@ -40,8 +27,8 @@ func TestUint_AvailableTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.Type, func(t *testing.T) {
checker := inst.Checker(test.Type)
if checker == nil {
validator := dt.Build(test.Type)
if validator == nil {
if test.Handled {
t.Errorf("expect %q to be handled", test.Type)
t.Fail()
@ -63,8 +50,8 @@ func TestUint_Values(t *testing.T) {
const typeName = "uint"
checker := builtin.NewUint().Checker(typeName)
if checker == nil {
validator := builtin.UintDataType{}.Build(typeName)
if validator == nil {
t.Errorf("expect %q to be handled", typeName)
t.Fail()
}
@ -110,7 +97,7 @@ func TestUint_Values(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
if checker(test.Value) {
if _, isValid := validator(test.Value); isValid {
if !test.Valid {
t.Errorf("expect value to be invalid")
t.Fail()

15
datatype/types.go Normal file
View File

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

48
dynamic/errors.go Normal file
View File

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

90
dynamic/handler.go Normal file
View File

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

119
dynamic/spec.go Normal file
View File

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

17
dynamic/types.go Normal file
View File

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

15
errors.go Normal file
View File

@ -0,0 +1,15 @@
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")
// ErrNoHandlerForService - no handler matching this service
const ErrNoHandlerForService = cerr("no handler found for this service")

0
go.sum Normal file
View File

32
handler.go Normal file
View File

@ -0,0 +1,32 @@
package aicra
import (
"fmt"
"strings"
"git.xdrm.io/go/aicra/dynamic"
"git.xdrm.io/go/aicra/internal/config"
)
type handler struct {
Method string
Path string
dynHandler *dynamic.Handler
}
// 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)
}
return &handler{
Path: path,
Method: method,
dynHandler: dynHandler,
}, nil
}

171
http.go
View File

@ -3,7 +3,6 @@ package aicra
import (
"log"
"net/http"
"strings"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/reqdata"
@ -13,103 +12,105 @@ import (
type httpServer Server
// ServeHTTP implements http.Handler and has to be called on each request
func (server httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
func (server httpServer) ServeHTTP(res http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
/* (1) create api.Request from http.Request
---------------------------------------------------------*/
request, err := api.NewRequest(r)
// 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)
}
// 2. find a matching service for this path in the config
serviceConf, pathIndex := server.config.Browse(request.URI)
if serviceConf == nil {
return
}
// 9. feed request with scope & parameters
apireq.Scope = service.Scope
apireq.Param = dataset.Data
// 3. extract the service path from request URI
servicePath := strings.Join(request.URI[:pathIndex], "/")
if !strings.HasPrefix(servicePath, "/") {
servicePath = "/" + servicePath
}
// 10. execute
returned, apiErr := foundHandler.dynHandler.Handle(dataset.Data)
response := api.EmptyResponse().WithError(apiErr)
for key, value := range returned {
// 4. find method configuration from http method */
var methodConf = serviceConf.Method(r.Method)
if methodConf == nil {
res := api.NewResponse(api.ErrorUnknownMethod())
res.ServeHTTP(w, r)
logError(res)
return
}
// 5. parse data from the request (uri, query, form, json)
data := reqdata.New(request.URI[pathIndex:], r)
/* (2) check parameters
---------------------------------------------------------*/
parameters, paramError := server.extractParameters(data, methodConf.Parameters)
// Fail if argument check failed
if paramError.Code != api.ErrorSuccess().Code {
res := api.NewResponse(paramError)
res.ServeHTTP(w, r)
logError(res)
return
}
request.Param = parameters
/* (3) search for the handler
---------------------------------------------------------*/
var foundHandler *api.Handler
var found bool
for _, handler := range server.handlers {
if handler.GetPath() == servicePath {
found = true
if handler.GetMethod() == r.Method {
foundHandler = handler
// find original name from rename
for name, param := range service.Output {
if param.Rename == key {
response.SetData(name, value)
}
}
}
// fail if found no handler
if foundHandler == nil {
if found {
res := api.NewResponse()
res.SetError(api.ErrorUncallableMethod(), servicePath, r.Method)
res.ServeHTTP(w, r)
logError(res)
return
}
res := api.NewResponse()
res.SetError(api.ErrorUncallableService(), servicePath)
res.ServeHTTP(w, r)
logError(res)
return
}
/* (4) execute handler and return response
---------------------------------------------------------*/
// 1. feed request with configuration scope
request.Scope = methodConf.Scope
// 2. execute
res := api.NewResponse()
foundHandler.Handle(*request, res)
// 3. apply headers
for key, values := range res.Headers {
// 11. apply headers
res.Header().Set("Content-Type", "application/json; charset=utf-8")
for key, values := range response.Headers {
for _, value := range values {
w.Header().Add(key, value)
res.Header().Add(key, value)
}
}
// 4. write to response
res.ServeHTTP(w, r)
return
// 12. write to response
response.ServeHTTP(res, req)
}

View File

@ -1,36 +0,0 @@
package cerr
// Error allows you to create constant "const" error with type boxing.
type Error string
// Error implements the error builtin interface.
func (err Error) Error() string {
return string(err)
}
// Wrap returns a new error which wraps a new error into itself.
func (err Error) Wrap(e error) *WrapError {
return &WrapError{
base: err,
wrap: e,
}
}
// WrapString returns a new error which wraps a new error created from a string.
func (err Error) WrapString(e string) *WrapError {
return &WrapError{
base: err,
wrap: Error(e),
}
}
// WrapError is way to wrap errors recursively.
type WrapError struct {
base error
wrap error
}
// Error implements the error builtin interface recursively.
func (err *WrapError) Error() string {
return err.base.Error() + ": " + err.wrap.Error()
}

View File

@ -1,57 +0,0 @@
package cerr
import (
"errors"
"fmt"
"testing"
)
func TestConstError(t *testing.T) {
const cerr1 = Error("some-string")
const cerr2 = Error("some-other-string")
const cerr3 = Error("some-string") // same const value as @cerr1
if cerr1.Error() == cerr2.Error() {
t.Errorf("cerr1 should not be equal to cerr2 ('%s', '%s')", cerr1.Error(), cerr2.Error())
}
if cerr2.Error() == cerr3.Error() {
t.Errorf("cerr2 should not be equal to cerr3 ('%s', '%s')", cerr2.Error(), cerr3.Error())
}
if cerr1.Error() != cerr3.Error() {
t.Errorf("cerr1 should be equal to cerr3 ('%s', '%s')", cerr1.Error(), cerr3.Error())
}
}
func TestWrappedConstError(t *testing.T) {
const parent = Error("file error")
const readErrorConst = Error("cannot read file")
var wrappedReadError = parent.Wrap(readErrorConst)
expectedWrappedReadError := fmt.Sprintf("%s: %s", parent.Error(), readErrorConst.Error())
if wrappedReadError.Error() != expectedWrappedReadError {
t.Errorf("expected '%s' (got '%s')", wrappedReadError.Error(), expectedWrappedReadError)
}
}
func TestWrappedStandardError(t *testing.T) {
const parent = Error("file error")
var writeErrorStandard error = errors.New("cannot write file")
var wrappedWriteError = parent.Wrap(writeErrorStandard)
expectedWrappedWriteError := fmt.Sprintf("%s: %s", parent.Error(), writeErrorStandard.Error())
if wrappedWriteError.Error() != expectedWrappedWriteError {
t.Errorf("expected '%s' (got '%s')", wrappedWriteError.Error(), expectedWrappedWriteError)
}
}
func TestWrappedStringError(t *testing.T) {
const parent = Error("file error")
var closeErrorString string = "cannot close file"
var wrappedCloseError = parent.WrapString(closeErrorString)
expectedWrappedCloseError := fmt.Sprintf("%s: %s", parent.Error(), closeErrorString)
if wrappedCloseError.Error() != expectedWrappedCloseError {
t.Errorf("expected '%s' (got '%s')", wrappedCloseError.Error(), expectedWrappedCloseError)
}
}

View File

@ -1,32 +0,0 @@
package config
import "net/http"
var availableHTTPMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}
// Service represents a service definition (from api.json)
type Service struct {
GET *Method `json:"GET"`
POST *Method `json:"POST"`
PUT *Method `json:"PUT"`
DELETE *Method `json:"DELETE"`
Children map[string]*Service `json:"/"`
}
// 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
Default *interface{} `json:"default"`
}
// Method represents a method definition (from api.json)
type Method struct {
Description string `json:"info"`
Scope [][]string `json:"scope"`
Parameters map[string]*Parameter `json:"in"`
Download *bool `json:"download"`
}

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,60 @@
package config
import "git.xdrm.io/go/aicra/internal/cerr"
// 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.Error("cannot read config")
const ErrRead = cerr("cannot read config")
// ErrUnknownMethod - invalid http method
const ErrUnknownMethod = cerr("unknown HTTP method")
// ErrFormat - a invalid format has been detected
const ErrFormat = cerr.Error("invalid config format")
const ErrFormat = cerr("invalid config format")
// ErrIllegalServiceName - an illegal character has been found in a service name
const ErrIllegalServiceName = cerr.Error("service must not contain any slash '/' nor '-' symbols")
// ErrPatternCollision - there is a collision between 2 services' patterns (same method)
const ErrPatternCollision = cerr("pattern collision")
// ErrMissingMethodDesc - a method is missing its description
const ErrMissingMethodDesc = cerr.Error("missing method description")
// ErrInvalidPattern - a service pattern is malformed
const ErrInvalidPattern = cerr("must begin with a '/' and not end with")
// ErrInvalidPatternBraceCapture - a service pattern brace capture is invalid
const ErrInvalidPatternBraceCapture = cerr("invalid uri capturing braces")
// ErrUnspecifiedBraceCapture - a parameter brace capture is not specified in the pattern
const ErrUnspecifiedBraceCapture = cerr("capturing brace missing in the path")
// ErrMandatoryRename - capture/query parameters must have a rename
const ErrMandatoryRename = cerr("capture and query parameters must have a 'name'")
// ErrUndefinedBraceCapture - a parameter brace capture in the pattern is not defined in parameters
const ErrUndefinedBraceCapture = cerr("capturing brace missing input definition")
// ErrMissingDescription - a service is missing its description
const ErrMissingDescription = cerr("missing description")
// ErrIllegalOptionalURIParam - an URI parameter cannot be optional
const ErrIllegalOptionalURIParam = cerr("URI parameter cannot be optional")
// ErrOptionalOption - an output is optional
const ErrOptionalOption = cerr("output cannot be optional")
// ErrMissingParamDesc - a parameter is missing its description
const ErrMissingParamDesc = cerr.Error("missing parameter description")
const ErrMissingParamDesc = cerr("missing parameter description")
// ErrUnknownDataType - a parameter has an unknown datatype name
const ErrUnknownDataType = cerr("unknown data type")
// ErrIllegalParamName - a parameter has an illegal name
const ErrIllegalParamName = cerr.Error("illegal parameter name (must not begin/end with '_')")
const ErrIllegalParamName = cerr("illegal parameter name")
// ErrMissingParamType - a parameter has an illegal type
const ErrMissingParamType = cerr.Error("missing parameter type")
const ErrMissingParamType = cerr("missing parameter type")
// ErrParamNameConflict - a parameter has a conflict with its name/rename field
const ErrParamNameConflict = cerr.Error("name conflict for parameter")
const ErrParamNameConflict = cerr("name conflict for parameter")

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

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

View File

@ -1,69 +0,0 @@
package config
import (
"strings"
)
// checkAndFormat checks for errors and missing fields and sets default values for optional fields.
func (methodDef *Method) checkAndFormat(servicePath string, httpMethod string) error {
// 1. fail on missing description
if len(methodDef.Description) < 1 {
return ErrMissingMethodDesc.WrapString(httpMethod + " " + servicePath)
}
// 2. stop if no parameter
if methodDef.Parameters == nil || len(methodDef.Parameters) < 1 {
methodDef.Parameters = make(map[string]*Parameter, 0)
return nil
}
// 3. for each parameter
for pName, pData := range methodDef.Parameters {
// 3.1. check name
if strings.Trim(pName, "_") != pName {
return ErrIllegalParamName.WrapString(httpMethod + " " + servicePath + " {" + pName + "}")
}
if len(pData.Rename) < 1 {
pData.Rename = pName
}
// 3.2. Check for name/rename conflict
for paramName, param := range methodDef.Parameters {
// ignore self
if pName == paramName {
continue
}
// 3.2.1. Same rename field
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if pData.Rename == param.Rename || pName == param.Rename || pData.Rename == paramName {
return ErrParamNameConflict.WrapString(httpMethod + " " + servicePath + " {" + pName + "}")
}
}
// 3.3. Fail on missing description
if len(pData.Description) < 1 {
return ErrMissingParamDesc.WrapString(httpMethod + " " + servicePath + " {" + pName + "}")
}
// 3.4. Manage invalid type
if len(pData.Type) < 1 || pData.Type == "?" {
return ErrMissingParamType.WrapString(httpMethod + " " + servicePath + " {" + pName + "}")
}
// 3.5. Set optional + type
if pData.Type[0] == '?' {
pData.Optional = true
pData.Type = pData.Type[1:]
}
}
return nil
}

View File

@ -0,0 +1,38 @@
package config
import (
"git.xdrm.io/go/aicra/datatype"
)
// Validate implements the validator interface
func (param *Parameter) Validate(datatypes ...datatype.T) error {
// missing description
if len(param.Description) < 1 {
return ErrMissingParamDesc
}
// invalid type
if len(param.Type) < 1 || param.Type == "?" {
return ErrMissingParamType
}
// optional type transform
if param.Type[0] == '?' {
param.Optional = true
param.Type = param.Type[1:]
}
// assign the datatype
for _, dtype := range datatypes {
param.Validator = dtype.Build(param.Type, datatypes...)
param.ExtractType = dtype.Type()
if param.Validator != nil {
break
}
}
if param.Validator == nil {
return ErrUnknownDataType
}
return nil
}

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

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

View File

@ -1,104 +1,314 @@
package config
import (
"encoding/json"
"io"
"fmt"
"net/http"
"regexp"
"strings"
"git.xdrm.io/go/aicra/datatype"
)
// Parse builds a service from a json reader and checks for most format errors.
func Parse(r io.Reader) (*Service, error) {
receiver := &Service{}
var braceRegex = regexp.MustCompile(`^{([a-z_-]+)}$`)
var queryRegex = regexp.MustCompile(`^GET@([a-z_-]+)$`)
err := json.NewDecoder(r).Decode(receiver)
if err != nil {
return nil, ErrRead.Wrap(err)
// 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
}
err = receiver.checkAndFormat("/")
if err != nil {
return nil, ErrFormat.Wrap(err)
// check path
if !svc.matchPattern(req.RequestURI) {
return false
}
return receiver, nil
// check and extract input
// todo: check if input match and extract models
return true
}
// Method returns the actual method from the http method.
func (svc *Service) Method(httpMethod string) *Method {
httpMethod = strings.ToUpper(httpMethod)
// checks if an uri matches the service's pattern
func (svc *Service) matchPattern(uri string) bool {
uriparts := SplitURL(uri)
parts := SplitURL(svc.Pattern)
switch httpMethod {
case http.MethodGet:
return svc.GET
case http.MethodPost:
return svc.POST
case http.MethodPut:
return svc.PUT
case http.MethodDelete:
return svc.DELETE
// fail if size differ
if len(uriparts) != len(parts) {
return false
}
return nil
}
// Browse the service childtree and returns the deepest matching child. The `path` is a formatted URL split by '/'
func (svc *Service) Browse(path []string) (*Service, int) {
currentService := svc
var depth int
// for each URI depth
for depth = 0; depth < len(path); depth++ {
currentPath := path[depth]
child, exists := currentService.Children[currentPath]
if !exists {
break
}
currentService = child
// root url '/'
if len(parts) == 0 {
return true
}
return currentService, depth
}
// check part by part
for i, part := range parts {
uripart := uriparts[i]
// checkAndFormat checks for errors and missing fields and sets default values for optional fields.
func (svc *Service) checkAndFormat(servicePath string) error {
isCapture := len(part) > 0 && part[0] == '{'
// 1. check and format every method
for _, httpMethod := range availableHTTPMethods {
methodDef := svc.Method(httpMethod)
if methodDef == nil {
// if no capture -> check equality
if !isCapture {
if part != uripart {
return false
}
continue
}
err := methodDef.checkAndFormat(servicePath, httpMethod)
if err != nil {
return err
param, exists := svc.Input[part]
// fail if no validator
if !exists || param.Validator == nil {
return false
}
// fail if not type-valid
if _, valid := param.Validator(uripart); !valid {
return false
}
}
// 2. stop if no child */
if svc.Children == nil || len(svc.Children) < 1 {
return nil
return true
}
// Validate implements the validator interface
func (svc *Service) Validate(datatypes ...datatype.T) error {
// check method
err := svc.isMethodAvailable()
if err != nil {
return fmt.Errorf("field 'method': %w", err)
}
// 3. for each service */
for childService, ctl := range svc.Children {
// check pattern
svc.Pattern = strings.Trim(svc.Pattern, " \t\r\n")
err = svc.isPatternValid()
if err != nil {
return fmt.Errorf("field 'path': %w", err)
}
// 3.1. invalid name */
if strings.ContainsAny(childService, "/-") {
return ErrIllegalServiceName.WrapString(childService)
// check description
if len(strings.Trim(svc.Description, " \t\r\n")) < 1 {
return fmt.Errorf("field 'description': %w", ErrMissingDescription)
}
// check input parameters
err = svc.validateInput(datatypes)
if err != nil {
return fmt.Errorf("field 'in': %w", err)
}
// 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)
}
}
// check output
err = svc.validateOutput(datatypes)
if err != nil {
return fmt.Errorf("field 'out': %w", err)
}
return nil
}
func (svc *Service) isMethodAvailable() error {
for _, available := range availableHTTPMethods {
if svc.Method == available {
return nil
}
}
return ErrUnknownMethod
}
func (svc *Service) isPatternValid() error {
length := len(svc.Pattern)
// empty pattern
if length < 1 {
return ErrInvalidPattern
}
if length > 1 {
// pattern not starting with '/' or ending with '/'
if svc.Pattern[0] != '/' || svc.Pattern[length-1] == '/' {
return ErrInvalidPattern
}
}
// for each slash-separated chunk
parts := SplitURL(svc.Pattern)
for i, part := range parts {
if len(part) < 1 {
return ErrInvalidPattern
}
// 3.2. check recursively */
err := ctl.checkAndFormat(childService)
if err != nil {
return err
// if brace capture
if matches := braceRegex.FindAllStringSubmatch(part, -1); len(matches) > 0 && len(matches[0]) > 1 {
braceName := matches[0][1]
// append
if svc.Captures == nil {
svc.Captures = make([]*BraceCapture, 0)
}
svc.Captures = append(svc.Captures, &BraceCapture{
Index: i,
Name: braceName,
Ref: nil,
})
continue
}
// fail on invalid format
if strings.ContainsAny(part, "{}") {
return ErrInvalidPatternBraceCapture
}
}
return nil
}
func (svc *Service) validateInput(types []datatype.T) error {
// ignore no parameter
if svc.Input == nil || len(svc.Input) < 1 {
svc.Input = make(map[string]*Parameter, 0)
return nil
}
// for each parameter
for paramName, param := range svc.Input {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
}
// fail if brace capture does not exists in pattern
var iscapture, isquery bool
if matches := braceRegex.FindAllStringSubmatch(paramName, -1); len(matches) > 0 && len(matches[0]) > 1 {
braceName := matches[0][1]
found := false
for _, capture := range svc.Captures {
if capture.Name == braceName {
capture.Ref = param
found = true
break
}
}
if !found {
return fmt.Errorf("%s: %w", paramName, ErrUnspecifiedBraceCapture)
}
iscapture = true
} else if matches := queryRegex.FindAllStringSubmatch(paramName, -1); len(matches) > 0 && len(matches[0]) > 1 {
queryName := matches[0][1]
// init map
if svc.Query == nil {
svc.Query = make(map[string]*Parameter)
}
svc.Query[queryName] = param
isquery = true
} else {
if svc.Form == nil {
svc.Form = make(map[string]*Parameter)
}
svc.Form[paramName] = param
}
// fail if capture or query without rename
if len(param.Rename) < 1 && (iscapture || isquery) {
return fmt.Errorf("%s: %w", paramName, ErrMandatoryRename)
}
// use param name if no rename
if len(param.Rename) < 1 {
param.Rename = paramName
}
err := param.Validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
}
// capture parameter cannot be optional
if iscapture && param.Optional {
return fmt.Errorf("%s: %w", paramName, ErrIllegalOptionalURIParam)
}
// fail on name/rename conflict
for paramName2, param2 := range svc.Input {
// ignore self
if paramName == paramName2 {
continue
}
// 3.2.1. Same rename field
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if param.Rename == param2.Rename || paramName == param2.Rename || paramName2 == param.Rename {
return fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
}
}
}
return nil
}
func (svc *Service) validateOutput(types []datatype.T) error {
// ignore no parameter
if svc.Output == nil || len(svc.Output) < 1 {
svc.Output = make(map[string]*Parameter, 0)
return nil
}
// for each parameter
for paramName, param := range svc.Output {
if len(paramName) < 1 {
return fmt.Errorf("%s: %w", paramName, ErrIllegalParamName)
}
// use param name if no rename
if len(param.Rename) < 1 {
param.Rename = paramName
}
err := param.Validate(types...)
if err != nil {
return fmt.Errorf("%s: %w", paramName, err)
}
if param.Optional {
return fmt.Errorf("%s: %w", paramName, ErrOptionalOption)
}
// fail on name/rename conflict
for paramName2, param2 := range svc.Output {
// ignore self
if paramName == paramName2 {
continue
}
// 3.2.1. Same rename field
// 3.2.2. Not-renamed field matches a renamed field
// 3.2.3. Renamed field matches name
if param.Rename == param2.Rename || paramName == param2.Rename || paramName2 == param.Rename {
return fmt.Errorf("%s: %w", paramName, ErrParamNameConflict)
}
}
}
return nil
}

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

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

View File

@ -1,15 +1,21 @@
package multipart
import "git.xdrm.io/go/aicra/internal/cerr"
// 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.Error("data 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.Error("data name conflict")
const ErrDataNameConflict = cerr("data name conflict")
// ErrNoHeader is set when a multipart variable/file has no (valid) header
const ErrNoHeader = cerr.Error("data has no header")
const ErrNoHeader = cerr("data has no header")
// Component represents a multipart variable/file
type Component struct {

View File

@ -0,0 +1,30 @@
package reqdata
// 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)
}
// ErrUnknownType is returned when encountering an unknown type
const ErrUnknownType = cerr("unknown type")
// ErrInvalidMultipart is returned when multipart parse failed
const ErrInvalidMultipart = cerr("invalid multipart")
// ErrParseParameter is returned when a parameter fails when parsing
const ErrParseParameter = cerr("cannot parse parameter")
// ErrInvalidJSON is returned when json parse failed
const ErrInvalidJSON = cerr("invalid json")
// ErrMissingRequiredParam - required param is missing
const ErrMissingRequiredParam = cerr("missing required param")
// ErrInvalidType - parameter value does not satisfy its type
const ErrInvalidType = cerr("invalid type")
// ErrMissingURIParameter - missing an URI parameter
const ErrMissingURIParameter = cerr("missing URI parameter")

View File

@ -1,128 +0,0 @@
package reqdata
import (
"encoding/json"
"fmt"
"reflect"
"git.xdrm.io/go/aicra/internal/cerr"
)
// ErrUnknownType is returned when encountering an unknown type
const ErrUnknownType = cerr.Error("unknown type")
// ErrInvalidJSON is returned when json parse failed
const ErrInvalidJSON = cerr.Error("invalid json")
// ErrInvalidRootType is returned when json is a map
const ErrInvalidRootType = cerr.Error("invalid json root type")
// Parameter represents an http request parameter
// that can be of type URL, GET, or FORM (multipart, json, urlencoded)
type Parameter struct {
// whether the value has been json-parsed
// for optimisation purpose, parameters are only parsed
// if they are required by the current service
Parsed bool
// whether the value is a file
File bool
// the actual parameter value
Value interface{}
}
// Parse parameter (json-like) if not already done
func (i *Parameter) Parse() error {
/* (1) Stop if already parsed or nil*/
if i.Parsed || i.Value == nil {
return nil
}
/* (2) Try to parse value */
parsed, err := parseParameter(i.Value)
if err != nil {
return err
}
i.Parsed = true
i.Value = parsed
return nil
}
// parseParameter parses http GET/POST data
// - []string
// - size = 1 : return json of first element
// - size > 1 : return array of json elements
// - string : return json if valid, else return raw string
func parseParameter(data interface{}) (interface{}, error) {
dtype := reflect.TypeOf(data)
dvalue := reflect.ValueOf(data)
switch dtype.Kind() {
/* (1) []string -> recursive */
case reflect.Slice:
// 1. ignore empty
if dvalue.Len() == 0 {
return data, nil
}
// 2. parse each element recursively
result := make([]interface{}, dvalue.Len())
for i, l := 0, dvalue.Len(); i < l; i++ {
element := dvalue.Index(i)
// ignore non-string
if element.Kind() != reflect.String {
result[i] = element.Interface()
continue
}
parsed, err := parseParameter(element.String())
if err != nil {
return data, err
}
result[i] = parsed
}
return result, nil
/* (2) string -> parse */
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
if err == nil {
mapval, ok := result.(map[string]interface{})
if !ok {
return dvalue.String(), ErrInvalidRootType
}
wrapped, ok := mapval["wrapped"]
if !ok {
return dvalue.String(), ErrInvalidJSON
}
return wrapped, nil
}
// else return as string
return dvalue.String(), nil
}
/* (3) NIL if unknown type */
return dvalue.Interface(), nil
}

View File

@ -6,21 +6,9 @@ import (
)
func TestSimpleString(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: "some-string"}
p := parseParameter("some-string")
err := p.Parse()
if err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
cast, canCast := p.Value.(string)
cast, canCast := p.(string)
if !canCast {
t.Errorf("expected parameter to be a string")
t.FailNow()
@ -37,19 +25,9 @@ func TestSimpleFloat(t *testing.T) {
for i, tcase := range tcases {
t.Run("case "+string(i), func(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: tcase}
p := parseParameter(tcase)
if err := p.Parse(); err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
cast, canCast := p.Value.(float64)
cast, canCast := p.(float64)
if !canCast {
t.Errorf("expected parameter to be a float64")
t.FailNow()
@ -68,19 +46,9 @@ func TestSimpleBool(t *testing.T) {
for i, tcase := range tcases {
t.Run("case "+string(i), func(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: tcase}
p := parseParameter(tcase)
if err := p.Parse(); err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
cast, canCast := p.Value.(bool)
cast, canCast := p.(bool)
if !canCast {
t.Errorf("expected parameter to be a bool")
t.FailNow()
@ -95,21 +63,9 @@ func TestSimpleBool(t *testing.T) {
}
func TestJsonStringSlice(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: `["str1", "str2"]`}
p := parseParameter(`["str1", "str2"]`)
err := p.Parse()
if err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
slice, canCast := p.Value.([]interface{})
slice, canCast := p.([]interface{})
if !canCast {
t.Errorf("expected parameter to be a []interface{}")
t.FailNow()
@ -139,21 +95,9 @@ func TestJsonStringSlice(t *testing.T) {
}
func TestStringSlice(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: []string{"str1", "str2"}}
p := parseParameter([]string{"str1", "str2"})
err := p.Parse()
if err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
slice, canCast := p.Value.([]interface{})
slice, canCast := p.([]interface{})
if !canCast {
t.Errorf("expected parameter to be a []interface{}")
t.FailNow()
@ -193,20 +137,9 @@ func TestJsonPrimitiveBool(t *testing.T) {
for i, tcase := range tcases {
t.Run("case "+string(i), func(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: tcase.Raw}
p := parseParameter(tcase.Raw)
err := p.Parse()
if err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
cast, canCast := p.Value.(bool)
cast, canCast := p.(bool)
if !canCast {
t.Errorf("expected parameter to be a bool")
t.FailNow()
@ -241,20 +174,9 @@ func TestJsonPrimitiveFloat(t *testing.T) {
for i, tcase := range tcases {
t.Run("case "+string(i), func(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: tcase.Raw}
p := parseParameter(tcase.Raw)
err := p.Parse()
if err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
cast, canCast := p.Value.(float64)
cast, canCast := p.(float64)
if !canCast {
t.Errorf("expected parameter to be a float64")
t.FailNow()
@ -270,21 +192,9 @@ func TestJsonPrimitiveFloat(t *testing.T) {
}
func TestJsonBoolSlice(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: []string{"true", "false"}}
p := parseParameter([]string{"true", "false"})
err := p.Parse()
if err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
slice, canCast := p.Value.([]interface{})
slice, canCast := p.([]interface{})
if !canCast {
t.Errorf("expected parameter to be a []interface{}")
t.FailNow()
@ -314,21 +224,9 @@ func TestJsonBoolSlice(t *testing.T) {
}
func TestBoolSlice(t *testing.T) {
p := Parameter{Parsed: false, File: false, Value: []bool{true, false}}
p := parseParameter([]bool{true, false})
err := p.Parse()
if err != nil {
t.Errorf("unexpected error: <%s>", err)
t.FailNow()
}
if !p.Parsed {
t.Errorf("expected parameter to be parsed")
t.FailNow()
}
slice, canCast := p.Value.([]interface{})
slice, canCast := p.([]interface{})
if !canCast {
t.Errorf("expected parameter to be a []interface{}")
t.FailNow()

323
internal/reqdata/set.go Normal file
View File

@ -0,0 +1,323 @@
package reqdata
import (
"encoding/json"
"fmt"
"io"
"reflect"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/multipart"
"net/http"
"strings"
)
// Set represents all data that can be caught:
// - URI (from the URI)
// - GET (default url data)
// - POST (from json, form-data, url-encoded)
// - 'application/json' => key-value pair is parsed as json into the map
// - 'application/x-www-form-urlencoded' => standard parameters as QUERY parameters
// - 'multipart/form-data' => parse form-data format
type Set struct {
service *config.Service
// contains URL+GET+FORM data with prefixes:
// - FORM: no prefix
// - URL: '{uri_var}'
// - GET: 'GET@' followed by the key in GET
Data map[string]interface{}
}
// New creates a new empty store.
func New(service *config.Service) *Set {
return &Set{
service: service,
Data: make(map[string]interface{}),
}
}
// ExtractURI fills 'Set' with creating pointers inside 'Url'
func (i *Set) ExtractURI(req *http.Request) error {
uriparts := config.SplitURL(req.URL.RequestURI())
for _, capture := range i.service.Captures {
// out of range
if capture.Index > len(uriparts)-1 {
return fmt.Errorf("%s: %w", capture.Name, ErrMissingURIParameter)
}
value := uriparts[capture.Index]
// should not happen
if capture.Ref == nil {
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 {
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
//
// - 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
if req.Method == http.MethodGet {
return nil
}
contentType := req.Header.Get("Content-Type")
// parse json
if strings.HasPrefix(contentType, "application/json") {
return i.parseJSON(req)
}
// parse urlencoded
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
return i.parseUrlencoded(req)
}
// parse multipart
if strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
return i.parseMultipart(req)
}
// nothing to parse
return nil
}
// parseJSON parses JSON from the request body inside 'Form'
// and 'Set'
func (i *Set) parseJSON(req *http.Request) error {
parsed := make(map[string]interface{}, 0)
decoder := json.NewDecoder(req.Body)
if err := decoder.Decode(&parsed); err != nil {
if err == io.EOF {
return 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
}
return nil
}
// parseUrlencoded parses urlencoded from the request body inside 'Form'
// and 'Set'
func (i *Set) parseUrlencoded(req *http.Request) error {
// use http.Request interface
if err := req.ParseForm(); err != nil {
return err
}
for name, param := range i.service.Form {
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
}
return nil
}
// parseMultipart parses multi-part from the request body inside 'Form'
// and 'Set'
func (i *Set) parseMultipart(req *http.Request) error {
// 1. create reader
boundary := req.Header.Get("Content-Type")[len("multipart/form-data; boundary="):]
mpr, err := multipart.NewReader(req.Body, boundary)
if err != nil {
if err == io.EOF {
return nil
}
return err
}
// 2. parse multipart
if err = mpr.Parse(); err != nil {
return fmt.Errorf("%s: %w", err, ErrInvalidMultipart)
}
for name, param := range i.service.Form {
component, exist := mpr.Data[name]
// fail on missing required
if !exist && !param.Optional {
return fmt.Errorf("%s: %w", name, ErrMissingRequiredParam)
}
// optional
if !exist {
continue
}
// parse parameter
parsed := parseParameter(string(component.Data))
// fail on invalid type
cast, valid := param.Validator(parsed)
if !valid {
return fmt.Errorf("%s: %w", name, ErrInvalidType)
}
// store cast value
i.Data[param.Rename] = cast
}
return nil
}
// parseParameter parses http URI/GET/POST data
// - []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)
switch dtype.Kind() {
/* (1) []string -> recursive */
case reflect.Slice:
// 1. ignore empty
if dvalue.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())
}
return result
/* (2) string -> parse */
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
if err != nil {
return dvalue.String()
}
mapval, ok := result.(map[string]interface{})
if !ok {
return dvalue.String()
}
wrapped, ok := mapval["wrapped"]
if !ok {
return dvalue.String()
}
return wrapped
}
/* (3) NIL if unknown type */
return dvalue.Interface()
}

View File

@ -0,0 +1,784 @@
package reqdata
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"git.xdrm.io/go/aicra/internal/config"
)
func getEmptyService() *config.Service {
return &config.Service{}
}
func getServiceWithURI(capturingBraces ...string) *config.Service {
service := &config.Service{
Input: make(map[string]*config.Parameter),
}
index := 0
for _, capture := range capturingBraces {
if len(capture) == 0 {
index++
continue
}
id := fmt.Sprintf("{%s}", capture)
service.Input[id] = &config.Parameter{
Rename: capture,
Validator: func(value interface{}) (interface{}, bool) { return value, true },
}
service.Captures = append(service.Captures, &config.BraceCapture{
Name: capture,
Index: index,
Ref: service.Input[id],
})
index++
}
return service
}
func getServiceWithQuery(params ...string) *config.Service {
service := &config.Service{
Input: make(map[string]*config.Parameter),
Query: make(map[string]*config.Parameter),
}
for _, name := range params {
id := fmt.Sprintf("GET@%s", name)
service.Input[id] = &config.Parameter{
Rename: name,
Validator: func(value interface{}) (interface{}, bool) { return value, true },
}
service.Query[name] = service.Input[id]
}
return service
}
func getServiceWithForm(params ...string) *config.Service {
service := &config.Service{
Input: make(map[string]*config.Parameter),
Form: make(map[string]*config.Parameter),
}
for _, name := range params {
service.Input[name] = &config.Parameter{
Rename: name,
Validator: func(value interface{}) (interface{}, bool) { return value, true },
}
service.Form[name] = service.Input[name]
}
return service
}
func TestStoreWithUri(t *testing.T) {
tests := []struct {
ServiceParams []string
URI string
Err error
}{
{
[]string{},
"/non-captured/uri",
nil,
},
{
[]string{"missing"},
"/",
ErrMissingURIParameter,
},
{
[]string{"gotit", "missing"},
"/gotme",
ErrMissingURIParameter,
},
{
[]string{"gotit", "gotittoo"},
"/gotme/andme",
nil,
},
{
[]string{"gotit", "gotittoo"},
"/gotme/andme/ignored",
nil,
},
{
[]string{"first", "", "second"},
"/gotme/ignored/gotmetoo",
nil,
},
{
[]string{"first", "", "second"},
"/gotme/ignored",
ErrMissingURIParameter,
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("test.%d", i), func(t *testing.T) {
service := getServiceWithURI(test.ServiceParams...)
store := New(service)
req := httptest.NewRequest(http.MethodGet, "http://host.com"+test.URI, nil)
err := store.ExtractURI(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if len(store.Data) != len(service.Input) {
t.Errorf("store should contain %d elements, got %d", len(service.Input), len(store.Data))
t.Fail()
}
})
}
}
func TestExtractQuery(t *testing.T) {
tests := []struct {
ServiceParam []string
Query string
Err error
ParamNames []string
ParamValues [][]string
}{
{
ServiceParam: []string{},
Query: "",
Err: nil,
ParamNames: nil,
ParamValues: nil,
},
{
ServiceParam: []string{"missing"},
Query: "",
Err: ErrMissingRequiredParam,
ParamNames: nil,
ParamValues: nil,
},
{
ServiceParam: []string{"a"},
Query: "a",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParam: []string{"a"},
Query: "a&b",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParam: []string{"a", "missing"},
Query: "a&b",
Err: ErrMissingRequiredParam,
ParamNames: nil,
ParamValues: nil,
},
{
ServiceParam: []string{"a", "b"},
Query: "a&b",
Err: nil,
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{""}},
},
{
ServiceParam: []string{"a"},
Err: nil,
Query: "a=",
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParam: []string{"a", "b"},
Err: nil,
Query: "a=&b=x",
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{"x"}},
},
{
ServiceParam: []string{"a", "c"},
Err: nil,
Query: "a=b&c=d",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b"}, []string{"d"}},
},
{
ServiceParam: []string{"a", "c"},
Err: nil,
Query: "a=b&c=d&a=x",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
store := New(getServiceWithQuery(test.ServiceParam...))
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://host.com?%s", test.Query), nil)
err := store.ExtractQuery(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Errorf("expected no GET parameters and got %d", len(store.Data))
t.FailNow()
}
// no param to check
return
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
}
for pi, pName := range test.ParamNames {
values := test.ParamValues[pi]
t.Run(pName, func(t *testing.T) {
param, isset := store.Data[pName]
if !isset {
t.Errorf("param does not exist")
t.FailNow()
}
cast, canCast := param.([]interface{})
if !canCast {
t.Errorf("should return a []string (got '%v')", cast)
t.FailNow()
}
if len(cast) != len(values) {
t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast))
t.FailNow()
}
for vi, value := range values {
t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) {
if value != cast[vi] {
t.Errorf("should return '%s' (got '%s')", value, cast[vi])
t.FailNow()
}
})
}
})
}
})
}
}
func TestStoreWithUrlEncodedFormParseError(t *testing.T) {
// http.Request.ParseForm() fails when:
// - http.Request.Method is one of [POST,PUT,PATCH]
// - http.Request.Form is not nil (created manually)
// - http.Request.PostForm is nil (deleted manually)
// - http.Request.Body is nil (deleted manually)
req := httptest.NewRequest(http.MethodPost, "http://host.com/", nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// break everything
req.Body = nil
req.Form = make(url.Values)
req.PostForm = nil
// defer req.Body.Close()
store := New(nil)
err := store.ExtractForm(req)
if err == nil {
t.Errorf("expected malformed urlencoded to have FailNow being parsed (got %d elements)", len(store.Data))
t.FailNow()
}
}
func TestExtractFormUrlEncoded(t *testing.T) {
tests := []struct {
ServiceParams []string
URLEncoded string
Err error
ParamNames []string
ParamValues [][]string
}{
{
ServiceParams: []string{},
URLEncoded: "",
Err: nil,
ParamNames: nil,
ParamValues: nil,
},
{
ServiceParams: []string{"missing"},
URLEncoded: "",
Err: ErrMissingRequiredParam,
ParamNames: nil,
ParamValues: nil,
},
{
ServiceParams: []string{"a"},
URLEncoded: "a",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParams: []string{"a"},
URLEncoded: "a&b",
Err: nil,
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParams: []string{"a", "missing"},
URLEncoded: "a&b",
Err: ErrMissingRequiredParam,
ParamNames: nil,
ParamValues: nil,
},
{
ServiceParams: []string{"a", "b"},
URLEncoded: "a&b",
Err: nil,
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{""}},
},
{
ServiceParams: []string{"a"},
Err: nil,
URLEncoded: "a=",
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
ServiceParams: []string{"a", "b"},
Err: nil,
URLEncoded: "a=&b=x",
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{"x"}},
},
{
ServiceParams: []string{"a", "c"},
Err: nil,
URLEncoded: "a=b&c=d",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b"}, []string{"d"}},
},
{
ServiceParams: []string{"a", "c"},
Err: nil,
URLEncoded: "a=b&c=d&a=x",
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
body := strings.NewReader(test.URLEncoded)
req := httptest.NewRequest(http.MethodPost, "http://host.com", body)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Errorf("expected no GET parameters and got %d", len(store.Data))
t.FailNow()
}
// no param to check
return
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
}
for pi, key := range test.ParamNames {
values := test.ParamValues[pi]
t.Run(key, func(t *testing.T) {
param, isset := store.Data[key]
if !isset {
t.Errorf("param does not exist")
t.FailNow()
}
cast, canCast := param.([]interface{})
if !canCast {
t.Errorf("should return a []interface{} (got '%v')", cast)
t.FailNow()
}
if len(cast) != len(values) {
t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast))
t.FailNow()
}
for vi, value := range values {
t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) {
if value != cast[vi] {
t.Errorf("should return '%s' (got '%s')", value, cast[vi])
t.FailNow()
}
})
}
})
}
})
}
}
func TestJsonParameters(t *testing.T) {
tests := []struct {
ServiceParams []string
Raw string
Err error
ParamNames []string
ParamValues []interface{}
}{
// no need to fully check json because it is parsed with the standard library
{
ServiceParams: []string{},
Raw: "",
Err: nil,
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
ServiceParams: []string{},
Raw: "{}",
Err: nil,
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
ServiceParams: []string{},
Raw: `{ "a": "b" }`,
Err: nil,
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
ServiceParams: []string{"a"},
Raw: `{ "a": "b" }`,
Err: nil,
ParamNames: []string{"a"},
ParamValues: []interface{}{"b"},
},
{
ServiceParams: []string{"a"},
Raw: `{ "a": "b", "ignored": "d" }`,
Err: nil,
ParamNames: []string{"a"},
ParamValues: []interface{}{"b"},
},
{
ServiceParams: []string{"a", "c"},
Raw: `{ "a": "b", "c": "d" }`,
Err: nil,
ParamNames: []string{"a", "c"},
ParamValues: []interface{}{"b", "d"},
},
{
ServiceParams: []string{"a"},
Raw: `{ "a": null }`,
Err: nil,
ParamNames: []string{"a"},
ParamValues: []interface{}{nil},
},
// json parse error
{
ServiceParams: []string{},
Raw: `{ "a": "b", }`,
Err: ErrInvalidJSON,
ParamNames: []string{},
ParamValues: []interface{}{},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
body := strings.NewReader(test.Raw)
req := httptest.NewRequest(http.MethodPost, "http://host.com", body)
req.Header.Add("Content-Type", "application/json")
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Errorf("expected no JSON parameters and got %d", len(store.Data))
t.FailNow()
}
// no param to check
return
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
}
for pi, pName := range test.ParamNames {
key := pName
value := test.ParamValues[pi]
t.Run(key, func(t *testing.T) {
param, isset := store.Data[key]
if !isset {
t.Errorf("store should contain element with key '%s'", key)
t.FailNow()
return
}
valueType := reflect.TypeOf(value)
paramValue := param
paramValueType := reflect.TypeOf(param)
if valueType != paramValueType {
t.Errorf("should be of type %v (got '%v')", valueType, paramValueType)
t.FailNow()
}
if paramValue != value {
t.Errorf("should return %v (got '%v')", value, paramValue)
t.FailNow()
}
})
}
})
}
}
func TestMultipartParameters(t *testing.T) {
tests := []struct {
ServiceParams []string
RawMultipart string
Err error
ParamNames []string
ParamValues []interface{}
}{
// no need to fully check json because it is parsed with the standard library
{
ServiceParams: []string{},
RawMultipart: ``,
Err: nil,
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
ServiceParams: []string{},
RawMultipart: `--xxx
`,
Err: ErrInvalidMultipart,
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
ServiceParams: []string{},
RawMultipart: `--xxx
--xxx--`,
Err: ErrInvalidMultipart,
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
ServiceParams: []string{},
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx--`,
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
ServiceParams: []string{"a"},
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx--`,
ParamNames: []string{"a"},
ParamValues: []interface{}{"b"},
},
{
ServiceParams: []string{"a", "c"},
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx
Content-Disposition: form-data; name="c"
d
--xxx--`,
Err: nil,
ParamNames: []string{"a", "c"},
ParamValues: []interface{}{"b", "d"},
},
{
ServiceParams: []string{"a"},
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx
Content-Disposition: form-data; name="ignored"
x
--xxx--`,
Err: nil,
ParamNames: []string{"a"},
ParamValues: []interface{}{"b"},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
body := strings.NewReader(test.RawMultipart)
req := httptest.NewRequest(http.MethodPost, "http://host.com", body)
req.Header.Add("Content-Type", "multipart/form-data; boundary=xxx")
defer req.Body.Close()
store := New(getServiceWithForm(test.ServiceParams...))
err := store.ExtractForm(req)
if err != nil {
if test.Err != nil {
if !errors.Is(err, test.Err) {
t.Errorf("expected error <%s>, got <%s>", test.Err, err)
t.FailNow()
}
return
}
t.Errorf("unexpected error <%s>", err)
t.FailNow()
}
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Data) != 0 {
t.Errorf("expected no JSON parameters and got %d", len(store.Data))
t.FailNow()
}
// no param to check
return
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.FailNow()
}
for pi, key := range test.ParamNames {
value := test.ParamValues[pi]
t.Run(key, func(t *testing.T) {
param, isset := store.Data[key]
if !isset {
t.Errorf("store should contain element with key '%s'", key)
t.FailNow()
return
}
valueType := reflect.TypeOf(value)
paramValue := param
paramValueType := reflect.TypeOf(param)
if valueType != paramValueType {
t.Errorf("should be of type %v (got '%v')", valueType, paramValueType)
t.FailNow()
}
if paramValue != value {
t.Errorf("should return %v (got '%v')", value, paramValue)
t.FailNow()
}
})
}
})
}
}

View File

@ -1,301 +0,0 @@
package reqdata
import (
"encoding/json"
"fmt"
"log"
"git.xdrm.io/go/aicra/internal/multipart"
"net/http"
"strings"
)
// Store represents all data that can be caught:
// - URI (guessed from the URI by removing the service path)
// - GET (default url data)
// - POST (from json, form-data, url-encoded)
type Store struct {
// ordered values from the URI
// catches all after the service path
//
// points to Store.Data
URI []*Parameter
// uri parameters following the QUERY format
//
// points to Store.Data
Get map[string]*Parameter
// form data depending on the Content-Type:
// '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
//
// points to Store.Data
Form map[string]*Parameter
// contains URL+GET+FORM data with prefixes:
// - FORM: no prefix
// - URL: 'URL#' followed by the index in Uri
// - GET: 'GET@' followed by the key in GET
Set map[string]*Parameter
}
// New creates a new store from an http request.
// URI params is required because it only takes into account after service path
// we do not know in this scope.
func New(uriParams []string, req *http.Request) *Store {
ds := &Store{
URI: make([]*Parameter, 0),
Get: make(map[string]*Parameter),
Form: make(map[string]*Parameter),
Set: make(map[string]*Parameter),
}
// 1. set URI parameters
ds.setURIParams(uriParams)
// ignore nil requests
if req == nil {
return ds
}
// 2. GET (query) data
ds.readQuery(req)
// 3. We are done if GET method
if req.Method == http.MethodGet {
return ds
}
// 4. POST (body) data
ds.readForm(req)
return ds
}
// setURIParameters fills 'Set' with creating pointers inside 'Url'
func (i *Store) setURIParams(orderedUParams []string) {
for index, value := range orderedUParams {
// create set index
setindex := fmt.Sprintf("URL#%d", index)
// store value in 'Set'
i.Set[setindex] = &Parameter{
Parsed: false,
Value: value,
}
// create link in 'Url'
i.URI = append(i.URI, i.Set[setindex])
}
}
// readQuery stores data from the QUERY (in url parameters)
func (i *Store) readQuery(req *http.Request) {
for name, value := range req.URL.Query() {
// prevent invalid names
if !isNameValid(name) {
log.Printf("invalid variable name: '%s'\n", name)
continue
}
// prevent injections
if hasNameInjection(name) {
log.Printf("get.injection: '%s'\n", name)
continue
}
// create set index
setindex := fmt.Sprintf("GET@%s", name)
// store value in 'Set'
i.Set[setindex] = &Parameter{
Parsed: false,
Value: value,
}
// create link in 'Get'
i.Get[name] = i.Set[setindex]
}
}
// readForm stores FORM data
//
// - parse 'form-data' if not supported (not POST requests)
// - parse 'x-www-form-urlencoded'
// - parse 'application/json'
func (i *Store) readForm(req *http.Request) {
contentType := req.Header.Get("Content-Type")
// parse json
if strings.HasPrefix(contentType, "application/json") {
i.parseJSON(req)
return
}
// parse urlencoded
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
i.parseUrlencoded(req)
return
}
// parse multipart
if strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
i.parseMultipart(req)
return
}
// if unknown type store nothing
}
// parseJSON parses JSON from the request body inside 'Form'
// and 'Set'
func (i *Store) parseJSON(req *http.Request) {
parsed := make(map[string]interface{}, 0)
decoder := json.NewDecoder(req.Body)
// if parse error: do nothing
if err := decoder.Decode(&parsed); err != nil {
log.Printf("json.parse() %s\n", err)
return
}
// else store values 'parsed' values
for name, value := range parsed {
// prevent invalid names
if !isNameValid(name) {
log.Printf("invalid variable name: '%s'\n", name)
continue
}
// prevent injections
if hasNameInjection(name) {
log.Printf("post.injection: '%s'\n", name)
continue
}
// store value in 'Set'
i.Set[name] = &Parameter{
Parsed: true,
Value: value,
}
// create link in 'Form'
i.Form[name] = i.Set[name]
}
}
// parseUrlencoded parses urlencoded from the request body inside 'Form'
// and 'Set'
func (i *Store) parseUrlencoded(req *http.Request) {
// use http.Request interface
if err := req.ParseForm(); err != nil {
log.Printf("urlencoded.parse() %s\n", err)
return
}
for name, value := range req.PostForm {
// prevent invalid names
if !isNameValid(name) {
log.Printf("invalid variable name: '%s'\n", name)
continue
}
// prevent injections
if hasNameInjection(name) {
log.Printf("post.injection: '%s'\n", name)
continue
}
// store value in 'Set'
i.Set[name] = &Parameter{
Parsed: false,
Value: value,
}
// create link in 'Form'
i.Form[name] = i.Set[name]
}
}
// parseMultipart parses multi-part from the request body inside 'Form'
// and 'Set'
func (i *Store) parseMultipart(req *http.Request) {
/* (1) Create reader */
boundary := req.Header.Get("Content-Type")[len("multipart/form-data; boundary="):]
mpr, err := multipart.NewReader(req.Body, boundary)
if err != nil {
return
}
/* (2) Parse multipart */
if err = mpr.Parse(); err != nil {
log.Printf("multipart.parse() %s\n", err)
return
}
/* (3) Store data into 'Form' and 'Set */
for name, data := range mpr.Data {
// prevent invalid names
if !isNameValid(name) {
log.Printf("invalid variable name: '%s'\n", name)
continue
}
// prevent injections
if hasNameInjection(name) {
log.Printf("post.injection: '%s'\n", name)
continue
}
// store value in 'Set'
i.Set[name] = &Parameter{
Parsed: false,
File: len(data.GetHeader("filename")) > 0,
Value: string(data.Data),
}
// create link in 'Form'
i.Form[name] = i.Set[name]
}
return
}
// hasNameInjection returns whether there is
// a parameter name injection:
// - inferred GET parameters
// - inferred URL parameters
func hasNameInjection(pName string) bool {
return strings.HasPrefix(pName, "GET@") || strings.HasPrefix(pName, "URL#")
}
// isNameValid returns whether a parameter name (without the GET@ or URL# prefix) is valid
// if fails if the name begins/ends with underscores
func isNameValid(pName string) bool {
return strings.Trim(pName, "_") == pName
}

View File

@ -1,804 +0,0 @@
package reqdata
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
)
func TestEmptyStore(t *testing.T) {
store := New(nil, nil)
if store.URI == nil {
t.Errorf("store 'URI' list should be initialized")
t.Fail()
}
if len(store.URI) != 0 {
t.Errorf("store 'URI' list should be empty")
t.Fail()
}
if store.Get == nil {
t.Errorf("store 'Get' map should be initialized")
t.Fail()
}
if store.Form == nil {
t.Errorf("store 'Form' map should be initialized")
t.Fail()
}
if store.Set == nil {
t.Errorf("store 'Set' map should be initialized")
t.Fail()
}
}
func TestStoreWithUri(t *testing.T) {
urilist := []string{"abc", "def"}
store := New(urilist, nil)
if len(store.URI) != len(urilist) {
t.Errorf("store 'Set' should contain %d elements (got %d)", len(urilist), len(store.URI))
t.Fail()
}
if len(store.Set) != len(urilist) {
t.Errorf("store 'Set' should contain %d elements (got %d)", len(urilist), len(store.Set))
t.Fail()
}
for i, value := range urilist {
t.Run(fmt.Sprintf("URL#%d='%s'", i, value), func(t *testing.T) {
key := fmt.Sprintf("URL#%d", i)
element, isset := store.Set[key]
if !isset {
t.Errorf("store should contain element with key '%s'", key)
t.Failed()
}
if element.Value != value {
t.Errorf("store[%s] should return '%s' (got '%s')", key, value, element.Value)
t.Failed()
}
})
}
}
func TestStoreWithGet(t *testing.T) {
tests := []struct {
Query string
InvalidNames []string
ParamNames []string
ParamValues [][]string
}{
{
Query: "",
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: [][]string{},
},
{
Query: "a",
InvalidNames: []string{},
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
Query: "a&b",
InvalidNames: []string{},
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{""}},
},
{
Query: "a=",
InvalidNames: []string{},
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
Query: "a=&b=x",
InvalidNames: []string{},
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{"x"}},
},
{
Query: "a=b&c=d",
InvalidNames: []string{},
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b"}, []string{"d"}},
},
{
Query: "a=b&c=d&a=x",
InvalidNames: []string{},
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}},
},
{
Query: "a=b&_invalid=x",
InvalidNames: []string{"_invalid"},
ParamNames: []string{"a", "_invalid"},
ParamValues: [][]string{[]string{"b"}, []string{""}},
},
{
Query: "a=b&invalid_=x",
InvalidNames: []string{"invalid_"},
ParamNames: []string{"a", "invalid_"},
ParamValues: [][]string{[]string{"b"}, []string{""}},
},
{
Query: "a=b&GET@injection=x",
InvalidNames: []string{"GET@injection"},
ParamNames: []string{"a", "GET@injection"},
ParamValues: [][]string{[]string{"b"}, []string{""}},
},
{ // not really useful as all after '#' should be ignored by http clients
Query: "a=b&URL#injection=x",
InvalidNames: []string{"URL#injection"},
ParamNames: []string{"a", "URL#injection"},
ParamValues: [][]string{[]string{"b"}, []string{""}},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://host.com?%s", test.Query), nil)
store := New(nil, req)
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Set) != 0 {
t.Errorf("expected no GET parameters and got %d", len(store.Get))
t.Failed()
}
// no param to check
return
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.Failed()
}
for pi, pName := range test.ParamNames {
key := fmt.Sprintf("GET@%s", pName)
values := test.ParamValues[pi]
isNameValid := true
for _, invalid := range test.InvalidNames {
if pName == invalid {
isNameValid = false
}
}
t.Run(key, func(t *testing.T) {
param, isset := store.Set[key]
if !isset {
if isNameValid {
t.Errorf("store should contain element with key '%s'", key)
t.Failed()
}
return
}
// if should be invalid
if isset && !isNameValid {
t.Errorf("store should NOT contain element with key '%s' (invalid name)", key)
t.Failed()
}
cast, canCast := param.Value.([]string)
if !canCast {
t.Errorf("should return a []string (got '%v')", cast)
t.Failed()
}
if len(cast) != len(values) {
t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast))
t.Failed()
}
for vi, value := range values {
t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) {
if value != cast[vi] {
t.Errorf("should return '%s' (got '%s')", value, cast[vi])
t.Failed()
}
})
}
})
}
})
}
}
func TestStoreWithUrlEncodedFormParseError(t *testing.T) {
// http.Request.ParseForm() fails when:
// - http.Request.Method is one of [POST,PUT,PATCH]
// - http.Request.Form is not nil (created manually)
// - http.Request.PostForm is nil (deleted manually)
// - http.Request.Body is nil (deleted manually)
req := httptest.NewRequest(http.MethodPost, "http://host.com/", nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// break everything
req.Body = nil
req.Form = make(url.Values)
req.PostForm = nil
// defer req.Body.Close()
store := New(nil, req)
if len(store.Form) > 0 {
t.Errorf("expected malformed urlencoded to have failed being parsed (got %d elements)", len(store.Form))
t.FailNow()
}
}
func TestStoreWithUrlEncodedForm(t *testing.T) {
tests := []struct {
URLEncoded string
InvalidNames []string
ParamNames []string
ParamValues [][]string
}{
{
URLEncoded: "",
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: [][]string{},
},
{
URLEncoded: "a",
InvalidNames: []string{},
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
URLEncoded: "a&b",
InvalidNames: []string{},
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{""}},
},
{
URLEncoded: "a=",
InvalidNames: []string{},
ParamNames: []string{"a"},
ParamValues: [][]string{[]string{""}},
},
{
URLEncoded: "a=&b=x",
InvalidNames: []string{},
ParamNames: []string{"a", "b"},
ParamValues: [][]string{[]string{""}, []string{"x"}},
},
{
URLEncoded: "a=b&c=d",
InvalidNames: []string{},
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b"}, []string{"d"}},
},
{
URLEncoded: "a=b&c=d&a=x",
InvalidNames: []string{},
ParamNames: []string{"a", "c"},
ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}},
},
{
URLEncoded: "a=b&_invalid=x",
InvalidNames: []string{"_invalid"},
ParamNames: []string{"a", "_invalid"},
ParamValues: [][]string{[]string{"b"}, []string{""}},
},
{
URLEncoded: "a=b&invalid_=x",
InvalidNames: []string{"invalid_"},
ParamNames: []string{"a", "invalid_"},
ParamValues: [][]string{[]string{"b"}, []string{""}},
},
{
URLEncoded: "a=b&GET@injection=x",
InvalidNames: []string{"GET@injection"},
ParamNames: []string{"a", "GET@injection"},
ParamValues: [][]string{[]string{"b"}, []string{""}},
},
{
URLEncoded: "a=b&URL#injection=x",
InvalidNames: []string{"URL#injection"},
ParamNames: []string{"a", "URL#injection"},
ParamValues: [][]string{[]string{"b"}, []string{""}},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
body := strings.NewReader(test.URLEncoded)
req := httptest.NewRequest(http.MethodPost, "http://host.com", body)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
defer req.Body.Close()
store := New(nil, req)
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Set) != 0 {
t.Errorf("expected no FORM parameters and got %d", len(store.Get))
t.Failed()
}
// no param to check
return
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.Failed()
}
for pi, pName := range test.ParamNames {
key := pName
values := test.ParamValues[pi]
isNameValid := true
for _, invalid := range test.InvalidNames {
if pName == invalid {
isNameValid = false
}
}
t.Run(key, func(t *testing.T) {
param, isset := store.Set[key]
if !isset {
if isNameValid {
t.Errorf("store should contain element with key '%s'", key)
t.Failed()
}
return
}
// if should be invalid
if isset && !isNameValid {
t.Errorf("store should NOT contain element with key '%s' (invalid name)", key)
t.Failed()
}
cast, canCast := param.Value.([]string)
if !canCast {
t.Errorf("should return a []string (got '%v')", cast)
t.Failed()
}
if len(cast) != len(values) {
t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast))
t.Failed()
}
for vi, value := range values {
t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) {
if value != cast[vi] {
t.Errorf("should return '%s' (got '%s')", value, cast[vi])
t.Failed()
}
})
}
})
}
})
}
}
func TestJsonParameters(t *testing.T) {
tests := []struct {
RawJson string
InvalidNames []string
ParamNames []string
ParamValues []interface{}
}{
// no need to fully check json because it is parsed with the standard library
{
RawJson: "",
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
RawJson: "{}",
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
RawJson: "{ \"a\": \"b\" }",
InvalidNames: []string{},
ParamNames: []string{"a"},
ParamValues: []interface{}{"b"},
},
{
RawJson: "{ \"a\": \"b\", \"c\": \"d\" }",
InvalidNames: []string{},
ParamNames: []string{"a", "c"},
ParamValues: []interface{}{"b", "d"},
},
{
RawJson: "{ \"_invalid\": \"x\" }",
InvalidNames: []string{"_invalid"},
ParamNames: []string{"_invalid"},
ParamValues: []interface{}{nil},
},
{
RawJson: "{ \"a\": \"b\", \"_invalid\": \"x\" }",
InvalidNames: []string{"_invalid"},
ParamNames: []string{"a", "_invalid"},
ParamValues: []interface{}{"b", nil},
},
{
RawJson: "{ \"invalid_\": \"x\" }",
InvalidNames: []string{"invalid_"},
ParamNames: []string{"invalid_"},
ParamValues: []interface{}{nil},
},
{
RawJson: "{ \"a\": \"b\", \"invalid_\": \"x\" }",
InvalidNames: []string{"invalid_"},
ParamNames: []string{"a", "invalid_"},
ParamValues: []interface{}{"b", nil},
},
{
RawJson: "{ \"GET@injection\": \"x\" }",
InvalidNames: []string{"GET@injection"},
ParamNames: []string{"GET@injection"},
ParamValues: []interface{}{nil},
},
{
RawJson: "{ \"a\": \"b\", \"GET@injection\": \"x\" }",
InvalidNames: []string{"GET@injection"},
ParamNames: []string{"a", "GET@injection"},
ParamValues: []interface{}{"b", nil},
},
{
RawJson: "{ \"URL#injection\": \"x\" }",
InvalidNames: []string{"URL#injection"},
ParamNames: []string{"URL#injection"},
ParamValues: []interface{}{nil},
},
{
RawJson: "{ \"a\": \"b\", \"URL#injection\": \"x\" }",
InvalidNames: []string{"URL#injection"},
ParamNames: []string{"a", "URL#injection"},
ParamValues: []interface{}{"b", nil},
},
// json parse error
{
RawJson: "{ \"a\": \"b\", }",
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: []interface{}{},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
body := strings.NewReader(test.RawJson)
req := httptest.NewRequest(http.MethodPost, "http://host.com", body)
req.Header.Add("Content-Type", "application/json")
defer req.Body.Close()
store := New(nil, req)
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Set) != 0 {
t.Errorf("expected no JSON parameters and got %d", len(store.Get))
t.Failed()
}
// no param to check
return
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.Failed()
}
for pi, pName := range test.ParamNames {
key := pName
value := test.ParamValues[pi]
isNameValid := true
for _, invalid := range test.InvalidNames {
if pName == invalid {
isNameValid = false
}
}
t.Run(key, func(t *testing.T) {
param, isset := store.Set[key]
if !isset {
if isNameValid {
t.Errorf("store should contain element with key '%s'", key)
t.Failed()
}
return
}
// if should be invalid
if isset && !isNameValid {
t.Errorf("store should NOT contain element with key '%s' (invalid name)", key)
t.Failed()
}
valueType := reflect.TypeOf(value)
paramValue := param.Value
paramValueType := reflect.TypeOf(param.Value)
if valueType != paramValueType {
t.Errorf("should be of type %v (got '%v')", valueType, paramValueType)
t.Failed()
}
if paramValue != value {
t.Errorf("should return %v (got '%v')", value, paramValue)
t.Failed()
}
})
}
})
}
}
func TestMultipartParameters(t *testing.T) {
tests := []struct {
RawMultipart string
InvalidNames []string
ParamNames []string
ParamValues []interface{}
}{
// no need to fully check json because it is parsed with the standard library
{
RawMultipart: ``,
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
RawMultipart: `--xxx
`,
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
RawMultipart: `--xxx
--xxx--`,
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: []interface{}{},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx--`,
InvalidNames: []string{},
ParamNames: []string{"a"},
ParamValues: []interface{}{"b"},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx
Content-Disposition: form-data; name="c"
d
--xxx--`,
InvalidNames: []string{},
ParamNames: []string{"a", "c"},
ParamValues: []interface{}{"b", "d"},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="_invalid"
x
--xxx--`,
InvalidNames: []string{"_invalid"},
ParamNames: []string{"_invalid"},
ParamValues: []interface{}{nil},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx
Content-Disposition: form-data; name="_invalid"
x
--xxx--`,
InvalidNames: []string{"_invalid"},
ParamNames: []string{"a", "_invalid"},
ParamValues: []interface{}{"b", nil},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="invalid_"
x
--xxx--`,
InvalidNames: []string{"invalid_"},
ParamNames: []string{"invalid_"},
ParamValues: []interface{}{nil},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx
Content-Disposition: form-data; name="invalid_"
x
--xxx--`,
InvalidNames: []string{"invalid_"},
ParamNames: []string{"a", "invalid_"},
ParamValues: []interface{}{"b", nil},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="GET@injection"
x
--xxx--`,
InvalidNames: []string{"GET@injection"},
ParamNames: []string{"GET@injection"},
ParamValues: []interface{}{nil},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx
Content-Disposition: form-data; name="GET@injection"
x
--xxx--`,
InvalidNames: []string{"GET@injection"},
ParamNames: []string{"a", "GET@injection"},
ParamValues: []interface{}{"b", nil},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="URL#injection"
x
--xxx--`,
InvalidNames: []string{"URL#injection"},
ParamNames: []string{"URL#injection"},
ParamValues: []interface{}{nil},
},
{
RawMultipart: `--xxx
Content-Disposition: form-data; name="a"
b
--xxx
Content-Disposition: form-data; name="URL#injection"
x
--xxx--`,
InvalidNames: []string{"URL#injection"},
ParamNames: []string{"a", "URL#injection"},
ParamValues: []interface{}{"b", nil},
},
// json parse error
{
RawMultipart: "{ \"a\": \"b\", }",
InvalidNames: []string{},
ParamNames: []string{},
ParamValues: []interface{}{},
},
}
for i, test := range tests {
t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) {
body := strings.NewReader(test.RawMultipart)
req := httptest.NewRequest(http.MethodPost, "http://host.com", body)
req.Header.Add("Content-Type", "multipart/form-data; boundary=xxx")
defer req.Body.Close()
store := New(nil, req)
if test.ParamNames == nil || test.ParamValues == nil {
if len(store.Set) != 0 {
t.Errorf("expected no JSON parameters and got %d", len(store.Get))
t.Failed()
}
// no param to check
return
}
if len(test.ParamNames) != len(test.ParamValues) {
t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues))
t.Failed()
}
for pi, pName := range test.ParamNames {
key := pName
value := test.ParamValues[pi]
isNameValid := true
for _, invalid := range test.InvalidNames {
if pName == invalid {
isNameValid = false
}
}
t.Run(key, func(t *testing.T) {
param, isset := store.Set[key]
if !isset {
if isNameValid {
t.Errorf("store should contain element with key '%s'", key)
t.Failed()
}
return
}
// if should be invalid
if isset && !isNameValid {
t.Errorf("store should NOT contain element with key '%s' (invalid name)", key)
t.Failed()
}
valueType := reflect.TypeOf(value)
paramValue := param.Value
paramValueType := reflect.TypeOf(param.Value)
if valueType != paramValueType {
t.Errorf("should be of type %v (got '%v')", valueType, paramValueType)
t.Failed()
}
if paramValue != value {
t.Errorf("should return %v (got '%v')", value, paramValue)
t.Failed()
}
})
}
})
}
}

View File

@ -1,25 +1,23 @@
package aicra
import (
"fmt"
"io"
"log"
"os"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/dynamic"
"git.xdrm.io/go/aicra/internal/config"
checker "git.xdrm.io/go/aicra/typecheck"
)
// Server represents an AICRA instance featuring: type checkers, services
type Server struct {
config *config.Service
Checkers *checker.Set
handlers []*api.Handler
config *config.Server
handlers []*handler
}
// New creates a framework instance from a configuration file
func New(configPath string) (*Server, error) {
func New(configPath string, dtypes ...datatype.T) (*Server, error) {
var (
err error
configFile io.ReadCloser
@ -28,8 +26,7 @@ func New(configPath string) (*Server, error) {
// 1. init instance
var i = &Server{
config: nil,
Checkers: checker.New(),
handlers: make([]*api.Handler, 0),
handlers: make([]*handler, 0),
}
// 2. open config file
@ -40,39 +37,55 @@ func New(configPath string) (*Server, error) {
defer configFile.Close()
// 3. load configuration
i.config, err = config.Parse(configFile)
i.config, err = config.Parse(configFile, dtypes...)
if err != nil {
return nil, err
}
// 4. log configuration services
log.Printf("🔧 Reading configuration '%s'\n", configPath)
logService(*i.config, "")
return i, nil
}
// HandleFunc sets a new handler for an HTTP method to a path
func (s *Server) HandleFunc(httpMethod, path string, handlerFunc api.HandlerFunc) {
handler := api.NewHandler(httpMethod, path, handlerFunc)
// 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
}
// Handle sets a new handler
func (s *Server) Handle(handler *api.Handler) {
s.handlers = append(s.handlers, handler)
}
// ToHTTPServer converts the server to a http server
func (s Server) ToHTTPServer() (*httpServer, error) {
// HTTP converts the server to a http server
func (s Server) HTTP() httpServer {
// 1. log available handlers
log.Printf("🔗 Mapping handlers\n")
for i := 0; i < len(s.handlers); i++ {
log.Printf(" ->\t%s\t'%s'\n", s.handlers[i].GetMethod(), s.handlers[i].GetPath())
// 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
return httpServer(s)
httpServer := httpServer(s)
return &httpServer, nil
}

View File

@ -1,22 +0,0 @@
package builtin
import "git.xdrm.io/go/aicra/typecheck"
// Any is a permissive type checker
type Any struct{}
// NewAny returns a bare any type checker
func NewAny() *Any {
return &Any{}
}
// Checker returns the checker function
func (Any) Checker(typeName string) typecheck.CheckerFunc {
// nothing if type not handled
if typeName != "any" {
return nil
}
return func(interface{}) bool {
return true
}
}

View File

@ -1,44 +0,0 @@
package builtin
import "git.xdrm.io/go/aicra/typecheck"
// Bool checks if a value is a boolean
type Bool struct{}
// NewBool returns a bare boolean type checker
func NewBool() *Bool {
return &Bool{}
}
// Checker returns the checker function
func (Bool) Checker(typeName string) typecheck.CheckerFunc {
// nothing if type not handled
if typeName != "bool" {
return nil
}
return func(value interface{}) bool {
_, isBool := readBool(value)
return isBool
}
}
// readBool tries to read a serialized boolean and returns whether it succeeded.
func readBool(value interface{}) (bool, bool) {
switch cast := value.(type) {
case bool:
return cast, true
case string:
strVal := string(cast)
return strVal == "true", strVal == "true" || strVal == "false"
case []byte:
strVal := string(cast)
return strVal == "true", strVal == "true" || strVal == "false"
default:
return false, false
}
return false, false
}

View File

@ -1,58 +0,0 @@
package builtin
import (
"encoding/json"
"git.xdrm.io/go/aicra/typecheck"
)
// Float64 checks if a value is a float64
type Float64 struct{}
// NewFloat64 returns a bare number type checker
func NewFloat64() *Float64 {
return &Float64{}
}
// Checker returns the checker function
func (Float64) Checker(typeName string) typecheck.CheckerFunc {
// nothing if type not handled
if typeName != "float64" && typeName != "float" {
return nil
}
return func(value interface{}) bool {
_, isFloat := readFloat(value)
return isFloat
}
}
// readFloat tries to read a serialized float and returns whether it succeeded.
func readFloat(value interface{}) (float64, bool) {
switch cast := value.(type) {
case int:
return float64(cast), true
case uint:
return float64(cast), true
case float64:
return cast, true
// serialized string -> try to convert to float
case string:
num := json.Number(cast)
floatVal, err := num.Float64()
return floatVal, err == nil
case []byte:
num := json.Number(cast)
floatVal, err := num.Float64()
return floatVal, err == nil
// unknown type
default:
return 0, false
}
}

View File

@ -1,64 +0,0 @@
package builtin
import (
"encoding/json"
"math"
"git.xdrm.io/go/aicra/typecheck"
)
// Int checks if a value is an int
type Int struct{}
// NewInt returns a bare number type checker
func NewInt() *Int {
return &Int{}
}
// Checker returns the checker function
func (Int) Checker(typeName string) typecheck.CheckerFunc {
// nothing if type not handled
if typeName != "int" {
return nil
}
return func(value interface{}) bool {
_, isInt := readInt(value)
return isInt
}
}
// readInt tries to read a serialized int and returns whether it succeeded.
func readInt(value interface{}) (int, bool) {
switch cast := value.(type) {
case int:
return cast, true
case uint:
overflows := cast > math.MaxInt64
return int(cast), !overflows
case float64:
intVal := int(cast)
overflows := cast < float64(math.MinInt64) || cast > float64(math.MaxInt64)
return intVal, cast == float64(intVal) && !overflows
// serialized string -> try to convert to float
case string:
num := json.Number(cast)
intVal, err := num.Int64()
return int(intVal), err == nil
// serialized string -> try to convert to float
case []byte:
num := json.Number(cast)
intVal, err := num.Int64()
return int(intVal), err == nil
// unknown type
default:
return 0, false
}
}

View File

@ -1,70 +0,0 @@
package builtin
import (
"encoding/json"
"math"
"git.xdrm.io/go/aicra/typecheck"
)
// Uint checks if a value is an uint
type Uint struct{}
// NewUint returns a bare number type checker
func NewUint() *Uint {
return &Uint{}
}
// Checker returns the checker function
func (Uint) Checker(typeName string) typecheck.CheckerFunc {
// nothing if type not handled
if typeName != "uint" {
return nil
}
return func(value interface{}) bool {
_, isInt := readUint(value)
return isInt
}
}
// readUint tries to read a serialized uint and returns whether it succeeded.
func readUint(value interface{}) (uint, bool) {
switch cast := value.(type) {
case int:
return uint(cast), cast >= 0
case uint:
return cast, true
case float64:
uintVal := uint(cast)
overflows := cast < 0 || cast > math.MaxUint64
return uintVal, cast == float64(uintVal) && !overflows
// serialized string -> try to convert to float
case string:
num := json.Number(cast)
floatVal, err := num.Float64()
if err != nil {
return 0, false
}
overflows := floatVal < 0 || floatVal > math.MaxUint64
return uint(floatVal), !overflows
case []byte:
num := json.Number(cast)
floatVal, err := num.Float64()
if err != nil {
return 0, false
}
overflows := floatVal < 0 || floatVal > math.MaxUint64
return uint(floatVal), !overflows
// unknown type
default:
return 0, false
}
}

View File

@ -1,43 +0,0 @@
package typecheck
// Set of type checkers
type Set struct {
types []Type
}
// New returns a new set of type checkers
func New() *Set {
return &Set{types: make([]Type, 0)}
}
// Add adds a new type checker
func (s *Set) Add(typeChecker Type) {
s.types = append(s.types, typeChecker)
}
// Run finds a type checker from the registry matching the type `typeName`
// and uses this checker to check the `value`. If no type checker matches
// the `type`, error is returned by default.
func (s *Set) Run(typeName string, value interface{}) error {
// find matching type (take first)
for _, typeChecker := range s.types {
if typeChecker == nil {
continue
}
// found
checkerFunc := typeChecker.Checker(typeName)
if checkerFunc == nil {
continue
}
// check value
if checkerFunc(value) {
return nil
}
return ErrDoesNotMatch
}
return ErrNoMatchingType
}

View File

@ -1,18 +0,0 @@
package typecheck
import "errors"
// ErrNoMatchingType when no available type checker matches the type
var ErrNoMatchingType = errors.New("no matching type")
// ErrDoesNotMatch when the value is invalid
var ErrDoesNotMatch = errors.New("does not match")
// CheckerFunc returns whether a given value fulfills a type
type CheckerFunc func(interface{}) bool
// Type represents a type checker
type Type interface {
// given a type name, returns the checker function or NIL if the type is not handled here
Checker(string) CheckerFunc
}

90
util.go
View File

@ -5,101 +5,11 @@ import (
"net/http"
"git.xdrm.io/go/aicra/api"
"git.xdrm.io/go/aicra/internal/config"
"git.xdrm.io/go/aicra/internal/reqdata"
)
// extractParameters extracts parameters for the request and checks
// every single one according to configuration options
func (s *httpServer) extractParameters(store *reqdata.Store, methodParam map[string]*config.Parameter) (map[string]interface{}, api.Error) {
// init vars
parameters := make(map[string]interface{})
// for each param of the config
for name, param := range methodParam {
// 1. extract value
p, isset := store.Set[name]
// 2. fail if required & missing
if !isset && !param.Optional {
apiErr := api.ErrorMissingParam()
apiErr.SetArguments(name)
return nil, apiErr
}
// 3. optional & missing: set default value
if !isset {
p = &reqdata.Parameter{
Parsed: true,
File: param.Type == "FILE",
Value: nil,
}
if param.Default != nil {
p.Value = *param.Default
}
// we are done
parameters[param.Rename] = p.Value
continue
}
// 4. parse parameter if not file
if !p.File {
p.Parse()
}
// 5. fail on unexpected multipart file
waitFile, gotFile := param.Type == "FILE", p.File
if gotFile && !waitFile || !gotFile && waitFile {
apiErr := api.ErrorInvalidParam()
apiErr.SetArguments(param.Rename, "FILE")
return nil, apiErr
}
// 6. ignore type check if file
if gotFile {
parameters[param.Rename] = p.Value
continue
}
// 7. check type
if s.Checkers.Run(param.Type, p.Value) != nil {
apiErr := api.ErrorInvalidParam()
apiErr.SetArguments(param.Rename, param.Type, p.Value)
return nil, apiErr
}
parameters[param.Rename] = p.Value
}
return parameters, api.ErrorSuccess()
}
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)
}
// logService logs a service details
func logService(s config.Service, path string) {
for _, method := range handledMethods {
if m := s.Method(method); m != nil {
if path == "" {
log.Printf(" ->\t%s\t'/'\n", method)
} else {
log.Printf(" ->\t%s\t'%s'\n", method, path)
}
}
}
if s.Children != nil {
for subPath, child := range s.Children {
logService(*child, path+"/"+subPath)
}
}
}