Compare commits

...

109 Commits

Author SHA1 Message Date
xdrm-brackets 39978a6743
Merge pull request #4 from xdrm-io/test/coverage
improve test coverage to 80% for every package
2021-06-23 09:13:39 +02:00
Adrien Marquès ccacd72a36
feat: handler differentiates missing and invalid parameter 2021-06-23 09:12:33 +02:00
Adrien Marquès 3613581b1c
fix: reqdata checks for missing form input globally for json, multipart, url encoded 2021-06-23 09:12:04 +02:00
Adrien Marquès 90e62b7e72 test: handler top-level errors: service, params 2021-06-22 23:49:03 +02:00
Adrien Marquès 1cc24be254 feat: url encoded parameters (uri + form) are only considered a slice when multiple values are set
- if `?a=123`, "123" is the value that can be validated as string, int, etc
 - if `?a=123&a=456`, the slice []type{123,456} is the value that can be validated as slice of strings, ints, etc.
2021-06-22 23:44:21 +02:00
Adrien Marquès fcc8b39717
fix: ignore uri query for service pattern matching 2021-06-22 22:45:47 +02:00
Adrien Marquès 2b67655cfd
refactor: reduce cyclomatic complexity of service.validateInput()
- simplify matchPattern()
 - rename isMethodAvailable() to checkMethod()
 - rename isPatternValid() to checkPattern()
 - rename validateInput() to checkInput()
 - rename validateOutput() to checkOutput()
 - refactor per-type input param management in new method parseParam(); that returns the param type (added unexported enum) and the error
 - refactor collision detection from checkInput() and checkOutput() in new method nameConflicts()
