├── .github
└── FUNDING.yml
├── .gitignore
├── .travis.yml
├── CODE_OF_CONDUCT.md
├── LICENSE
├── Makefile
├── README.md
├── api.go
├── api_test.go
├── context.go
├── default_handler_500_test.go
├── doc.go
├── doc
├── developer.md
├── figure_1_normal_flow.png
├── figure_2_break_flow.png
├── figure_3_performance.png
├── figure_4_routing_example.png
└── todo.md
├── example
├── README.md
├── apidoc.md
├── example
├── main.go
├── main_test.go
└── model.go
├── extended_writer.go
├── extended_writer_test.go
├── go.mod
├── handler.go
├── icon.png
├── icon2.png
├── interceptor.go
├── interceptordeep_test.go
├── interceptors.go
├── logo.png
├── node.go
├── operation.go
├── operation_test.go
├── request_test.go
├── response_test.go
├── splittail.go
├── splittail_test.go
└── world_test.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [fulldump] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bin/
2 | pkg/
3 | .idea/
4 | src/
5 | coverage*
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 | env:
3 | global:
4 | - CC_TEST_REPORTER_ID=1d2439bd6471f837400ff913813cf146aea22d1a16f8e72ff7c56b7278ccb3ca
5 | go:
6 | - "1.5"
7 | - "1.6"
8 | - "1.7"
9 | - "1.8"
10 | - "1.9"
11 | - "1.10"
12 | - "1.11"
13 | - "1.12"
14 | - "1.13"
15 | - "1.14"
16 | - "1.15"
17 | - "1.16"
18 | - "1.17"
19 |
20 | script:
21 | - make setup && make test
22 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our Standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our Responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at gerardooscarjt@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44 |
45 | [homepage]: http://contributor-covenant.org
46 | [version]: http://contributor-covenant.org/version/1/4/
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 gerardooscarjt@gmail.com
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PROJECT=github.com/fulldump/golax
2 |
3 | GOCMD=go
4 |
5 | .PHONY: test clean dependencies setup example coverage
6 |
7 | all: test
8 |
9 | clean:
10 | rm -fr src
11 |
12 | setup:
13 | mkdir -p src/$(PROJECT)
14 | rmdir src/$(PROJECT)
15 | ln -s ../../.. src/$(PROJECT)
16 |
17 | test:
18 | $(GOCMD) version
19 | $(GOCMD) env
20 | $(GOCMD) test $(PROJECT)
21 |
22 | example:
23 | $(GOCMD) install $(PROJECT)/example
24 |
25 | coverage:
26 | $(GOCMD) test ./src/github.com/fulldump/goconfig -cover -covermode=count -coverprofile=coverage.out; \
27 | $(GOCMD) tool cover -html=coverage.out
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Golax is the official go implementation for the _Lax_ framework.
11 |
12 |
13 |
14 | - [About Lax](#about-lax)
15 | - [Getting started](#getting-started)
16 | - [Routing example](#routing-example)
17 | - [Performance](#performance)
18 | - [How interceptor works](#how-interceptor-works)
19 | - [Handling parameters](#handling-parameters)
20 | - [Support for Google custom methods](#support-for-google-custom-methods)
21 | - [Sample use cases](#sample-use-cases)
22 |
23 |
24 |
25 | Related docs:
26 |
27 | * [Developer notes](doc/developer.md)
28 | * [TODO list](doc/todo.md)
29 |
30 | ## About Lax
31 |
32 | Lax wants to be the best _"user experience"_ for developers making REST APIs.
33 |
34 | The design principles for _Lax_ are:
35 |
36 | * The lowest language overhead
37 | * Extremely fast to develop
38 | * Very easy to read and trace.
39 |
40 |
41 | ## Getting started
42 |
43 | ```go
44 | my_api := golax.NewApi()
45 |
46 | my_api.Root.
47 | Interceptor(golax.InterceptorError).
48 | Interceptor(myLogingInterceptor)
49 |
50 | my_api.Root.Node("hello").
51 | Method("GET", func(c *golax.Context) {
52 | // At this point, Root interceptors has been already executed
53 | fmt.Fprintln(c.Response, "Hello world!")
54 | })
55 |
56 | my_api.Serve()
57 | ```
58 |
59 | ## Routing example
60 |
61 | Routing is based on nodes.
62 |
63 |
64 |
65 |
66 |
67 | There are three types: `static`, `regex` and `parameter`.
68 |
69 | * static: Only matches with the url part if it is exactly the same.
70 | * regex: Surrounded by `(` and `)`, if the regex match.
71 | * parameter: Surrounded by `{` and `}`, always matches.
72 |
73 | ## Performance
74 |
75 | The performance compared with the [most popular alternative](http://www.gorillatoolkit.org/) is very similar (actually _golax_ performs slightly better) however code readability and maintainability is far better with _golax_ implementation.
76 |
77 |
78 |
79 |
80 |
81 | Tests has been executed in a `Intel(R) Core(TM) i5-3210M CPU @ 2.50GHz`.
82 |
83 | Learn more about this https://github.com/fulldump/golax-performance.
84 |
85 | ## How interceptor works
86 |
87 | If I want to handle a `GET /users/1234/stats` request, all interceptors in nodes from `` to `.../stats` are executed:
88 |
89 | 
90 |
91 | To abort the execution, call to `c.Error(404, "Resource not found")`:
92 |
93 | 
94 |
95 | ## Handling parameters
96 |
97 | ```go
98 | my_api := golax.NewApi()
99 |
100 | my_api.Root.
101 | Node("users").
102 | Node("{user_id}").
103 | Method("GET", func (c *golax.Context) {
104 | fmt.Fprintln(c.Response, "You are looking for user " + c.Parameter)
105 | })
106 |
107 | my_api.Serve()
108 | ```
109 |
110 | It is also possible get all parameters:
111 |
112 | ```go
113 | func (c *golax.Context) {
114 | fmt.Fprintln(c.Response, "All parameters:", c.Parameters)
115 | }
116 | ```
117 |
118 | ## Support for Google custom methods
119 |
120 | According to Google's API design guidelines to map RPC services to REST HTTP,
121 | it describes custom methods as extra operations that can not be easyly mapped
122 | to HTTP verbs. [More info about custom methods](https://cloud.google.com/apis/design/custom_methods)
123 |
124 | For example, this URL has a custom method `:activate`:
125 |
126 | ```
127 | https://my.service.com/v1/users/31231231231:activate
128 | ```
129 |
130 | Golax support custom methods as operations:
131 |
132 | ```go
133 | my_api.Root.
134 | Node("v1").
135 | Node("users").
136 | Node("{user_id}").
137 | Operation("activate").
138 | Method("POST", func(c *golax.Context) {
139 | user_id := c.Parameters["{user_id}"]"
140 | fmt.Fprintln(c.Response, "Here is custom method ':activate' for user "+user_id)
141 | })
142 | ```
143 |
144 |
145 | ## Sample use cases
146 |
147 | TODO: put here some examples to cover cool things:
148 |
149 | * fluent implementation
150 | * node cycling
151 | * readability
152 | * node preference
153 | * sample logging interceptor
154 | * sample auth interceptor
155 | * sample api errors
156 |
--------------------------------------------------------------------------------
/api.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 | "os"
8 | "regexp"
9 | "runtime/debug"
10 | "strings"
11 | )
12 |
13 | // Api is a complete API that implements http.Handler interface.
14 | type Api struct {
15 | Root *Node
16 | Prefix string
17 | Handler405 Handler
18 | Handler404 Handler
19 | Handler500 Handler
20 | }
21 |
22 | // NewApi instances and initializes a new *Api.
23 | func NewApi() *Api {
24 | return &Api{
25 | Root: NewNode(),
26 | Prefix: "",
27 | Handler404: defaultHandler404,
28 | Handler405: defaultHandler405,
29 | Handler500: defaultHandler500,
30 | }
31 | }
32 |
33 | // Serve start a default server on address 0.0.0.0:8000
34 | func (a *Api) Serve() {
35 | origin := "0.0.0.0:8000"
36 | log.Println("Server listening at " + origin)
37 | http.ListenAndServe(origin, a)
38 | }
39 |
40 | // ServeHTTP implements http.Handler interface.
41 | // This code is ugly but... It works! This is a critical part for the
42 | // performance, so it has to be written with love
43 | func (a *Api) ServeHTTP(w http.ResponseWriter, r *http.Request) {
44 |
45 | // Create the context and populate it
46 | c := NewContext()
47 | c.Response = NewExtendedWriter(w)
48 | c.Request = r
49 |
50 | defer func(c *Context) {
51 | if r := recover(); r != nil {
52 | c.Error(http.StatusInternalServerError, fmt.Sprintln(r)+string(debug.Stack()))
53 | a.Handler500(c)
54 | }
55 | }(c)
56 |
57 | runInterceptors(a.Root.Interceptors, c)
58 | addDeepInterceptors(a.Root.InterceptorsDeep, c)
59 |
60 | path := r.URL.Path
61 |
62 | // No prefix, not found
63 | if !strings.HasPrefix(path, a.Prefix) {
64 | runHandler(a.Handler404, c)
65 | return
66 | }
67 |
68 | // Remove prefix
69 | path = strings.TrimPrefix(path, a.Prefix)
70 |
71 | // Split path
72 | parts := strings.Split(path, "/")[1:]
73 |
74 | // Remove last part if empty
75 | last := len(parts) - 1
76 | if last >= 0 && "" == parts[last] {
77 | parts = parts[:last]
78 | }
79 |
80 | // Calculate PathHttp and PathHandler
81 | c.PathHandlers = a.Prefix
82 |
83 | // Search the right node
84 | current := a.Root
85 | var operation *Operation // Resulting operation
86 | lastPosition := len(parts) - 1 // cache parts length
87 | fullPath := false
88 | for i, part := range parts {
89 |
90 | partLast := i == lastPosition // cache is last part
91 |
92 | found := false
93 | for _, child := range current.Children {
94 |
95 | childPath := child._path // cache child.Path indirection
96 |
97 | if partLast && child._hasOperations {
98 | subparts := SplitTail(part, ":")
99 | if 2 == len(subparts) {
100 | subpart1 := subparts[1]
101 | operationE := false
102 | if operation, operationE = child.Operations[subpart1]; operationE {
103 | part = subparts[0]
104 | }
105 | }
106 | }
107 |
108 | if part == childPath {
109 | c.Parameter = ""
110 | c.PathHandlers += "/" + part
111 | found = true
112 | current = child
113 | break
114 | } else if child._isParameter {
115 | c.Parameter = part
116 | c.Parameters[child._parameterKey] = c.Parameter
117 | c.PathHandlers += "/" + childPath
118 | found = true
119 | current = child
120 | break
121 | } else if child._isRegex {
122 | regex := child._parameterKey
123 | if match, _ := regexp.MatchString(regex, part); match {
124 | c.Parameter = part
125 | c.Parameters[child._parameterKey] = c.Parameter
126 | c.PathHandlers += "/" + childPath
127 | found = true
128 | current = child
129 | break
130 | }
131 | } else if child._isFullPath {
132 | fullPath = true
133 | c.Parameter = strings.Join(parts[i:], "/")
134 | c.Parameters["*"] = c.Parameter
135 | c.PathHandlers += "/" + childPath
136 | found = true
137 | current = child
138 | break
139 | }
140 | }
141 |
142 | if found {
143 | runInterceptors(current.Interceptors, c)
144 | addDeepInterceptors(current.InterceptorsDeep, c)
145 | if nil != operation {
146 | c.PathHandlers += ":" + operation.Path
147 | runInterceptors(operation.Interceptors, c)
148 | }
149 | if fullPath {
150 | break
151 | }
152 | } else {
153 | c.PathHandlers = ""
154 | runHandler(a.Handler404, c)
155 | return
156 | }
157 | }
158 |
159 | methods := current.Methods
160 | if nil != operation {
161 | methods = operation.Methods
162 | }
163 |
164 | method := strings.ToUpper(r.Method)
165 | if f, exists := methods[method]; exists {
166 | runHandler(f, c)
167 | return
168 | }
169 |
170 | if f, exists := methods["*"]; exists {
171 | runHandler(f, c)
172 | return
173 | }
174 |
175 | c.PathHandlers = ""
176 | runHandler(a.Handler405, c)
177 | }
178 |
179 | func defaultHandler404(c *Context) {
180 | c.Error(404, "Not found")
181 | }
182 |
183 | func defaultHandler405(c *Context) {
184 | c.Error(405, "Method not allowed")
185 | }
186 |
187 | func defaultHandler500(c *Context) {
188 | os.Stderr.WriteString(c.LastError.Description)
189 | c.Error(http.StatusInternalServerError, "InternalServerError")
190 | }
191 |
192 | func addDeepInterceptors(l []*Interceptor, c *Context) {
193 | if nil != c.LastError {
194 | return
195 | }
196 | for _, i := range l {
197 | c.deepInterceptors = append([]*Interceptor{i}, c.deepInterceptors...)
198 | }
199 | }
200 |
201 | func runInterceptors(l []*Interceptor, c *Context) {
202 | if nil != c.LastError {
203 | return
204 | }
205 | for _, i := range l {
206 | if nil != i.After {
207 | c.afters = append(c.afters, i.After)
208 | }
209 |
210 | if nil != i.Before {
211 | i.Before(c)
212 | if nil != c.LastError {
213 | break
214 | }
215 | }
216 | }
217 | }
218 |
219 | func runHandler(f Handler, c *Context) {
220 |
221 | runInterceptors(c.deepInterceptors, c)
222 |
223 | if nil == c.LastError {
224 | f(c)
225 | }
226 | for i := len(c.afters) - 1; i >= 0; i-- {
227 | c.afters[i](c)
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/api_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io/ioutil"
7 | "net/http"
8 | "reflect"
9 | "strconv"
10 | "strings"
11 | "testing"
12 | )
13 |
14 | func bodyBytes(r *http.Request) []byte {
15 | body, err := ioutil.ReadAll(r.Body)
16 | if err != nil {
17 | panic(err)
18 | }
19 | return body
20 | }
21 |
22 | func bodyString(r *http.Request) string {
23 | return string(bodyBytes(r))
24 | }
25 |
26 | func Test_404_ok(t *testing.T) {
27 | world := NewWorld()
28 | defer world.Destroy()
29 |
30 | response := world.Request("GET", "/hello").Do()
31 |
32 | if http.StatusNotFound != response.StatusCode {
33 | t.Error("Status code '404' is expected")
34 | }
35 | }
36 |
37 | func Test_405_ok(t *testing.T) {
38 | world := NewWorld()
39 | defer world.Destroy()
40 |
41 | world.Api.Root.Method("POST", func(c *Context) {
42 | // Do nothing
43 | })
44 |
45 | response := world.Request("GET", "/").Do()
46 |
47 | if http.StatusMethodNotAllowed != response.StatusCode {
48 | t.Error("Status code '405' is expected")
49 | }
50 | }
51 |
52 | /**
53 | * What happens if path is empty string
54 | */
55 | func Test_border_case_1(t *testing.T) {
56 | world := NewWorld()
57 | defer world.Destroy()
58 |
59 | response := world.Request("GET", "").Do()
60 |
61 | if http.StatusMethodNotAllowed != response.StatusCode {
62 | t.Error("Status code '405' is expected")
63 | }
64 | }
65 |
66 | func Test_Prefix(t *testing.T) {
67 | world := NewWorld()
68 | defer world.Destroy()
69 |
70 | world.Api.Prefix = "/my/prefix/v3"
71 |
72 | world.Api.Root.Node("resource").Method(
73 | "GET",
74 | func(c *Context) {
75 | fmt.Fprint(c.Response, "My resource")
76 | },
77 | )
78 |
79 | response := world.Request("GET", "/my/prefix/v3/resource").Do()
80 |
81 | if "My resource" != response.BodyString() {
82 | t.Error("Body 'My resource' is expected")
83 | }
84 | }
85 |
86 | /**
87 | * Test if standard methods (and a invented one) are handleable.
88 | * A valid response should return the non standard `432` status code.
89 | */
90 | func Test_Methods_ok(t *testing.T) {
91 |
92 | methods := []string{
93 | "OPTIONS", "GET", "HEAD",
94 | "POST", "PUT", "DELETE",
95 | "TRACE", "CONNECT", "PATCH",
96 | "INVENTED",
97 | }
98 |
99 | for _, method := range methods {
100 | world := NewWorld()
101 |
102 | world.Api.Root.Node("hello").Method(method, func(c *Context) {
103 | c.Response.WriteHeader(432)
104 | })
105 |
106 | response := world.Request(method, "/hello").Do()
107 |
108 | if 432 != response.StatusCode {
109 | t.Error("Method '" + method + "': Status code '432' is expected")
110 | }
111 |
112 | world.Destroy()
113 | }
114 |
115 | }
116 |
117 | /**
118 | * Test if standard methods (and a invented one) are handleable if are not
119 | * defined but the asterisk method is defined.
120 | * A valid response should return the non standard `432` status code.
121 | */
122 | func Test_Method_asterisk_ok(t *testing.T) {
123 |
124 | methods := []string{
125 | "OPTIONS", "GET", "HEAD",
126 | "POST", "PUT", "DELETE",
127 | "TRACE", "CONNECT", "PATCH",
128 | "INVENTED",
129 | }
130 |
131 | for _, method := range methods {
132 | world := NewWorld()
133 |
134 | world.Api.Root.Node("hello").Method("*", func(c *Context) {
135 | c.Response.WriteHeader(432)
136 | })
137 |
138 | response := world.Request(method, "/hello").Do()
139 |
140 | if 432 != response.StatusCode {
141 | t.Error("Method '" + method + "': Status code '432' is expected")
142 | }
143 |
144 | world.Destroy()
145 | }
146 |
147 | }
148 |
149 | /**
150 | * Test method precedence (all methods over asterisk)
151 | * Status code `432` should be returned
152 | */
153 | func Test_Method_not_asterisk_ok(t *testing.T) {
154 |
155 | methods := []string{
156 | "OPTIONS", "GET", "HEAD",
157 | "POST", "PUT", "DELETE",
158 | "TRACE", "CONNECT", "PATCH",
159 | "INVENTED",
160 | }
161 |
162 | for _, method := range methods {
163 | world := NewWorld()
164 |
165 | world.Api.Root.Node("hello").Method(method, func(c *Context) {
166 | c.Response.WriteHeader(432)
167 | })
168 |
169 | world.Api.Root.Node("hello").Method("*", func(c *Context) {
170 | c.Response.WriteHeader(431)
171 | })
172 |
173 | response := world.Request(method, "/hello").Do()
174 |
175 | if 432 != response.StatusCode {
176 | t.Error("Method '" + method + "': Status code '432' is expected")
177 | }
178 |
179 | world.Destroy()
180 | }
181 |
182 | }
183 |
184 | /**
185 | * methods defined as lower case should be also handled
186 | */
187 | func Test_Method_lowercase_ok(t *testing.T) {
188 |
189 | methods := []string{
190 | "options", "get", "head",
191 | "post", "put", "delete",
192 | "trace", "connect", "patch",
193 | "invented", "opTionS", "Put", "pOst", "dELETE",
194 | }
195 |
196 | for _, method := range methods {
197 | world := NewWorld()
198 |
199 | world.Api.Root.Node("hello").Method(method, func(c *Context) {
200 | c.Response.WriteHeader(432)
201 | })
202 |
203 | METHOD := strings.ToUpper(method)
204 | response := world.Request(METHOD, "/hello").Do()
205 |
206 | if 432 != response.StatusCode {
207 | t.Error("Method '" + method + "': Status code '432' is expected")
208 | }
209 |
210 | world.Destroy()
211 | }
212 |
213 | }
214 |
215 | /**
216 | * methods defined as upper case but the http request is lowercase
217 | */
218 | func Test_Method_uppercase_ok(t *testing.T) {
219 |
220 | methods := []string{
221 | "options", "get", "head",
222 | "post", "put", "delete",
223 | "trace", "connect", "patch",
224 | "invented", "opTionS", "Put", "pOst", "dELETE",
225 | }
226 |
227 | for _, method := range methods {
228 | world := NewWorld()
229 |
230 | world.Api.Root.Node("hello").Method(method, func(c *Context) {
231 | c.Response.WriteHeader(432)
232 | })
233 |
234 | METHOD := strings.ToLower(method)
235 | response := world.Request(METHOD, "/hello").Do()
236 |
237 | if 432 != response.StatusCode {
238 | t.Error("Method '" + method + "': Status code '432' is expected")
239 | }
240 |
241 | world.Destroy()
242 | }
243 | }
244 |
245 | /**
246 | * Call to context.Error `555`
247 | */
248 | func Test_Method_error_555(t *testing.T) {
249 |
250 | world := NewWorld()
251 | defer world.Destroy()
252 |
253 | world.Api.Root.Interceptor(&Interceptor{
254 | After: func(c *Context) {
255 | if nil != c.LastError {
256 | c.Response.WriteHeader(c.LastError.StatusCode)
257 | }
258 | },
259 | })
260 |
261 | world.Api.Root.Node("hello").Method("GET", func(c *Context) {
262 | c.Error(555, "Sample error")
263 | })
264 |
265 | response := world.Request("GET", "/hello").Do()
266 |
267 | if 555 != response.StatusCode {
268 | t.Error("Status code '555' is expected")
269 | }
270 |
271 | }
272 |
273 | func Test_Parameter(t *testing.T) {
274 |
275 | world := NewWorld()
276 | defer world.Destroy()
277 |
278 | world.Api.Root.Node("users").Node("{id}").Method("GET", func(c *Context) {
279 | fmt.Fprintln(c.Response, "The user is "+c.Parameter)
280 | })
281 |
282 | response := world.Request("GET", "/users/42").Do()
283 |
284 | if 200 != response.StatusCode {
285 | t.Error("Status code '200' is expected")
286 | }
287 |
288 | if "The user is 42\n" != response.BodyString() {
289 | t.Error("Body 'The user is 42\\n' is expected")
290 | }
291 |
292 | }
293 |
294 | func Test_Parameters(t *testing.T) {
295 |
296 | world := NewWorld()
297 | defer world.Destroy()
298 |
299 | world.Api.Root.
300 | Node("{a}").
301 | Node("{b}").
302 | Node("(^[0-9]+[A-Z]$)").
303 | Node("{{*}}").
304 | Method("GET", func(c *Context) {
305 | json.NewEncoder(c.Response).Encode(c.Parameters)
306 | })
307 |
308 | response := world.Request("GET", "/1/2/33Z/444/555/666").Do()
309 |
310 | obtainedBody := response.BodyJson()
311 | expectedBody := map[string]interface{}{
312 | "a": "1",
313 | "b": "2",
314 | "^[0-9]+[A-Z]$": "33Z",
315 | "*": "444/555/666",
316 | }
317 |
318 | if !reflect.DeepEqual(obtainedBody, expectedBody) {
319 | t.Error("Parameters are not being collected")
320 | }
321 |
322 | }
323 |
324 | func Test_Parameters_collision(t *testing.T) {
325 |
326 | world := NewWorld()
327 | defer world.Destroy()
328 |
329 | world.Api.Root.Node("{a}").Node("{a}").Method("GET", func(c *Context) {
330 | json.NewEncoder(c.Response).Encode(c.Parameters)
331 | })
332 |
333 | response := world.Request("GET", "/1/2").Do()
334 |
335 | obtainedBody := response.BodyJson()
336 | expectedBody := map[string]interface{}{
337 | "a": "2",
338 | }
339 |
340 | if !reflect.DeepEqual(obtainedBody, expectedBody) {
341 | t.Error("Parameters are not being collected")
342 | }
343 |
344 | }
345 |
346 | /**
347 | * The users node has two nodes in order:
348 | * - stats
349 | * - {user_id}
350 | * GET /users/stats should return 200 `There are 2000 users`
351 | * GET /users/1231 should return 200 `User 1231`
352 | * Get /users/9999 should return 404 `User 9999 does not exist`
353 | */
354 | func Test_Parameter_precedence(t *testing.T) {
355 |
356 | world := NewWorld()
357 | defer world.Destroy()
358 |
359 | root := world.Api.Root
360 | root.Interceptor(&Interceptor{
361 | After: func(c *Context) {
362 | if nil != c.LastError {
363 | c.Response.WriteHeader(c.LastError.StatusCode)
364 | fmt.Fprint(c.Response, c.LastError.Description)
365 | }
366 | },
367 | })
368 |
369 | users := root.Node("users")
370 |
371 | stats := users.Node("stats")
372 | stats.Method("GET", func(c *Context) {
373 | fmt.Fprint(c.Response, "There are 2000 users")
374 | })
375 |
376 | user := users.Node("{user_id}")
377 | user.Method("GET", func(c *Context) {
378 | userID, _ := strconv.Atoi(c.Parameter)
379 | if userID > 2000 {
380 | c.Error(404, "User "+c.Parameter+" does not exist")
381 | return
382 | }
383 |
384 | fmt.Fprint(c.Response, "User "+c.Parameter)
385 | })
386 |
387 | response1 := world.Request("GET", "/users/stats").Do()
388 | if 200 != response1.StatusCode {
389 | t.Error("Status code `200` is expected")
390 | }
391 | if "There are 2000 users" != response1.BodyString() {
392 | t.Error("Body `There are 2000 users` is expected")
393 | }
394 |
395 | response2 := world.Request("GET", "/users/1231").Do()
396 | if 200 != response2.StatusCode {
397 | t.Error("Status code `200` is expected")
398 | }
399 | if "User 1231" != response2.BodyString() {
400 | t.Error("Body `User 1231` is expected")
401 | }
402 |
403 | response3 := world.Request("GET", "/users/9999").Do()
404 | if 404 != response3.StatusCode {
405 | t.Error("Status code `404` is expected")
406 | }
407 | if "User 9999 does not exist" != response3.BodyString() {
408 | t.Error("Body `User 9999 does not exist` is expected")
409 | }
410 |
411 | }
412 |
413 | /**
414 | * https://github.com/fulldump/golax/issues/5
415 | * If a parameter is not the last one, it is not possible getting its value
416 | */
417 | func Test_ParameterBug_issue_5(t *testing.T) {
418 | world := NewWorld()
419 | defer world.Destroy()
420 |
421 | myInterceptor := &Interceptor{
422 | Before: func(c *Context) {
423 | c.Set("my_parameter", c.Parameter)
424 | },
425 | }
426 |
427 | getProfile := func(c *Context) {
428 | myParameter, _ := c.Get("my_parameter")
429 | fmt.Fprint(c.Response, "parameter: "+myParameter.(string))
430 | }
431 |
432 | world.Api.Root.
433 | Node("users").
434 | Node("{aa}").
435 | Interceptor(myInterceptor).
436 | Node("profile").Method("GET", getProfile)
437 |
438 | response := world.Request("GET", "/users/-the-value-/profile").Do()
439 |
440 | body := response.BodyString()
441 |
442 | if "parameter: -the-value-" != body {
443 | t.Error("Body does not match")
444 | }
445 | }
446 |
447 | func Test_handling(t *testing.T) {
448 | world := NewWorld()
449 | defer world.Destroy()
450 |
451 | wrapper := func(text string) *Interceptor {
452 | return &Interceptor{
453 | Before: func(c *Context) {
454 | fmt.Println(text)
455 | fmt.Fprintf(c.Response, "%s(", text)
456 | },
457 | After: func(c *Context) {
458 | fmt.Println("/" + text)
459 | fmt.Fprintf(c.Response, ")%s", text)
460 | },
461 | }
462 | }
463 |
464 | root := world.Api.Root
465 | root.Interceptor(wrapper("root"))
466 |
467 | a := root.Node("a")
468 | a.Interceptor(wrapper("a"))
469 |
470 | b := a.Node("b")
471 | b.Interceptor(wrapper("b"))
472 |
473 | c := b.Node("c")
474 | c.Interceptor(wrapper("c"))
475 | c.Method("GET", func(c *Context) {
476 | fmt.Println("Hello world, I am C")
477 | fmt.Fprint(c.Response, "Hello world, I am C")
478 | })
479 |
480 | response := world.Request("GET", "/a/b/c").Do()
481 |
482 | body := response.BodyString()
483 |
484 | if "root(a(b(c(Hello world, I am C)c)b)a)root" != body {
485 | t.Error("Body does not match")
486 | }
487 | }
488 |
489 | func Test_RegexParameter_ok(t *testing.T) {
490 | world := NewWorld()
491 | defer world.Destroy()
492 |
493 | world.Api.Root.Node("(^a+$)").Method("GET", func(c *Context) {
494 | fmt.Fprint(c.Response, "a+:", c.Parameter)
495 | })
496 |
497 | world.Api.Root.Node("(^b+$)").Method("GET", func(c *Context) {
498 | fmt.Fprint(c.Response, "b+:", c.Parameter)
499 | })
500 |
501 | world.Api.Root.Node("abba").Method("GET", func(c *Context) {
502 | fmt.Fprint(c.Response, "static:abba")
503 | })
504 |
505 | r1 := world.Request("GET", "/a").Do()
506 | if "a+:a" != r1.BodyString() {
507 | t.Error("r1: Body a+ does not match")
508 | }
509 |
510 | r2 := world.Request("GET", "/bbbbb").Do()
511 | if "b+:bbbbb" != r2.BodyString() {
512 | t.Error("Body b+ does not match")
513 | }
514 |
515 | r3 := world.Request("GET", "/abba").Do()
516 | if "static:abba" != r3.BodyString() {
517 | t.Error("Body abba does not match")
518 | }
519 |
520 | r4 := world.Request("GET", "/").Do()
521 | if 405 != r4.StatusCode {
522 | t.Error("r4: Status code does not match")
523 | }
524 | }
525 |
526 | func Test_FullPath(t *testing.T) {
527 | world := NewWorld()
528 | defer world.Destroy()
529 |
530 | world.Api.Prefix = "/service"
531 |
532 | world.Api.Root.Node("files").Node("{{*}}").Method(
533 | "GET",
534 | func(c *Context) {
535 | fmt.Fprint(c.Response, c.Parameter)
536 | },
537 | )
538 |
539 | response := world.Request("GET", "/service/files/static/docs/document.txt").Do()
540 |
541 | body := response.BodyString()
542 |
543 | if "static/docs/document.txt" != body {
544 | t.Error("Parameter does not match")
545 | }
546 |
547 | }
548 |
549 | func alwaysBreak(name string) *Interceptor {
550 | return &Interceptor{
551 | Before: func(c *Context) {
552 | c.Response.Header().Add("Interceptors", "[BREAK "+name+"]")
553 | c.Error(666, "Break "+name)
554 | },
555 | }
556 | }
557 |
558 | func alwaysWork(name string) *Interceptor {
559 | return &Interceptor{
560 | Before: func(c *Context) {
561 | c.Response.Header().Add("Interceptors", "[WORK "+name+"]")
562 | },
563 | }
564 | }
565 |
566 | func Test_Interceptors_ErrorChain0(t *testing.T) {
567 | world := NewWorld()
568 | defer world.Destroy()
569 |
570 | world.Api.Root.
571 | Interceptor(alwaysWork("1")).
572 | Interceptor(alwaysWork("2")).
573 | Interceptor(alwaysBreak("Z")).
574 | Method("GET", func(c *Context) {
575 | c.Response.Header().Add("Interceptors", "[ROOT]")
576 | }).
577 | Node("node").
578 | Interceptor(alwaysWork("3")).
579 | Interceptor(alwaysWork("4")).
580 | Method("GET", func(c *Context) {
581 | c.Response.Header().Add("Interceptors", "[NODE]")
582 | })
583 |
584 | r := world.Request("GET", "/node").Do()
585 |
586 | status := r.StatusCode
587 | chain := strings.Join(r.Header["Interceptors"], "")
588 | fmt.Println(r.StatusCode, chain)
589 |
590 | if 666 != status {
591 | t.Error("Status code should be 666")
592 | }
593 |
594 | if "[WORK 1][WORK 2][BREAK Z]" != chain {
595 | t.Error("Chain does not match")
596 | }
597 | }
598 |
599 | func Test_Interceptors_ErrorChain1(t *testing.T) {
600 | world := NewWorld()
601 | defer world.Destroy()
602 |
603 | world.Api.Root.
604 | Interceptor(alwaysWork("1")).
605 | Interceptor(alwaysBreak("Z")).
606 | Interceptor(alwaysWork("2")).
607 | Method("GET", func(c *Context) {
608 | c.Response.Header().Add("Interceptors", "[ROOT]")
609 | }).
610 | Node("node").
611 | Interceptor(alwaysWork("3")).
612 | Interceptor(alwaysWork("4")).
613 | Method("GET", func(c *Context) {
614 | c.Response.Header().Add("Interceptors", "[NODE]")
615 | })
616 |
617 | r := world.Request("GET", "/node").Do()
618 |
619 | status := r.StatusCode
620 | chain := strings.Join(r.Header["Interceptors"], "")
621 | fmt.Println(r.StatusCode, chain)
622 |
623 | if 666 != status {
624 | t.Error("Status code should be 666")
625 | }
626 |
627 | if "[WORK 1][BREAK Z]" != chain {
628 | t.Error("Chain does not match")
629 | }
630 | }
631 |
632 | func Test_Interceptors_ErrorChain2(t *testing.T) {
633 | world := NewWorld()
634 | defer world.Destroy()
635 |
636 | world.Api.Root.
637 | Interceptor(alwaysWork("1")).
638 | Interceptor(alwaysWork("2")).
639 | Method("GET", func(c *Context) {
640 | c.Response.Header().Add("Interceptors", "[ROOT]")
641 | }).
642 | Node("node").
643 | Interceptor(alwaysWork("3")).
644 | Interceptor(alwaysWork("4")).
645 | Method("GET", func(c *Context) {
646 | c.Response.Header().Add("Interceptors", "[NODE]")
647 | })
648 |
649 | r := world.Request("GET", "/node").Do()
650 |
651 | status := r.StatusCode
652 | chain := strings.Join(r.Header["Interceptors"], "")
653 | fmt.Println(r.StatusCode, chain)
654 |
655 | if 200 != status {
656 | t.Error("Status code should be 666")
657 | }
658 |
659 | if "[WORK 1][WORK 2][WORK 3][WORK 4][NODE]" != chain {
660 | t.Error("Chain does not match")
661 | }
662 | }
663 |
664 | func Test_Interceptors_ErrorChain3(t *testing.T) {
665 | world := NewWorld()
666 | defer world.Destroy()
667 |
668 | world.Api.Root.
669 | Interceptor(alwaysWork("1")).
670 | Interceptor(alwaysWork("2")).
671 | Method("GET", func(c *Context) {
672 | c.Response.Header().Add("Interceptors", "[ROOT]")
673 | }).
674 | Node("node").
675 | Interceptor(alwaysWork("3")).
676 | Interceptor(alwaysBreak("Z")).
677 | Interceptor(alwaysWork("4")).
678 | Method("GET", func(c *Context) {
679 | c.Response.Header().Add("Interceptors", "[NODE]")
680 | })
681 |
682 | r := world.Request("GET", "/node").Do()
683 |
684 | status := r.StatusCode
685 | chain := strings.Join(r.Header["Interceptors"], "")
686 | fmt.Println(r.StatusCode, chain)
687 |
688 | if 666 != status {
689 | t.Error("Status code should be 666")
690 | }
691 |
692 | if "[WORK 1][WORK 2][WORK 3][BREAK Z]" != chain {
693 | t.Error("Chain does not match")
694 | }
695 |
696 | }
697 |
--------------------------------------------------------------------------------
/context.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import "net/http"
4 |
5 | // Context is a space to store information to be passed between interceptors and
6 | // the final handler.
7 | type Context struct {
8 | Request *http.Request
9 | Response *ExtendedWriter
10 | Parameter string
11 | Parameters map[string]string
12 | LastError *ContextError
13 | Scope map[string]interface{}
14 | PathHandlers string
15 | afters []Handler
16 | deepInterceptors []*Interceptor
17 | }
18 |
19 | // ContextError is the error passed back when context.Error is called. It can be
20 | // used inside an interceptor or a handler
21 | type ContextError struct {
22 | StatusCode int `json:"status_code"`
23 | ErrorCode int `json:"error_code"`
24 | Description string `json:"description_code"`
25 | }
26 |
27 | // NewContext instances and initializes a Context
28 | func NewContext() *Context {
29 | return &Context{
30 | LastError: nil,
31 | Parameters: map[string]string{},
32 | Scope: map[string]interface{}{},
33 | afters: []Handler{},
34 | deepInterceptors: []*Interceptor{},
35 | }
36 | }
37 |
38 | func (c *Context) Error(s int, d string) *ContextError {
39 | c.Response.WriteHeader(s)
40 | e := &ContextError{
41 | StatusCode: s,
42 | Description: d,
43 | }
44 | c.LastError = e
45 | return e
46 | }
47 |
48 | // Set stores a key-value tuple inside a Context
49 | func (c *Context) Set(k string, v interface{}) {
50 | c.Scope[k] = v
51 | }
52 |
53 | // Get retrieves a value from a Context
54 | func (c *Context) Get(k string) (interface{}, bool) {
55 | a, b := c.Scope[k]
56 | return a, b
57 | }
58 |
--------------------------------------------------------------------------------
/default_handler_500_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "net/http"
5 | "testing"
6 | )
7 |
8 | func Test_Default500_PathHandlers(t *testing.T) {
9 | world := NewWorld()
10 | defer world.Destroy()
11 |
12 | PanicHandler := func(c *Context) {
13 | panic("Something bad happened...")
14 | }
15 |
16 | world.Api.Root.
17 | Interceptor(InterceptorError).
18 | Node("a").
19 | Node("b").
20 | Node("c").
21 | Method("GET", PanicHandler)
22 |
23 | { // Case 1
24 | r := world.Request("GET", "/a/b/c").Do()
25 |
26 | if http.StatusInternalServerError != r.StatusCode {
27 | t.Error("StatusCode should be 500")
28 | }
29 |
30 | body := r.BodyString()
31 | if "" != body {
32 | t.Error("Body for default 500 handler should be empty")
33 | }
34 |
35 | }
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | // Doc represents documentation information that can be attached to a node,
4 | // method or interceptor.
5 | type Doc struct {
6 | Name string
7 | Description string
8 | Ommit bool
9 | }
10 |
--------------------------------------------------------------------------------
/doc/developer.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - [Implementation decisions](#implementation-decisions)
5 | - [Decision #1: Only context](#decision-1-only-context)
6 | - [Decision #2: The Hollywood Principle](#decision-2-the-hollywood-principle)
7 | - [Decision #3: Interceptor flow](#decision-3-interceptor-flow)
8 | - [Decision #4: Custom methods](#decision-4-custom-methods)
9 |
10 |
11 |
12 | # Implementation decisions
13 |
14 | This part cover some of the implementation decisions taken along the development process.
15 |
16 | ## Decision #1: Only context
17 |
18 | Handler functions has 1 parameter:
19 |
20 | ```
21 | func (c *golax.Context) {
22 |
23 | }
24 | ```
25 |
26 | Why not `w`, `r` and `c` and maintain developer compatibility?
27 |
28 | We would ended up with the following signature:
29 |
30 | ```
31 | func (w http.ResponseWriter, r *http.Request, c *golax.Context) {
32 |
33 | }
34 | ```
35 |
36 | Old code is not going to work by doing copy&paste, but you only have to replace:
37 |
38 | * `w` by `c.Response`
39 | * `r` by `c.Request`
40 |
41 | Making this decision is hard but `c *golax.Context` is much easier to remember and to write.
42 |
43 | About code readability, `w.Write(...)` is shorter but `c.Response.Write(...)` is more semantic.
44 |
45 | ## Decision #2: The Hollywood Principle
46 |
47 | Changing _Middleware_ vs _Interceptor_ is a semantic decision to break up with Sinatra styled frameworks.
48 |
49 | Typical middlewares should call to `next()` to continue chaining execution. On the other hand, an interceptor has two parts `Before` and `After` and you don't have to call any `next()` or similar. It follows the _Hollywood Principle_ known as "Don't call us, we'll call you".
50 |
51 |
52 | ## Decision #3: Interceptor flow
53 |
54 | 
55 |
56 | Each node in the routing hierarchy executes all its parent nodes interceptors. Why?
57 |
58 | This is the desired behaviour for the 95% of the cases or even more. With several advantages:
59 |
60 | * You don't have to repeat the interceptor (or middleware) in every endpoint.
61 | * You get prettier, more readable and understandable code.
62 | * Avoid human errors.
63 |
64 |
65 | ## Decision #4: Custom methods
66 |
67 | Support for custom methods is a routing feature, you are free to use or not. It does not mean
68 | coupling with other libraries or adding non-routing responsibilites.
69 |
70 | If you do not use custom methods (`.Operation("your-custom-method")`) the router behaves
71 | exactly as if it does not support them. In other words, it is backwards compatible.
72 |
73 |
--------------------------------------------------------------------------------
/doc/figure_1_normal_flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fulldump/golax/6a387f0818ac2927e3161214178731f44ba5c301/doc/figure_1_normal_flow.png
--------------------------------------------------------------------------------
/doc/figure_2_break_flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fulldump/golax/6a387f0818ac2927e3161214178731f44ba5c301/doc/figure_2_break_flow.png
--------------------------------------------------------------------------------
/doc/figure_3_performance.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fulldump/golax/6a387f0818ac2927e3161214178731f44ba5c301/doc/figure_3_performance.png
--------------------------------------------------------------------------------
/doc/figure_4_routing_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fulldump/golax/6a387f0818ac2927e3161214178731f44ba5c301/doc/figure_4_routing_example.png
--------------------------------------------------------------------------------
/doc/todo.md:
--------------------------------------------------------------------------------
1 | # TODO list
2 |
3 | ## Pending
4 | * node cycling
5 | * write some documentation examples
6 | * To be discussed: the best way to break the flow execution
7 |
8 | ## Done
9 | * Allow parameters validation with regex
10 | * implement some out-of-the-box interceptors
11 | * performance benchmarks
12 | * More flexible `Api.Serve()`
13 | * parameters
14 | * fluent implementation
15 | * more readable code
16 | * node preference
17 | * error handling
18 | * implement interceptors funcionality
19 |
20 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # Example project
2 |
3 | The example project implements a sample REST API with `golax`.
4 |
5 |
6 |
7 | - [Up and running](#up-and-running)
8 | - [The API](#the-api)
9 | - [List user ids `GET /service/v1/users/`](#list-user-ids-get-servicev1users)
10 | - [Create new user `POST /service/v1/users/`](#create-new-user-post-servicev1users)
11 | - [Get a user `GET /service/v1/users/{user_id}`](#get-a-user-get-servicev1usersuser_id)
12 | - [Modify a user `POST /service/v1/users/{user_id}`](#modify-a-user-post-servicev1usersuser_id)
13 | - [Delete a user `DELETE /service/v1/users/{user_id}`](#delete-a-user-delete-servicev1usersuser_id)
14 |
15 |
16 |
17 | ## Up and running
18 |
19 | How to build and run:
20 |
21 | ```sh
22 | make example && ./_vendor/bin/example
23 | ```
24 |
25 | ## The API
26 |
27 | It is a CRUD over a `users` collection.
28 |
29 | ### List user ids `GET /service/v1/users/`
30 |
31 | Command:
32 | ```sh
33 | curl -i http://localhost:8000/service/v1/users/
34 | ```
35 |
36 | Result:
37 | ```http
38 | HTTP/1.1 200 OK
39 | Date: Sun, 31 Jan 2016 21:15:50 GMT
40 | Content-Length: 8
41 | Content-Type: text/plain; charset=utf-8
42 |
43 | [1,2,3]
44 | ```
45 |
46 | ### Create new user `POST /service/v1/users/`
47 |
48 | Command:
49 | ```sh
50 | curl -i http://localhost:8000/service/v1/users/ --data '{"name":"Oscar"}'
51 | ```
52 |
53 | Result:
54 | ```http
55 | HTTP/1.1 201 Created
56 | Date: Sun, 31 Jan 2016 21:32:38 GMT
57 | Content-Length: 9
58 | Content-Type: text/plain; charset=utf-8
59 |
60 | {"id":4}
61 | ```
62 |
63 | ### Get a user `GET /service/v1/users/{user_id}`
64 |
65 | Command:
66 | ```sh
67 | curl -i http://localhost:8000/service/v1/users/4
68 | ```
69 |
70 | Result:
71 | ```http
72 | HTTP/1.1 200 OK
73 | Date: Sun, 31 Jan 2016 21:33:36 GMT
74 | Content-Length: 43
75 | Content-Type: text/plain; charset=utf-8
76 |
77 | {"name":"Oscar","age":0,"introduction":""}
78 | ```
79 |
80 | ### Modify a user `POST /service/v1/users/{user_id}`
81 |
82 | Command:
83 | ```sh
84 | curl -i http://localhost:8000/service/v1/users/4 \
85 | --data '{"age":70, "introduction": "Hello, I like golax"}'
86 | ```
87 |
88 | Result:
89 | ```http
90 | HTTP/1.1 200 OK
91 | Date: Sun, 31 Jan 2016 21:35:12 GMT
92 | Content-Length: 0
93 | Content-Type: text/plain; charset=utf-8
94 |
95 | ```
96 |
97 | ### Delete a user `DELETE /service/v1/users/{user_id}`
98 |
99 | Command:
100 | ```sh
101 | curl -i -X DELETE http://localhost:8000/service/v1/users/1
102 | ```
103 |
104 | Result:
105 | ```http
106 | HTTP/1.1 200 OK
107 | Date: Sun, 31 Jan 2016 21:36:40 GMT
108 | Content-Length: 0
109 | Content-Type: text/plain; charset=utf-8
110 |
111 | ```
112 |
113 |
--------------------------------------------------------------------------------
/example/apidoc.md:
--------------------------------------------------------------------------------
1 | # API Documentation
2 | _Example_ is a demonstration REST API that implements a CRUD over a collection
3 | of users, stored in memory.
4 |
5 | All API calls:
6 | * are returning errors with the same JSON format and
7 | * are logging all request to standard output.
8 |
9 | **Interceptors applied to all API:** [`Log`](#interceptor-log) [`Error`](#interceptor-error)
10 |
11 | ## /service/v1/users
12 |
13 | Resource users to list and create elements. It does not support pagination,
14 | sorting or filtering.
15 |
16 | **Interceptors chain:** [`Log`](#interceptor-log) [`Error`](#interceptor-error)
17 |
18 | **Methods:** [`GET`](#get-servicev1users) [`POST`](#post-servicev1users)
19 |
20 | ### GET /service/v1/users
21 |
22 | Return a list with a list of user ids:
23 |
24 | ```json
25 | [1,2,3]
26 | ```
27 |
28 | ### POST /service/v1/users
29 |
30 | Create a user:
31 | ```sh
32 | curl http://localhost:8000/service/v1/users --data '{"name": "John"}'
33 | ```
34 | And return the user id:
35 | ```json
36 | {"id":4}
37 | ```
38 |
39 | ## /service/v1/users/{user_id}
40 |
41 | Resource user to retrieve, modify and delete. A user has this structure:
42 |
43 | ```json
44 | {
45 | "name": "Menganito Menganez",
46 | "age": 30,
47 | "introduction": "Hi, I like wheels and cars"
48 | }
49 | ```
50 |
51 | **Interceptors chain:** [`Log`](#interceptor-log) [`Error`](#interceptor-error) [`User`](#interceptor-user)
52 |
53 | **Methods:** [`GET`](#get-servicev1usersuser_id) [`POST`](#post-servicev1usersuser_id) [`DELETE`](#delete-servicev1usersuser_id)
54 |
55 | ### GET /service/v1/users/{user_id}
56 |
57 | Return a user in JSON format. For example:
58 | ```sh
59 | curl http://localhost:8000/service/v1/users/4
60 | ```
61 | Will return this:
62 | ```json
63 | {
64 | "name": "John",
65 | "age": 0,
66 | "introduction": ""
67 | }
68 | ```
69 |
70 | ### POST /service/v1/users/{user_id}
71 |
72 | Modify an existing user. You do not have to send all fields, for example, to
73 | change only the age of the user 4:
74 |
75 | ```sh
76 | curl http://localhost:8000/service/v1/users/4 --data '{"age": 11}'
77 | ```
78 |
79 | ### DELETE /service/v1/users/{user_id}
80 |
81 | Delete an existing user:
82 |
83 | ```sh
84 | curl -X DELETE http://localhost:8000/service/v1/users/4
85 | ```
86 |
87 | # Interceptors
88 |
89 | ## Interceptor Log
90 | Log all HTTP requests to stdout in this form:
91 |
92 | ```
93 | 2016/02/20 11:09:17 GET /favicon.ico 404 59B
94 | 2016/02/20 11:09:34 GET /service/v1/ 405 68B
95 | 2016/02/20 11:09:46 GET /service/v1/doc 405 68B
96 | ```
97 |
98 | ## Interceptor Error
99 | Print JSON error in this form:
100 |
101 | ```json
102 | {
103 | "status_code": 404,
104 | "error_code": 21,
105 | "description_code": "User '231223' not found."
106 | }
107 | ```
108 |
109 | ## Interceptor User
110 | Extract and validate user from url. If the user does not exist, a 404 will be
111 | returned.
--------------------------------------------------------------------------------
/example/example:
--------------------------------------------------------------------------------
1 | ../../example
--------------------------------------------------------------------------------
/example/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "strconv"
6 |
7 | "github.com/fulldump/golax"
8 | )
9 |
10 | func main() {
11 |
12 | myApi := golax.NewApi()
13 | myApi.Prefix = "/service/v1"
14 |
15 | myApi.Root.
16 | Doc(golax.Doc{
17 | Description: `
18 | _Example_ is a demonstration REST API that implements a CRUD over a collection
19 | of users, stored in memory.
20 |
21 | All API calls:
22 | * are returning errors with the same JSON format and
23 | * are logging all request to standard output.
24 | `,
25 | }).
26 | Interceptor(golax.InterceptorLog).
27 | Interceptor(golax.InterceptorError)
28 |
29 | users := myApi.Root.Node("users").
30 | Doc(golax.Doc{
31 | Description: `
32 | Resource users to list and create elements. It does not support pagination,
33 | sorting or filtering.
34 | `}).
35 | Method("GET", getUsers, golax.Doc{
36 | Description: `
37 | Return a list with a list of user ids:
38 |
39 | ´´´json
40 | [1,2,3]
41 | ´´´
42 | `}).
43 | Method("POST", postUsers, golax.Doc{
44 | Description: `
45 | Create a user:
46 | ´´´sh
47 | curl http://localhost:8000/service/v1/users --data '{"name": "John"}'
48 | ´´´
49 | And return the user id:
50 | ´´´json
51 | {"id":4}
52 | ´´´
53 | `})
54 |
55 | users.Node("{user_id}").
56 | Doc(golax.Doc{
57 | Description: `
58 | Resource user to retrieve, modify and delete. A user has this structure:
59 |
60 | ´´´json
61 | {
62 | "name": "Menganito Menganez",
63 | "age": 30,
64 | "introduction": "Hi, I like wheels and cars"
65 | }
66 | ´´´
67 | `}).
68 | Interceptor(interceptorUser).
69 | Method("GET", getUser, golax.Doc{
70 | Description: `
71 | Return a user in JSON format. For example:
72 | ´´´sh
73 | curl http://localhost:8000/service/v1/users/4
74 | ´´´
75 | Will return this:
76 | ´´´json
77 | {
78 | "name": "John",
79 | "age": 0,
80 | "introduction": ""
81 | }
82 | ´´´
83 | `}).
84 | Method("POST", postUser, golax.Doc{
85 | Description: `
86 | Modify an existing user. You do not have to send all fields, for example, to
87 | change only the age of the user 4:
88 |
89 | ´´´sh
90 | curl http://localhost:8000/service/v1/users/4 --data '{"age": 11}'
91 | ´´´
92 | `}).
93 | Method("DELETE", deleteUser, golax.Doc{
94 | Description: `
95 | Delete an existing user:
96 |
97 | ´´´sh
98 | curl -X DELETE http://localhost:8000/service/v1/users/4
99 | ´´´
100 | `})
101 |
102 | //d := apidoc.Build(myApi, myApi.Root)
103 | //d.Title = "Example"
104 | //d.Subtitle = "Autogenerated doc"
105 |
106 | myApi.Serve()
107 | }
108 |
109 | func getUsers(c *golax.Context) {
110 | ids := []int{}
111 | for id := range users {
112 | ids = append(ids, id)
113 | }
114 |
115 | json.NewEncoder(c.Response).Encode(ids)
116 | }
117 |
118 | func postUsers(c *golax.Context) {
119 | u := &user{}
120 |
121 | json.NewDecoder(c.Request.Body).Decode(u)
122 |
123 | insertUser(u)
124 |
125 | c.Response.WriteHeader(201)
126 | json.NewEncoder(c.Response).Encode(map[string]interface{}{"id": u.id})
127 | }
128 |
129 | func getUser(c *golax.Context) {
130 | u := getContextUser(c)
131 |
132 | json.NewEncoder(c.Response).Encode(u)
133 | }
134 |
135 | func postUser(c *golax.Context) {
136 | u := getContextUser(c)
137 |
138 | json.NewDecoder(c.Request.Body).Decode(u)
139 | }
140 |
141 | func deleteUser(c *golax.Context) {
142 | u := getContextUser(c)
143 | delete(users, u.id)
144 | }
145 |
146 | /**
147 | * Interceptor {user_id}
148 | * if: `user_id` exists -> load the object and put it available in the context
149 | * else: raise 404
150 | */
151 | var interceptorUser = &golax.Interceptor{
152 | Documentation: golax.Doc{
153 | Name: "User",
154 | Description: `
155 | Extract and validate user from url. If the user does not exist, a 404 will be
156 | returned.
157 | `,
158 | },
159 | Before: func(c *golax.Context) {
160 | userID, _ := strconv.Atoi(c.Parameter)
161 | if user, exists := users[userID]; exists {
162 | c.Set("user", user)
163 | } else {
164 | c.Error(404, "user `"+c.Parameter+"` does not exist")
165 | }
166 | },
167 | }
168 |
169 | /**
170 | * Helper to get a user object from the context
171 | */
172 | func getContextUser(c *golax.Context) *user {
173 | v, _ := c.Get("user")
174 | return v.(*user)
175 | }
176 |
--------------------------------------------------------------------------------
/example/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | )
7 |
8 | func Test_001(t *testing.T) {
9 | fmt.Println("hello")
10 | }
11 |
--------------------------------------------------------------------------------
/example/model.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | type user struct {
4 | id int
5 | Name string `json:"name"`
6 | Age int `json:"age"`
7 | Introduction string `json:"introduction"`
8 | }
9 |
10 | var users = map[int]*user{}
11 | var usersLastId = 0
12 |
13 | func insertUser(u *user) {
14 | usersLastId++ // NOTE: This should be thread safe in a nice server
15 |
16 | u.id = usersLastId
17 | users[u.id] = u
18 | }
19 |
20 | /**
21 | * Insert 3 sample users
22 | */
23 | func init() {
24 | insertUser(&user{
25 | Name: "Fulanito Fulanitez",
26 | Age: 20,
27 | Introduction: "Hello, I like flowers and plants",
28 | })
29 |
30 | insertUser(&user{
31 | Name: "Menganito Menganez",
32 | Age: 30,
33 | Introduction: "Hi, I like wheels and cars",
34 | })
35 |
36 | insertUser(&user{
37 | Name: "Zutanito Zutanez",
38 | Age: 40,
39 | Introduction: "Hey, I love cats and dogs",
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/extended_writer.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import "net/http"
4 |
5 | // ExtendedWriter wraps http.ResponseWriter with StatusCode & Length
6 | type ExtendedWriter struct {
7 | StatusCode int
8 | statusCodeSent bool
9 | Length int
10 | http.ResponseWriter
11 | }
12 |
13 | // NewExtendedWriter instances a new *ExtendedWriter
14 | func NewExtendedWriter(w http.ResponseWriter) *ExtendedWriter {
15 | return &ExtendedWriter{
16 | StatusCode: 200,
17 | statusCodeSent: false,
18 | Length: 0,
19 | ResponseWriter: w,
20 | }
21 | }
22 |
23 | // Write replaces default behaviour of http.ResponseWriter
24 | func (w *ExtendedWriter) Write(p []byte) (int, error) {
25 | n, err := w.ResponseWriter.Write(p)
26 | w.Length += n
27 | return n, err
28 | }
29 |
30 | // WriteHeader replaces default behaviour of http.ResponseWriter
31 | func (w *ExtendedWriter) WriteHeader(statusCode int) {
32 | w.StatusCode = statusCode
33 | if !w.statusCodeSent {
34 | w.ResponseWriter.WriteHeader(statusCode)
35 | w.statusCodeSent = true
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/extended_writer_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "reflect"
7 | "strings"
8 | "testing"
9 | )
10 |
11 | func Test_ExtendedWriter_WriteTwice(t *testing.T) {
12 | world := NewWorld()
13 | defer world.Destroy()
14 |
15 | world.Api.Handler500 = func(c *Context) {
16 | json.NewEncoder(c.Response).Encode(map[string]interface{}{
17 | "status": c.LastError.StatusCode,
18 | "description": c.LastError.Description,
19 | })
20 | }
21 |
22 | world.Api.Root.
23 | Interceptor(InterceptorError).
24 | Node("example").
25 | Method("GET", func(c *Context) {
26 | c.Response.WriteHeader(400)
27 | c.Response.WriteHeader(401)
28 |
29 | c.Response.Write([]byte("Hello\n"))
30 | panic("This is a panic!")
31 | })
32 |
33 | fmt.Println("=============================================")
34 |
35 | res := world.Request("GET", "/example").Do()
36 |
37 | if 400 != res.StatusCode {
38 | t.Error("Status code should be 400")
39 | }
40 |
41 | body := res.BodyString()
42 |
43 | bodyLines := strings.Split(body, "\n")
44 |
45 | if "Hello" != bodyLines[0] {
46 | t.Error("First line should be `Hello` instead of " + bodyLines[0])
47 | }
48 |
49 | bodyJson := map[string]interface{}{}
50 |
51 | json.Unmarshal([]byte(bodyLines[1]), &bodyJson)
52 |
53 | if !reflect.DeepEqual(float64(500), bodyJson["status"]) {
54 | t.Error("Body json status should be status:500")
55 | }
56 |
57 | if !strings.HasPrefix(bodyJson["description"].(string), "This is a panic!") {
58 | t.Error("Body json description should start by `This is a panic!` ")
59 | }
60 |
61 | }
62 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/fulldump/golax
2 |
3 | go 1.17
4 |
--------------------------------------------------------------------------------
/handler.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | // Handler is a function that implements an HTTP method for a Node, Operation,
4 | // Interceptor.Before and Interceptor.After items.
5 | type Handler func(c *Context)
6 |
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fulldump/golax/6a387f0818ac2927e3161214178731f44ba5c301/icon.png
--------------------------------------------------------------------------------
/icon2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fulldump/golax/6a387f0818ac2927e3161214178731f44ba5c301/icon2.png
--------------------------------------------------------------------------------
/interceptor.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | // Interceptor are pieces of code attached to nodes that can interact with the
4 | // context and break the execution.
5 | // A interceptor has two pieces of code, `Before` is executed before the handler
6 | // and `After` is executed after the handler. The `After` code is executed
7 | // always if the `Before` code has been executed successfully (without calling
8 | // to `context.Error(...)`.
9 | type Interceptor struct {
10 | Before Handler
11 | After Handler
12 | Documentation Doc
13 | }
14 |
--------------------------------------------------------------------------------
/interceptordeep_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func Test_InterceptorDeep_OK(t *testing.T) {
8 | world := NewWorld()
9 | defer world.Destroy()
10 |
11 | Print := func(c *Context) {
12 | value, exist := c.Get("list")
13 | if exist {
14 | list := value.(string)
15 | c.Response.Write([]byte(list))
16 | }
17 | }
18 |
19 | failS := ""
20 | Append := func(s string) *Interceptor {
21 | return &Interceptor{
22 | Before: func(c *Context) {
23 |
24 | if s == failS {
25 | c.Error(999, "Fail cause: "+failS)
26 | Print(c)
27 | return
28 | }
29 |
30 | list := ""
31 | if value, exist := c.Get("list"); exist {
32 | list = value.(string)
33 | }
34 | c.Set("list", list+s)
35 | },
36 | }
37 | }
38 |
39 | root := world.Api.Root
40 |
41 | root.Interceptor(Append("[root"))
42 | root.InterceptorDeep(Append("]"))
43 |
44 | a := root.Node("a")
45 | a.Interceptor(Append(",a_i1"))
46 | a.Interceptor(Append(",a_i2"))
47 | a.InterceptorDeep(Append(",a_d1"))
48 | a.InterceptorDeep(Append(",a_d2"))
49 | {
50 | b := a.Node("b")
51 | b.Interceptor(Append(",b_i1"))
52 | b.Interceptor(Append(",b_i2"))
53 | b.InterceptorDeep(Append(",b_d1"))
54 | b.InterceptorDeep(Append(",b_d2"))
55 | b.Method("GET", Print)
56 | }
57 |
58 | grandma := root.Node("grandma")
59 | grandma.Interceptor(Append(",grandma"))
60 | {
61 | mary := grandma.Node("mary")
62 | mary.Interceptor(Append(",Mary"))
63 | mary.Method("GET", Print)
64 | }
65 |
66 | // Test1
67 | res1 := world.Request("GET", "/grandma/mary").Do()
68 | body1 := res1.BodyString()
69 | //fmt.Println(body1)
70 | if "[root,grandma,Mary]" != body1 {
71 | t.Error("DeepInterceptor should be executed at the end")
72 | }
73 |
74 | // Test2
75 | res2 := world.Request("GET", "/a/b").Do()
76 | body2 := res2.BodyString()
77 | //fmt.Println(body2)
78 | if "[root,a_i1,a_i2,b_i1,b_i2,b_d2,b_d1,a_d2,a_d1]" != body2 {
79 | t.Error("DeepInterceptor are executed in reverse order")
80 | }
81 |
82 | // Test3
83 | cases := map[string]string{
84 | "[root": "",
85 | ",a_i1": "[root",
86 | ",a_i2": "[root,a_i1",
87 | ",b_i1": "[root,a_i1,a_i2",
88 | ",b_i2": "[root,a_i1,a_i2,b_i1",
89 | ",b_d2": "[root,a_i1,a_i2,b_i1,b_i2",
90 | ",b_d1": "[root,a_i1,a_i2,b_i1,b_i2,b_d2",
91 | ",a_d2": "[root,a_i1,a_i2,b_i1,b_i2,b_d2,b_d1",
92 | ",a_d1": "[root,a_i1,a_i2,b_i1,b_i2,b_d2,b_d1,a_d2",
93 | "]": "[root,a_i1,a_i2,b_i1,b_i2,b_d2,b_d1,a_d2,a_d1",
94 | "": "[root,a_i1,a_i2,b_i1,b_i2,b_d2,b_d1,a_d2,a_d1]",
95 | }
96 |
97 | for s, expected := range cases {
98 | failS = s
99 | res := world.Request("GET", "/a/b").Do()
100 | body := res.BodyString()
101 | if expected != body {
102 | t.Error("s:", s, "Expected:", expected, "Obtained:", body)
103 | }
104 |
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/interceptors.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "encoding/json"
5 | "log"
6 | )
7 |
8 | // InterceptorError prints an error in JSON format if Context.LastError is
9 | // not nil.
10 | // Example:
11 | // {
12 | // "status_code": 404,
13 | // "error_code": 1000023,
14 | // "description_code": "User 'fulanez' not found.",
15 | // }
16 | var InterceptorError = &Interceptor{
17 | Documentation: Doc{
18 | Name: "Error",
19 | Description: `
20 | Print JSON error in this form:
21 |
22 | ´´´json
23 | {
24 | "status_code": 404,
25 | "error_code": 21,
26 | "description_code": "User '231223' not found."
27 | }
28 | ´´´
29 | `,
30 | },
31 | After: func(c *Context) {
32 | if nil != c.LastError {
33 | json.NewEncoder(c.Response).Encode(c.LastError)
34 | }
35 | },
36 | }
37 |
38 | // InterceptorLog prints an access log to standard output.
39 | // Example:
40 | // 2016/02/20 11:09:17 GET /favicon.ico 404 59B
41 | // 2016/02/20 11:09:34 GET /service/v1/ 405 68B
42 | // 2016/02/20 11:09:46 GET /service/v1/doc 405 68B
43 | var InterceptorLog = &Interceptor{
44 | Documentation: Doc{
45 | Name: "Log",
46 | Description: `
47 | Log all HTTP requests to stdout in this form:
48 |
49 | ´´´
50 | 2016/02/20 11:09:17 GET /favicon.ico 404 59B
51 | 2016/02/20 11:09:34 GET /service/v1/ 405 68B
52 | 2016/02/20 11:09:46 GET /service/v1/doc 405 68B
53 | ´´´
54 | `,
55 | },
56 | After: func(c *Context) {
57 | log.Printf(
58 | "%s\t%s\t%d\t%dB",
59 | c.Request.Method,
60 | c.Request.URL.RequestURI(),
61 | c.Response.StatusCode,
62 | c.Response.Length,
63 | )
64 | },
65 | }
66 |
67 | // InterceptorNoCache set some headers to force response to not be cached
68 | // by user agent.
69 | var InterceptorNoCache = &Interceptor{
70 | Documentation: Doc{
71 | Name: "InterceptorNoCache",
72 | Description: `
73 | Avoid caching via http headers
74 | `,
75 | },
76 | Before: func(c *Context) {
77 | add := c.Response.Header().Add
78 |
79 | add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
80 | add("Pragma", "no-cache")
81 | add("Expires", "0")
82 | },
83 | }
84 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fulldump/golax/6a387f0818ac2927e3161214178731f44ba5c301/logo.png
--------------------------------------------------------------------------------
/node.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import "strings"
4 |
5 | // Node represents a path part of an URL
6 | type Node struct {
7 | Interceptors []*Interceptor
8 | InterceptorsDeep []*Interceptor
9 | Methods map[string]Handler
10 | Children []*Node
11 | Documentation Doc
12 | DocumentationMethods map[string]Doc
13 | Operations map[string]*Operation
14 |
15 | _path string
16 | _hasOperations bool
17 | _isParameter bool
18 | _isRegex bool
19 | _isFullPath bool
20 | _parameterKey string
21 | }
22 |
23 | // NewNode instances and initializes a new node
24 | func NewNode() *Node {
25 | return &Node{
26 | _path: "",
27 | Interceptors: []*Interceptor{},
28 | InterceptorsDeep: []*Interceptor{},
29 | Methods: map[string]Handler{},
30 | Children: []*Node{},
31 | DocumentationMethods: map[string]Doc{},
32 | Operations: map[string]*Operation{},
33 | }
34 | }
35 |
36 | // Method implements an HTTP method for a handler and optionally allows
37 | // a third documentation parameter
38 | func (n *Node) Method(m string, h Handler, d ...Doc) *Node {
39 | M := strings.ToUpper(m)
40 | n.Methods[M] = h
41 | if len(d) > 0 {
42 | n.DocumentationMethods[M] = d[0]
43 | }
44 | return n
45 | }
46 |
47 | // Interceptor attaches an *Interceptor to a *Node
48 | func (n *Node) Interceptor(m *Interceptor) *Node {
49 | n.Interceptors = append(n.Interceptors, m)
50 | return n
51 | }
52 |
53 | // InterceptorDeep attaches an *Interceptor to a *Node but will be executed
54 | // after all regular interceptors.
55 | func (n *Node) InterceptorDeep(m *Interceptor) *Node {
56 | n.InterceptorsDeep = append(n.InterceptorsDeep, m)
57 | return n
58 | }
59 |
60 | // Doc attaches documentation to a *Node
61 | func (n *Node) Doc(d Doc) *Node {
62 | n.Documentation = d
63 |
64 | return n
65 | }
66 |
67 | // Node appends a child node
68 | func (n *Node) Node(p string) *Node {
69 | newNode := NewNode()
70 | newNode.SetPath(p)
71 |
72 | n.Children = append(n.Children, newNode)
73 |
74 | return newNode
75 | }
76 |
77 | // Operation appends an operation to a *Node
78 | func (n *Node) Operation(p string) *Operation {
79 | n._hasOperations = true
80 |
81 | newOperation := NewOperation()
82 | newOperation.Path = p
83 |
84 | n.Operations[p] = newOperation
85 |
86 | return newOperation
87 | }
88 |
89 | // SetPath modifies a node path
90 | func (n *Node) SetPath(p string) {
91 | n._path = p
92 |
93 | if "{{*}}" == p {
94 | n._isFullPath = true
95 | } else if isParameter(p) {
96 | n._isParameter = true
97 | n._parameterKey = biTrim("{", p, "}")
98 | } else if isRegex(p) {
99 | n._isRegex = true
100 | n._parameterKey = biTrim("(", p, ")")
101 | }
102 | }
103 |
104 | // GetPath retrieves a node path
105 | func (n *Node) GetPath() string {
106 | return n._path
107 | }
108 |
109 | func isParameter(path string) bool {
110 | return '{' == path[0] && '}' == path[len(path)-1]
111 | }
112 |
113 | func isRegex(path string) bool {
114 | return '(' == path[0] && ')' == path[len(path)-1]
115 | }
116 |
117 | func biTrim(l, s, r string) string {
118 | return strings.TrimLeft(strings.TrimRight(s, r), l)
119 | }
120 |
--------------------------------------------------------------------------------
/operation.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import "strings"
4 |
5 | // Operation is a terminal node, ready to execute code but exposed as Google
6 | // custom methods (with :operation syntax)
7 | type Operation struct {
8 | Path string // Operation name
9 | Interceptors []*Interceptor
10 | Methods map[string]Handler
11 | }
12 |
13 | // NewOperation instances and initialize an Operation
14 | func NewOperation() *Operation {
15 | return &Operation{
16 | Path: "",
17 | Interceptors: []*Interceptor{},
18 | Methods: map[string]Handler{},
19 | }
20 | }
21 |
22 | // Method implement an HTTP method for an operation
23 | func (o *Operation) Method(m string, h Handler) *Operation {
24 | M := strings.ToUpper(m)
25 | o.Methods[M] = h
26 | return o
27 | }
28 |
29 | // Interceptor attaches an Interceptor to an operation
30 | func (o *Operation) Interceptor(m *Interceptor) *Operation {
31 | o.Interceptors = append(o.Interceptors, m)
32 | return o
33 | }
34 |
--------------------------------------------------------------------------------
/operation_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | )
7 |
8 | func Test_Operation_Combo1(t *testing.T) {
9 | world := NewWorld()
10 | defer world.Destroy()
11 |
12 | world.Api.Root.
13 | Node("users").
14 | Node("{user_id}").
15 | Interceptor(&Interceptor{
16 | Before: func(c *Context) {
17 | fmt.Fprintln(c.Response, "Interceptor users/{user_id}", c.Parameters)
18 | },
19 | }).
20 | Method("GET", func(c *Context) {
21 | fmt.Fprintln(c.Response, "Method users/{user_id} ", c.Parameters["user_id"])
22 | }).
23 | Operation("list").
24 | Interceptor(&Interceptor{
25 | Before: func(c *Context) {
26 | fmt.Fprintln(c.Response, "Interceptor users/{user_id}:list", c.Parameters)
27 | },
28 | }).
29 | Method("GET", func(c *Context) {
30 | fmt.Fprintln(c.Response, "Operation 'list' operation over ", c.Parameters["user_id"])
31 | })
32 |
33 | // Case 1
34 | r := world.Request("GET", "/users/2").Do()
35 |
36 | expected := `Interceptor users/{user_id} map[user_id:2]
37 | Method users/{user_id} 2
38 | `
39 |
40 | if r.BodyString() != expected {
41 | t.Error("Expected interceptor + method users")
42 | }
43 |
44 | // Case 2
45 | r = world.Request("GET", "/users/2:list").Do()
46 |
47 | expected = `Interceptor users/{user_id} map[user_id:2]
48 | Interceptor users/{user_id}:list map[user_id:2]
49 | Operation 'list' operation over 2
50 | `
51 |
52 | if r.BodyString() != expected {
53 | t.Error("Expected interceptor (node) + interceptor (operation) + operation")
54 | }
55 |
56 | }
57 |
58 | func Test_Operation_Combo2(t *testing.T) {
59 | world := NewWorld()
60 | defer world.Destroy()
61 |
62 | world.Api.Root.
63 | Node("users:good").
64 | Method("GET", func(c *Context) {
65 | fmt.Fprint(c.Response, "I am /users:good")
66 | }).
67 | Operation("list").
68 | Method("GET", func(c *Context) {
69 | fmt.Fprint(c.Response, "I am /users:good:list")
70 | })
71 |
72 | // Case 1
73 | r := world.Request("GET", "/users:good").Do()
74 |
75 | expected := `I am /users:good`
76 | body := r.BodyString()
77 |
78 | if body != expected {
79 | t.Error("Expected interceptor + method users")
80 | }
81 |
82 | // Case 2
83 | r = world.Request("GET", "/users:good:list").Do()
84 |
85 | expected = `I am /users:good:list`
86 | body = r.BodyString()
87 |
88 | if body != expected {
89 | t.Error("Expected interceptor (node) + interceptor (operation) + operation")
90 | }
91 |
92 | }
93 |
94 | func Test_Operation_Combo3(t *testing.T) {
95 | world := NewWorld()
96 | defer world.Destroy()
97 |
98 | world.Api.Root.
99 | Interceptor(InterceptorError).
100 | Node("users").
101 | Interceptor(&Interceptor{
102 | Before: func(c *Context) {
103 | c.Error(999, "Unexpected invented error")
104 | },
105 | }).
106 | Node("{user_id}").
107 | Operation("list").
108 | Method("GET", func(c *Context) {
109 | fmt.Fprint(c.Response, "I am /users:good:list")
110 | })
111 |
112 | // Case 1
113 | r := world.Request("GET", "/users/23:list").Do()
114 |
115 | if 999 != r.StatusCode {
116 | t.Error("Expected status code 999")
117 | }
118 | }
119 |
120 | func Test_Operation_PathHandlers(t *testing.T) {
121 | world := NewWorld()
122 | defer world.Destroy()
123 |
124 | PrintHandlers := func(c *Context) {
125 | fmt.Fprint(c.Response, c.PathHandlers)
126 | }
127 |
128 | world.Api.Root.
129 | Node("a").
130 | Node("{b}").
131 | Node("c:isNode").
132 | Method("GET", PrintHandlers).
133 | Node("{d}").
134 | Method("GET", PrintHandlers).
135 | Operation("myOperation").
136 | Method("GET", PrintHandlers)
137 |
138 | { // Case 1
139 | r := world.Request("GET", "/a/b:3/c:isNode/d:myOperation").Do()
140 | body := r.BodyString()
141 | expected := "/a/{b}/c:isNode/{d}:myOperation"
142 | if body != expected {
143 | t.Error("Expected:", expected, "Obtained:", body)
144 | }
145 | }
146 |
147 | { // Case 2
148 | r := world.Request("GET", "/a/b:3/c:isNode").Do()
149 | body := r.BodyString()
150 | expected := "/a/{b}/c:isNode"
151 | if body != expected {
152 | t.Error("Expected:", expected, "Obtained:", body)
153 | }
154 | }
155 |
156 | { // Case 3
157 | r := world.Request("GET", "/a/b/c:isNode/d:notOperation").Do()
158 | body := r.BodyString()
159 | expected := "/a/{b}/c:isNode/{d}"
160 | if body != expected {
161 | t.Error("Expected:", expected, "Obtained:", body)
162 | }
163 | }
164 |
165 | }
166 |
--------------------------------------------------------------------------------
/request_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 | )
7 |
8 | type RequestTest struct {
9 | http.Request
10 | }
11 |
12 | func NewRequestTest(method, path string) *RequestTest {
13 | request, err := http.NewRequest(method, path, strings.NewReader(""))
14 | if nil != err {
15 | panic(err)
16 | }
17 |
18 | return &RequestTest{*request}
19 | }
20 |
21 | func (rt *RequestTest) Do() *ResponseTest {
22 | response, err := http.DefaultClient.Do(&rt.Request)
23 | if err != nil {
24 | panic(err)
25 | }
26 |
27 | return &ResponseTest{*response}
28 | }
29 |
--------------------------------------------------------------------------------
/response_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "net/http"
7 | )
8 |
9 | type ResponseTest struct {
10 | http.Response
11 | }
12 |
13 | func (rt *ResponseTest) BodyBytes() []byte {
14 | body, err := ioutil.ReadAll(rt.Body)
15 | if err != nil {
16 | panic(err)
17 | }
18 | return body
19 | }
20 |
21 | func (rt *ResponseTest) BodyString() string {
22 | return string(rt.BodyBytes())
23 | }
24 |
25 | func (rt *ResponseTest) BodyJson() interface{} {
26 | var body interface{}
27 | if err := json.Unmarshal(rt.BodyBytes(), &body); err != nil {
28 | panic(err)
29 | }
30 | return body
31 | }
32 |
33 | func (rt *ResponseTest) BodyJsonMap() *map[string]interface{} {
34 | body := map[string]interface{}{}
35 | if err := json.Unmarshal(rt.BodyBytes(), &body); err != nil {
36 | panic(err)
37 | }
38 | return &body
39 | }
40 |
--------------------------------------------------------------------------------
/splittail.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import "strings"
4 |
5 | // SplitTail split by separator and return pending tail and last part
6 | func SplitTail(s, sep string) []string {
7 | parts := strings.Split(s, sep)
8 | l := len(parts)
9 | if 1 == l {
10 | return []string{s}
11 | }
12 | return []string{strings.Join(parts[:l-1], sep), parts[l-1]}
13 | }
14 |
--------------------------------------------------------------------------------
/splittail_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func Test_SplitTail_Zero(t *testing.T) {
9 |
10 | r := SplitTail("", ":")
11 |
12 | if !reflect.DeepEqual(r, []string{""}) {
13 | t.Error("Result should be len 1 with an empty string")
14 | }
15 |
16 | }
17 |
18 | func Test_SplitTail_One(t *testing.T) {
19 |
20 | r := SplitTail("abc", ":")
21 |
22 | if !reflect.DeepEqual(r, []string{"abc"}) {
23 | t.Error("Result should be len 1 with an 'abc' string")
24 | }
25 |
26 | }
27 |
28 | func Test_SplitTail_MoreThanOne(t *testing.T) {
29 |
30 | r := SplitTail("a:b:c:d", ":")
31 |
32 | if !reflect.DeepEqual(r, []string{"a:b:c", "d"}) {
33 | t.Error("Result should be len 2 with: 'a:b:c' and 'd' strings")
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/world_test.go:
--------------------------------------------------------------------------------
1 | package golax
2 |
3 | import "net/http/httptest"
4 |
5 | type World struct {
6 | Api *Api
7 | Server *httptest.Server
8 | }
9 |
10 | func NewWorld() *World {
11 | api := NewApi()
12 |
13 | return &World{
14 | Api: api,
15 | Server: httptest.NewServer(api),
16 | }
17 | }
18 |
19 | func (w *World) Destroy() {
20 | w.Server.Close()
21 | }
22 |
23 | func (w *World) Request(method, path string) *RequestTest {
24 | return NewRequestTest(method, w.Server.URL+path)
25 | }
26 |
--------------------------------------------------------------------------------