├── .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 | GoDoc 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 | ![Normal flow](doc/figure_1_normal_flow.png) 90 | 91 | To abort the execution, call to `c.Error(404, "Resource not found")`: 92 | 93 | ![Break flow](doc/figure_2_break_flow.png) 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 | ![Normal flow](figure_1_normal_flow.png) 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 | --------------------------------------------------------------------------------