2021-06-22 22:18:29 +02:00
Adrien Marquès 140fbb8b23
fix: gofmt: with -s argument 2021-06-22 21:16:25 +02:00
Adrien Marquès f4f49e6ae6
fix: lint: consistent receiver name 2021-06-22 21:15:25 +02:00
Adrien Marquès c048db76e6
fix: ineffectual assignments 2021-06-22 21:14:38 +02:00
Adrien Marquès ad86a3b46b
fix: mispells 2021-06-22 21:11:59 +02:00
Adrien Marquès ad178781ac
test: cover dynfunc signature to 100% 2021-06-21 22:46:04 +02:00
Adrien Marquès 178d9a8eee
refactor: export config errors 2021-06-21 21:50:57 +02:00
Adrien Marquès 19bcc2e8dc
test: cover api context 2021-06-21 21:46:03 +02:00
Adrien Marquès 8b92abd1c2 fix: remove debug/typo 2021-06-21 21:38:06 +02:00
Adrien Marquès 89e81617d5 test: cover response 2021-06-21 21:38:06 +02:00
Adrien Marquès cff4106bf5 refactor: unexport api.Response into aicra.response 2021-06-21 21:38:06 +02:00
Adrien Marquès f17622195a test: cover builtin types 2021-06-21 21:38:03 +02:00
Adrien Marquès 461c17299e test: cover builder 2021-06-21 21:35:14 +02:00
Adrien Marquès 8c122e9ddf feat: export dynfunc errors 2021-06-21 21:34:52 +02:00
xdrm-brackets b4a426adcc fix: cover api.Auth 2021-06-21 21:34:52 +02:00
xdrm-brackets 6182276856
Merge pull request #3 from xdrm-io/refactor/validators
refactor: semantic rename and simplify validators
2021-06-21 21:34:17 +02:00
Adrien Marquès de547576c9
refactor: semantic move 'builtin' into 'validator' 2021-06-21 21:30:33 +02:00
Adrien Marquès defa2c3645
refactor: rename semantics of datatype to validator.Type 2021-06-21 21:08:22 +02:00
xdrm-brackets 0ee814abbe
Merge pull request #1 from xdrm-io/migrate/github
Migrate repo to GitHub
2021-06-20 21:51:03 +02:00
Adrien Marquès 36991ea9ef
docs: add build status 2021-06-20 21:49:56 +02:00
Adrien Marquès b3ef7de624
migrate: drone CI to github actions 2021-06-20 21:47:17 +02:00
Adrien Marquès 822ef823e9
migrate: symbols import paths to github 2021-06-20 21:29:46 +02:00
Adrien Marquès 77a1f3b11d Merge pull request 'docs: fix logo asset url to branch 0.4.0' (#26) from fix/readme-asset-url into 0.4.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Reviewed-on: #26
2021-06-20 08:35:45 +00:00
Adrien Marquès a8d7905180
docs: fix logo asset url to branch 0.4.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-06-20 10:35:08 +02:00
Adrien Marquès cc25995659 Merge pull request 'refactor/idiomatic-handlers-middlewares' (#25) from refactor/idiomatic-handlers-middlewares into 0.4.0
continuous-integration/drone/push Build is passing Details
Reviewed-on: #25
2021-06-20 08:26:27 +00:00
Adrien Marquès fd1ced5a8b
fix: restore request denied on invalid auth after contextual middlwares
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-06-20 10:24:12 +02:00
Adrien Marquès 97941da901
docs: update README for context.Context and middlewares
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-06-20 02:16:24 +02:00
Adrien Marquès af63c4514b
refactor: idiomatic remove of api.Context for context.Context, custom middlewares for standard http middlewares
continuous-integration/drone/push Build is passing Details
- remove api.Context as using context.Context is more idiomatic
 - remove api.Adapter as it is redundant with func(http.Handler) http.Handler
 - remove authentication middlewares as they be achieved as normal middlewares but launched around the handler (after the service has been found and validated)
 - builder.With() adds an standard Middleware that runs before any aicra code
 - builder.WithContext() adds an http middleware that runs just before the service handler is called. The http.Request provided contains a context with useful values such as the required permissions (from the service configuration).
 - handlers take a context.Context variable as first argument instead of api.Context
2021-06-20 02:14:31 +02:00
Adrien Marquès 6a78351a2c
doc: update README for *api.Context handler argument 2021-06-20 00:56:25 +02:00
Adrien Marquès 53dfc8f679
feat: *api.Context is required as first handler argument
continuous-integration/drone/push Build is passing Details
2021-06-20 00:47:04 +02:00
Adrien Marquès ed404106f2
refactor: rename api.Ctx to api.Context, extends context.Context with helper methods 2021-06-20 00:46:42 +02:00
Adrien Marquès fa1ecfd97f
feat: create internal context.Context custom keys 2021-06-20 00:46:04 +02:00
Adrien Marquès 418631e09d Merge branch 'feature/improve-readme' into 0.3.0
continuous-integration/drone/push Build is passing Details
2021-06-19 00:25:01 +02:00
Adrien Marquès 2d87052dda
fix: typos
continuous-integration/drone/push Build is passing Details
2021-06-19 00:24:26 +02:00
Adrien Marquès 610ab66ea8
readme: add logo, improve structure and explanations
continuous-integration/drone/push Build is passing Details
2021-06-19 00:17:37 +02:00
Adrien Marquès 3563d53365 Merge pull request 'feature: dynamic scope using input arguments' (#23) from feature/dynamic-scope into 0.3.0
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
Reviewed-on: #23
2021-05-19 12:06:19 +00:00
xdrm-brackets c35e2fdd9a
fix: do not use optional (nil) inputs for dynamic scope
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-18 17:45:07 +02:00
xdrm-brackets 8c2ebd916e
feat: add test coverage
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-18 16:30:20 +02:00
xdrm-brackets 2a17ba2f72
fix: allow non-Stringer using %v format (unsafe but does the job) 2021-05-18 16:24:31 +02:00
xdrm-brackets 346cc4e557
feat: add dynamic scope from request's input
- all occurences of '[abc]' where 'abc' is a valid input name ('name' field from json) is replaced with its value between square brackets
2021-05-18 16:06:49 +02:00
Adrien Marquès 214e2348aa Merge pull request 'feature: authentication middlewares' (#20) from feature/expose-scope into 0.3.0
continuous-integration/drone/push Build is passing Details
Reviewed-on: #20
2021-05-18 13:57:56 +00:00
Adrien Marquès 976b13bd38 Merge pull request 'fix: remove error status from json' (#22) from fix/json-err-status into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
Reviewed-on: #22
2021-05-18 09:10:53 +00:00
xdrm-brackets 3bb02fcbb7
fix: remove error status from json
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-18 11:10:12 +02:00
xdrm-brackets 4a62df8029
feat: handle auth adapters
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-18 09:59:49 +02:00
xdrm-brackets 4f55302e8a
feat: add WithAuth() to builder using api.AuthAdapter interface 2021-05-18 09:36:33 +02:00
xdrm-brackets 18d809c4ca
feat: create api.Auth wrapping authorization management 2021-05-18 09:34:01 +02:00
Adrien Marquès e3d24ae1ef Merge pull request 'feature: add optional context to handlers' (#19) from feature/context into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
An optional first input argument of type api.Ctx to handlers to access standard request/response
2021-05-10 14:42:57 +00:00
Adrien Marquès af106acd3f refactor: test: dynamic function handler
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-04-19 23:34:31 +02:00
Adrien Marquès b88a4439c8 fixup: update comment for optional api.Ctx 2021-04-19 22:15:34 +02:00
Adrien Marquès e44dab4bc9 fixup: remove HasContext from spec 2021-04-19 19:55:00 +02:00
Adrien Marquès 1245861be7 test: builder
continuous-integration/drone/push Build is passing Details
2021-04-19 18:46:18 +02:00
Adrien Marquès d6f8457274 feat: pass optional context argument to handlers
continuous-integration/drone/push Build is passing Details
2021-04-18 19:31:54 +02:00
Adrien Marquès 939ab2e57d fixup: expose api context fields 2021-04-18 19:31:40 +02:00
Adrien Marquès 0a55c2ee13 feat: add optional api.Ctx first argument to handler checker 2021-04-18 19:25:31 +02:00
Adrien Marquès 24be7c294e test: dynamic func input 2021-04-18 18:26:37 +02:00
Adrien Marquès f334d19ef4 feat: add api context type 2021-04-18 18:14:30 +02:00
Adrien Marquès 08b825b38f Merge pull request 'feature: add http middleware capability' (#18) from feature/middleware into 0.3.0
continuous-integration/drone/push Build is passing Details
2021-04-18 16:08:10 +00:00
Adrien Marquès a693bbbf9b fix: reflect assign value to pointer of value in call argument
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-04-18 17:55:48 +02:00
Adrien Marquès 3986f7a022 fix: remove recoverer and body closer; must be users' responsability 2021-04-18 17:53:53 +02:00
Adrien Marquès 14ae59561c feat: encapsulate request handling into adapters 2021-04-18 16:50:02 +02:00
Adrien Marquès 96164127e1 feat: add Use() method to add adapters to the aicra builder 2021-04-18 16:49:46 +02:00
Adrien Marquès 87c15b91e5 feat: add middleware (Adapter) type 2021-04-18 16:49:24 +02:00
Adrien Marquès f3127edde1 Merge pull request 'improvements, fixes, update to go 1.16' (#16) from refactor/go1.16 into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2021-03-28 17:44:58 +00:00
Adrien Marquès 546130cfd0
update: readme
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-03-28 19:41:25 +02:00
Adrien Marquès 11aa9f0a0f
fix: global handler recoverer 2021-03-28 19:05:43 +02:00
Adrien Marquès 468a09be8d
update: rename 'Server' into 'Handler' 2021-03-28 19:03:16 +02:00
Adrien Marquès 10e59acdae
fix: test string-int concatenation warnings 2021-03-28 18:50:25 +02:00
Adrien Marquès 334f1fba21
feat: add builder helpers Get(), Post(), Put(), Delete() that proxies to Bind() 2021-03-28 18:50:04 +02:00
Adrien Marquès 6039fbb41f
update: api.Err system
- rename 'Error' to 'Err'
 - use struct instead of int as underlying type ; remove dependency on 2 maps for reason and HTTP status codes
 - remove useless json implementation
2021-03-28 18:49:23 +02:00
Adrien Marquès a9acfca089
update go.mod to go 1.16 2021-03-28 18:06:09 +02:00
Adrien Marquès fb69dbb903 Merge branch 'refactor-test' of go/aicra into 0.3.0
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-04-04 15:33:43 +00:00
Adrien Marquès 658c66d2db
update readme
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-04-04 17:33:08 +02:00
Adrien Marquès 3c453e7f89
remove api useless request, update default errors and bind status codes to errors
continuous-integration/drone/push Build is passing Details
2020-04-04 16:03:50 +02:00
Adrien Marquès d198086dd4
fix http error handlers 2020-04-04 16:03:12 +02:00
Adrien Marquès 30862195a1
config: refactor, simplify, test, remove redundant comments 2020-04-04 15:39:00 +02:00
Adrien Marquès 990bb86919
rework reqdata api and remove redundant comments 2020-04-04 14:56:15 +02:00
Adrien Marquès 35ede5e266
unexport config errors 2020-04-04 14:34:20 +02:00
Adrien Marquès 90472b8bf7
unexport dynfunc errors 2020-04-04 12:46:43 +02:00
Adrien Marquès df56496a16
dynfunc: normalize file names 2020-04-04 12:45:36 +02:00
Adrien Marquès caa57889b4
multipart: rename files and unexport errors 2020-04-04 12:43:55 +02:00
Adrien Marquès 4ba62e19c7
remove func.go and standardize main file name 2020-04-04 12:42:18 +02:00
Adrien Marquès 5cadfcf78b
unexport aicra errors 2020-04-04 12:40:21 +02:00
Adrien Marquès e0ea0c97c5
clarify datatype comments and standardize file name 2020-04-04 12:40:01 +02:00
Adrien Marquès 6319761731 Merge branch 'test/dynamic' of go/aicra into 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
test dynfunc package; standardize and refactor api
2020-04-04 10:09:19 +00:00
Adrien Marquès 92da498d49
remove server logs and util file
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-04-04 12:06:31 +02:00
Adrien Marquès 60ef4717a8
clarity: aicra server request management 2020-04-04 12:05:17 +02:00
Adrien Marquès 5cc3d2d455
use http.Request instead of pointer 2020-04-04 12:03:29 +02:00
Adrien Marquès c5cdba8007
move aicra builder and server into their own files 2020-04-04 11:50:01 +02:00
Adrien Marquès 09362aad83
make 'dynfunc' internal 2020-04-04 11:49:33 +02:00
Adrien Marquès d69dd2508c
refactor aicra: meaningful defaults, stage renaming Builder.Build() -> Server 2020-04-04 11:46:37 +02:00
Adrien Marquès 1e0fb77d61
standardize and simplify the config package 2020-04-04 11:45:49 +02:00
Adrien Marquès b0e25b431c
ToHTTPServer now returns the exported field http.Handler instead of an unexported type 2020-04-04 10:39:02 +02:00
Adrien Marquès b1498e59c1
clarity rename: dynamic package to dynfunc 2020-04-04 10:36:52 +02:00
Adrien Marquès eb690cf862
add api errors for storage 2020-04-04 10:10:24 +02:00
Adrien Marquès e1606273dd
remove useless func type 2020-04-04 10:10:24 +02:00
Adrien Marquès 8fa18cd61b
enforce dynamic signature check: no input struct allowed when no input is specified 2020-04-04 10:02:48 +02:00
Adrien Marquès db4429b329
ignore empty param renames when creating the spec, not after 2020-03-29 19:33:26 +02:00
Adrien Marquès b48c1d07bf
test: spec add checkOutput() tests for : nil type (ignore type check) ; invalid last output (not api.Error)
continuous-integration/drone/push Build is passing Details
2020-03-29 19:31:08 +02:00
Adrien Marquès 307021bc88
test: spec checkOutput() method
continuous-integration/drone/push Build is passing Details
2020-03-29 19:23:13 +02:00
Adrien Marquès 261e25c127
fix: invert conversion check 2020-03-29 19:23:02 +02:00
Adrien Marquès 438e308f71
merge duplicate errors 2020-03-29 19:22:43 +02:00
Adrien Marquès 7e42c1b6d9
test: spec checkInput() method 2020-03-29 19:14:12 +02:00
Adrien Marquès 66985dfbd0
forbid unexported input/output name 2020-03-29 19:13:07 +02:00
64 changed files with 4892 additions and 1916 deletions

View File

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

27
.github/workflows/go.yml vendored Normal file
View File

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

432
README.md
View File

@ -1,215 +1,339 @@
# | aicra |
<p align="center">
<a href="https://github.com/xdrm-io/aicra">
<img src="https://github.com/xdrm-io/aicra/raw/0.4.0/readme.assets/logo.png" alt="aicra logo" width="200" height="200">
</a>
</p>
<h3 align="center">aicra</h3>
<p align="center">
Fast, intuitive, and powerful configuration-driven engine for faster and easier <em>REST</em> development.
</p>
[![Go version](https://img.shields.io/badge/go_version-1.10.3-blue.svg)](https://golang.org/doc/go1.10)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Report Card](https://goreportcard.com/badge/git.xdrm.io/go/aicra)](https://goreportcard.com/report/git.xdrm.io/go/aicra)
[![Go doc](https://godoc.org/git.xdrm.io/go/aicra?status.svg)](https://godoc.org/git.xdrm.io/go/aicra)
[![Build Status](https://drone.xdrm.io/api/badges/go/aicra/status.svg)](https://drone.xdrm.io/go/aicra)
[![Go version](https://img.shields.io/badge/go_version-1.16-blue.svg)](https://golang.org/doc/go1.16)
[![Go doc](https://pkg.go.dev/badge/github.com/xdrm-io/aicra)](https://pkg.go.dev/github.com/xdrm-io/aicra)
[![Go Report Card](https://goreportcard.com/badge/github.com/xdrm-io/aicra)](https://goreportcard.com/report/github.com/xdrm-io/aicra)
[![Build status](https://github.com/xdrm-io/aicra/actions/workflows/go.yml/badge.svg)](https://github.com/xdrm-io/aicra/actions/workflows/go.yml)
## Presentation
**Aicra** is a *configuration-driven* **web framework** written in Go that allows you to create a fully featured REST API.
`aicra` is a lightweight and idiomatic configuration-driven engine for building REST services. It's especially good at helping you write large APIs that remain maintainable as your project grows.
The whole management is done for you from a configuration file describing your API, you're left with implementing :
- handlers
- optionnally middle-wares (_e.g. authentication, csrf_)
- and optionnally your custom type checkers to check input parameters
The focus of the project is to allow you to build a fully-featured REST API in an elegant, comfortable and inexpensive way. This is achieved by using a single configuration file to drive the server. This one file describes your entire API: methods, uris, input data, expected output, permissions, etc.
Repetitive tasks are automatically processed by `aicra` based on your configuration, you're left with implementing your handlers (_usually business logic_).
The aicra server fulfills the `net/http` [Server interface](https://golang.org/pkg/net/http/#Server).
> A example project is available [here](https://git.xdrm.io/go/tiny-url-ex)
### Table of contents
## Table of contents
<!-- toc -->
- [I/ Installation](#i-installation)
- [II/ Development](#ii-development)
* [1) Main executable](#1-main-executable)
* [2) API Configuration](#2-api-configuration)
- [Definition](#definition)
+ [Input Arguments](#input-arguments)
- [1. Input types](#1-input-types)
- [2. Global Format](#2-global-format)
- [III/ Change Log](#iii-change-log)
- [Installation](#installation)
- [What's automated](#whats-automated)
- [Getting started](#getting-started)
- [Configuration file](#configuration-file)
* [Services](#services)
* [Input and output parameters](#input-and-output-parameters)
* [Example](#example)
- [Writing your code](#writing-your-code)
- [Changelog](#changelog)
<!-- tocstop -->
### I/ Installation
You need a recent machine with `go` [installed](https://golang.org/doc/install). This package has not been tested under the version **1.10**.
## Installation
To install the aicra package, you need to install Go and set your Go workspace first.
> not tested under Go 1.14
1. you can use the below Go command to install aicra.
```bash
go get -u git.xdrm.io/go/aicra/cmd/aicra
$ go get -u github.com/xdrm-io/aicra
```
2. Import it in your code:
```go
import "github.com/xdrm-io/aicra"
```
The library should now be available as `git.xdrm.io/go/aicra` in your imports.
## What's automated
As the configuration file is here to make your life easier, let's take a quick look at what you do not have to do ; or in other words, what does `aicra` automates.
### II/ Development
Http requests are only accepted when they have the permissions you have defined. If unauthorized, the request is rejected with an error response.
Request data is automatically extracted and validated before it reaches your code. If a request has missing or invalid data an automatic error response is sent.
#### 1) Main executable
When launching the server, it ensures everything is ok and won't start until fixed. You will get errors for:
- handler signature does not match the configuration
- a configuration service has no handler
- a handler does not match any service
Your main executable will declare and run the aicra server, it might look quite like the code below.
The same applies if your configuration is invalid:
- unknown HTTP method
- invalid uri
- uri collision between 2 services
- missing fields
- unknown data type
- input name collision
## Getting started
Here is the minimal code to launch your aicra server assuming your configuration file is `api.json`.
```go
package main
import (
"log"
"net/http"
"log"
"net/http"
"os"
"git.xdrm.io/go/aicra"
"git.xdrm.io/go/aicra/datatype"
"git.xdrm.io/go/aicra/datatype/builtin"
"github.com/xdrm-io/aicra"
"github.com/xdrm-io/aicra/api"
"github.com/xdrm-io/aicra/validator/builtin"
)
func main() {
builder := &aicra.Builder{}
// 1. select your datatypes (builtin, custom)
var dtypes []datatype.T
dtypes = append(dtypes, builtin.AnyDataType{})
dtypes = append(dtypes, builtin.BoolDataType{})
dtypes = append(dtypes, builtin.UintDataType{})
dtypes = append(dtypes, builtin.StringDataType{})
// add custom type validators
builder.Validate(validator.BoolDataType{})
builder.Validate(validator.UintDataType{})
builder.Validate(validator.StringDataType{})
// 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)
}
// load your configuration
config, err := os.Open("api.json")
if err != nil {
log.Fatalf("cannot open config: %s", err)
}
err = builder.Setup(config)
config.Close() // free config file
if err != nil {
log.Fatalf("invalid config: %s", err)
}
// 3. bind your implementations
server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){
// ... process stuff ...
res.SetError(api.ErrorSuccess());
})
// add http middlewares (logger)
builder.With(func(next http.Handler) http.Handler{ /* ... */ })
// 4. extract to http server
httpServer, err := server.ToHTTPServer()
if err != nil {
log.Fatalf("cannot get to http server: %s", err)
}
// add contextual middlewares (authentication)
builder.WithContext(func(next http.Handler) http.Handler{ /* ... */ })
// 4. launch server
log.Fatal( http.ListenAndServe("localhost:8080", server) )
// bind handlers
err = builder.Bind(http.MethodGet, "/user/{id}", getUserById)
if err != nil {
log.Fatalf("cannog bind GET /user/{id}: %s", err)
}
// build your services
handler, err := builder.Build()
if err != nil {
log.Fatalf("cannot build handler: %s", err)
}
http.ListenAndServe("localhost:8080", handler)
}
```
If you want to use HTTPS, you can configure your own `http.Server`.
```go
func main() {
server := &http.Server{
Addr: "localhost:8080",
TLSConfig: tls.Config{},
// ...
Handler: AICRAHandler,
}
server.ListenAndServe()
}
```
## Configuration file
#### 2) API Configuration
First of all, the configuration uses `json`.
The whole project behavior is described inside a json file (_e.g. usually api.json_). For a better understanding of the format, take a look at this working [template](https://git.xdrm.io/go/tiny-url-ex/src/master/api.json). This file defines :
- routes and their methods
- every input for each method (called *argument*)
- every output for each method
- scope permissions (list of permissions needed by clients)
- input policy :
- type of argument (_i.e. for data types_)
- required/optional
- variable renaming
###### Definition
The root of the json file must be an array containing your requests definitions.
For each, you will have to create fields described in the table above.
| field path | description | example |
| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| `info` | A short human-readable description of what the method does | `create a new user` |
| `scope` | A 2-dimensional array of permissions. The first dimension can be translated to a **or** operator, the second dimension as a **and**. It allows you to combine permissions in complex ways. | `[["A", "B"], ["C", "D"]]` can be translated to : this method needs users to have permissions (A **and** B) **or** (C **and** D) |
| `in` | The list of arguments that the clients will have to provide. See [here](#input-arguments) for details. | |
| `out` | The list of output data that will be returned by your controllers. It has the same syntax as the `in` field but is only use for readability purpose and documentation. | |
##### Input Arguments
###### 1. Input types
Input arguments defines what data from the HTTP request the method needs. Aicra is able to extract 3 types of data :
- **URI** - Curly Braces enclosed strings inside the request path. For instance, if your controller is bound to the `/user/{id}` URI, you can set the input argument `{id}` matching this uri part.
- **Query** - data formatted at the end of the URL following the standard [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **URL encoded** - data send inside the body of the request but following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- **Multipart** - data send inside the body of the request with a dedicated [format](https://tools.ietf.org/html/rfc2388#section-3). This format is not very lightweight but allows you to receive data as well as files.
- **JSON** - data send inside the body as a json object ; each key being a variable name, each value its content. Note that the HTTP header '**Content-Type**' must be set to `application/json` for the API to use it.
###### 2. Global Format
The `in` field in each method contains as list of arguments where the key is the argument name, and the value defines how to manage the variable.
> Variable names from **URI** or **Query** must be named accordingly :
> Quick note if you thought: "I hate JSON, I would have preferred yaml, or even xml !"
>
> - the **URI** variable `{id}` from your request route must be named `{id}`.
> - the variable `somevar` in the **Query** has to be names `GET@somevar`.
> I've had a hard time deciding and testing different formats including yaml and xml.
> But as it describes our entire api and is crucial for our server to keep working over updates; xml would have been too verbose with growth and yaml on the other side would have been too difficult to read. Json sits in the right spot for this.
**Example**
Let's take a quick look at the configuration format !
In this example we want 3 arguments :
> if you don't like boring explanations and prefer a working example, see [here](https://git.xdrm.io/go/articles-api/src/master/api.json)
- the 1^st^ one is send at the end of the URI and is a number compliant with the `int` type checker. It is renamed `article_id`, this new name will be sent to the handler.
- the 2^nd^ one is send in the query (_e.g. [http://host/uri?get-var=value](http://host/uri?get-var=value)_). It must be a valid `string` or not given at all (the `?` at the beginning of the type tells that the argument is **optional**) ; it will be named `title`.
- the 3^rd^ can be send with a **JSON** body, in **multipart** or **URL encoded** it makes no difference and only give clients a choice over the technology to use. If not renamed, the variable will be given to the handler with the name `content`.
### Services
To begin with, the configuration file defines a list of services. Each one is defined by:
- `method` an HTTP method
- `path` an uri pattern (can contain variables)
- `info` a short description of what it does
- `scope` a list of the required permissions
- `in` a list of input arguments
- `out` a list of output arguments
```json
[
{
"method": "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" }
}
}
{
"method": "GET",
"path": "/article",
"scope": [["author", "reader"], ["admin"]],
"info": "returns all available articles",
"in": {},
"out": {}
}
]
```
The `scope` is a 2-dimensional list of permissions. The first list means **or**, the second means **and**, it allows for complex permission combinations. The example above can be translated to: this method requires users to have permissions (author **and** reader) **or** (admin)
### Input and output parameters
Input and output parameters share the same format, featuring:
- `info` a short description of what it is
- `type` its data type (_c.f. validation_)
- `?` whether it is mandatory or optional
- `name` a custom name for easy access in code
```json
[
{
"method": "PUT",
"path": "/article/{id}",
"scope": [["author"]],
"info": "updates an article",
"in": {
"{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" }
},
"out": {
"title": { "info": "updated article title", "type": "string" },
"content": { "info": "updated article content", "type": "string" }
}
}
]
```
If a parameter is optional you just have to prefix its type with a question mark, by default all parameters are mandatory.
The format of the key of input arguments defines where it comes from:
1. `{param}` is an URI parameter that is extracted from the `"path"`
2. `GET@param` is an URL parameter that is extracted from the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
3. `param` is a body parameter that can be extracted from 3 formats independently:
- _url encoded_: data send in the body following the [HTTP Query](https://tools.ietf.org/html/rfc3986#section-3.4) syntax.
- _multipart_: data send in the body with a dedicated [format](https://tools.ietf.org/html/rfc2388#section-3). This format can be quite heavy but allows to transmit data as well as files.
- _JSON_: data sent in the body as a json object ; The _Content-Type_ header must be `application/json` for it to work.
### Example
```json
[
{
"method": "PUT",
"path": "/article/{id}",
"scope": [["author"]],
"info": "updates an article",
"in": {
"{id}": { "info": "...", "type": "int", "name": "id" },
"GET@title": { "info": "...", "type": "?string", "name": "title" },
"content": { "info": "...", "type": "string" }
},
"out": {
"id": { "info": "updated article id", "type": "uint" },
"title": { "info": "updated article title", "type": "string" },
"content": { "info": "updated article content", "type": "string" }
}
}
]
```
1. `{id}` is extracted from the end of the URI and is a number compliant with the `int` type checker. It is renamed `ID`, this new name will be sent to the handler.
2. `GET@title` is extracted from the query (_e.g. [http://host/uri?get-var=value](http://host/uri?get-var=value)_). It must be a valid `string` or not given at all (the `?` at the beginning of the type tells that the argument is **optional**) ; it will be named `title`.
3. `content` can be extracted from json, multipart or url-encoded data; it makes no difference and only give clients a choice over the technology to use. If not renamed, the variable will be given to the handler with its original name `content`.
### III/ Change Log
## Writing your code
Besides your main package where you launch your server, you will need to create handlers matching services from the configuration.
The code below implements a simple handler.
```go
// "in": {
// "Input1": { "info": "...", "type": "int" },
// "Input2": { "info": "...", "type": "?string" }
// },
type req struct{
Input1 int
Input2 *string // optional are pointers
}
// "out": {
// "Output1": { "info": "...", "type": "string" },
// "Output2": { "info": "...", "type": "bool" }
// }
type res struct{
Output1 string
Output2 bool
}
func myHandler(ctx context.Context, r req) (*res, api.Err) {
err := doSomething()
if err != nil {
return nil, api.ErrFailure
}
return &res{"out1", true}, api.ErrSuccess
}
```
If your handler signature does not match the configuration exactly, the server will print out the error and won't start.
The `api.Err` type automatically maps to HTTP status codes and error descriptions that will be sent to the client as json; clients have to manage the same format for every response.
```json
{
"error": {
"code": 0,
"reason": "all right"
}
}
```
## Changelog
- [x] human-readable json configuration
- [x] nested routes (*i.e. `/user/:id:` and `/user/post/:id:`*)
- [x] nested URL arguments (*i.e. `/user/:id:` and `/user/:id:/post/:id:`*)
- [x] nested routes (*i.e. `/user/{id}` and `/user/post/{id}`*)
- [x] nested URL arguments (*i.e. `/user/{id}` and `/user/{uid}/post/{id}`*)
- [x] useful http methods: GET, POST, PUT, DELETE
- [x] manage URL, query and body arguments:
- [x] multipart/form-data (variables and file uploads)
- [x] application/x-www-form-urlencoded
- [x] application/json
- [ ] add support for PATCH method
- [ ] add support for OPTIONS method
- [ ] it might be interesting to generate the list of allowed methods from the configuration
- [ ] add CORS support
- [x] manage request data extraction:
- [x] URL slash-separated strings
- [x] HTTP Query named parameters
- [x] manage array format
- [x] body parameters
- [x] multipart/form-data (variables and file uploads)
- [x] application/x-www-form-urlencoded
- [x] application/json
- [x] required vs. optional parameters with a default value
- [x] parameter renaming
- [x] generic type check (*i.e. implement custom types alongside built-in ones*)
- [ ] built-in types
- [x] `any` - wildcard matching all values
- [x] `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] generic type check (*i.e. you can add custom types alongside built-in ones*)
- [x] built-in types
- [x] `any` - matches any value
- [x] `int` - see go types
- [x] `uint` - see go types
- [x] `float` - see go types
- [x] `string` - any text
- [x] `string(len)` - any string with a length of exactly `len` characters
- [x] `string(min, max)` - any string with a length between `min` and `max`
- [ ] `[]a` - array containing **only** elements matching `a` type
- [ ] `a[b]` - map containing **only** keys of type `a` and values of type `b` (*a or b can be ommited*)
- [x] generic handler implementation
- [x] response interface
- [x] log bound resources when building the aicra server
- [x] fail on check for unimplemented resources at server boot.
- [x] fail on check for unavailable types in api.json at server boot.
- [x] generic errors that automatically formats into response
- [x] builtin errors
- [x] possibility to add custom errors
- [x] check for missing handlers when building the handler
- [x] check handlers not matching a route in the configuration at server boot
- [x] specific configuration format errors qt server boot
- [x] statically typed handlers - avoids having to check every input and its type (_which is used by context.Context for instance_)
- [x] using reflection to use structs as input and output arguments to match the configuration
- [x] check for input and output arguments structs at server boot
- [x] check for unavailable types in configuration at server boot
- [x] recover panics from handlers
- [ ] improve tests and coverage

62
api/auth.go Normal file
View File

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

114
api/auth_test.go Normal file
View File

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

44
api/context.go Normal file
View File

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

79
api/context_test.go Normal file
View File

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

View File

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

View File

@ -1,42 +1,21 @@
package api
import (
"encoding/json"
"fmt"
)
// Error represents an http response error following the api format.
// Err represents an http response error following the api format.
// These are used by the services to set the *execution status*
// directly into the response as JSON alongside response output fields.
type Error int
// Error implements the error interface
func (e Error) Error() string {
// use unknown error if no reason
reason, ok := errorReasons[e]
if !ok {
return ErrorUnknown.Error()
}
return fmt.Sprintf("[%d] %s", e, reason)
type Err struct {
// error code (unique)
Code int `json:"code"`
// error small description
Reason string `json:"reason"`
// associated HTTP status
Status int `json:"-"`
}
// 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)
func (e Err) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Reason)
}

View File

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

View File

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

View File

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

157
builder.go Normal file
View File

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

439
builder_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
go.mod
View File

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

View File

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

1138
handler_test.go Normal file

File diff suppressed because it is too large Load Diff

116
http.go
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

13
internal/ctx/ctx.go Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
readme.assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

60
response.go Normal file
View File

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

95
response_test.go Normal file
View File

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

View File

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

15
util.go
View File

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

24
validator/any.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

55
validator/validator.go Normal file
View File

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