├── LICENSE ├── README.md ├── docs.go ├── errors.go ├── examples_test.go ├── httpclient.go ├── links.go ├── links_test.go ├── linkset.go ├── navigator.go ├── navigator_test.go └── query_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 James Gregory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # halgo 2 | 3 | [HAL](http://stateless.co/hal_specification.html) implementation in Go. 4 | 5 | > HAL is a simple format that gives a consistent and easy way to hyperlink between resources in your API. 6 | 7 | Halgo helps with generating HAL-compliant JSON from Go structs, and 8 | provides a Navigator for walking a HAL-compliant API. 9 | 10 | [![GoDoc](https://godoc.org/github.com/jagregory/halgo?status.png)](https://godoc.org/github.com/jagregory/halgo) 11 | 12 | ## Install 13 | 14 | go get github.com/jagregory/halgo 15 | 16 | ## Usage 17 | 18 | Serialising a resource with HAL links: 19 | 20 | ```go 21 | import "github.com/jagregory/halgo" 22 | 23 | type MyResource struct { 24 | halgo.Links 25 | Name string 26 | } 27 | 28 | res := MyResource{ 29 | Links: Links{}. 30 | Self("/orders"). 31 | Next("/orders?page=2"). 32 | Link("ea:find", "/orders{?id}"). 33 | Add("ea:admin", Link{Href: "/admins/2", Title: "Fred"}, Link{Href: "/admins/5", Title: "Kate"}), 34 | Name: "James", 35 | } 36 | 37 | bytes, _ := json.Marshal(res) 38 | 39 | fmt.Println(bytes) 40 | 41 | // { 42 | // "_links": { 43 | // "self": { "href": "/orders" }, 44 | // "next": { "href": "/orders?page=2" }, 45 | // "ea:find": { "href": "/orders{?id}", "templated": true }, 46 | // "ea:admin": [{ 47 | // "href": "/admins/2", 48 | // "title": "Fred" 49 | // }, { 50 | // "href": "/admins/5", 51 | // "title": "Kate" 52 | // }] 53 | // }, 54 | // "Name": "James" 55 | // } 56 | ``` 57 | 58 | Navigating a HAL-compliant API: 59 | 60 | ```go 61 | res, err := halgo.Navigator("http://example.com"). 62 | Follow("products"). 63 | Followf("page", halgo.P{"n": 10}). 64 | Get() 65 | ``` 66 | 67 | Deserialising a resource: 68 | 69 | ```go 70 | import "github.com/jagregory/halgo" 71 | 72 | type MyResource struct { 73 | halgo.Links 74 | Name string 75 | } 76 | 77 | data := []byte(`{ 78 | "_links": { 79 | "self": { "href": "/orders" }, 80 | "next": { "href": "/orders?page=2" }, 81 | "ea:find": { "href": "/orders{?id}", "templated": true }, 82 | "ea:admin": [{ 83 | "href": "/admins/2", 84 | "title": "Fred" 85 | }, { 86 | "href": "/admins/5", 87 | "title": "Kate" 88 | }] 89 | }, 90 | "Name": "James" 91 | }`) 92 | 93 | res := MyResource{} 94 | json.Unmarshal(data, &res) 95 | 96 | res.Name // "James" 97 | res.Links.Href("self") // "/orders" 98 | res.Links.HrefParams("self", Params{"id": 123}) // "/orders?id=123" 99 | ``` 100 | 101 | ## TODO 102 | 103 | * Curies 104 | * Embedded resources 105 | -------------------------------------------------------------------------------- /docs.go: -------------------------------------------------------------------------------- 1 | // Package halgo is used to create application/hal+json representations of 2 | // Go structs, and provides a client for navigating HAL-compliant APIs. 3 | // 4 | // There are two sides to halgo: serialisation and navigation. 5 | // 6 | // Serialisation is based around the Links struct, which you can embed in 7 | // your own structures to provide HAL compliant links when you serialise 8 | // your structs into JSON. Links has a little builder API which can make 9 | // it somewhat more succinct to generate these links than modelling the 10 | // structures yourself. 11 | // 12 | // Navigation, specifically through the Navigator func, is for when you 13 | // want to consume a HAL-compliant API and walk its relations. 14 | package halgo 15 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package halgo 2 | 3 | import "fmt" 4 | 5 | // LinkNotFoundError is returned when a link with the specified relation 6 | // couldn't be found in the links collection. 7 | type LinkNotFoundError struct { 8 | rel string 9 | items map[string]linkSet 10 | } 11 | 12 | func (err LinkNotFoundError) Error() string { 13 | opts := []string{} 14 | 15 | for k, _ := range err.items { 16 | opts = append(opts, fmt.Sprintf("'%s'", k)) 17 | } 18 | 19 | return fmt.Sprintf("Response didn't contain '%s' link relation: available options were %v", 20 | err.rel, opts) 21 | } 22 | 23 | // InvalidUrlError is returned when a link contains a malformed or invalid 24 | // url. 25 | type InvalidUrlError struct { 26 | url string 27 | } 28 | 29 | func (err InvalidUrlError) Error() string { 30 | return fmt.Sprintf("Invalid URL: %s", err.url) 31 | } 32 | -------------------------------------------------------------------------------- /examples_test.go: -------------------------------------------------------------------------------- 1 | package halgo_test 2 | 3 | import ( 4 | "." 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | func ExampleLinks() { 11 | type person struct { 12 | halgo.Links 13 | Id int 14 | Name string 15 | } 16 | 17 | p := person{ 18 | Id: 1, 19 | Name: "James", 20 | 21 | Links: halgo.Links{}. 22 | Self("http://example.com/users/1"). 23 | Link("invoices", "http://example.com/users/1/invoices"), 24 | } 25 | 26 | b, _ := json.MarshalIndent(p, "", " ") 27 | 28 | fmt.Println(string(b)) 29 | // Output: 30 | // { 31 | // "_links": { 32 | // "invoices": { 33 | // "href": "http://example.com/users/1/invoices" 34 | // }, 35 | // "self": { 36 | // "href": "http://example.com/users/1" 37 | // } 38 | // }, 39 | // "Id": 1, 40 | // "Name": "James" 41 | // } 42 | } 43 | 44 | func ExampleLinks_templated() { 45 | type root struct{ halgo.Links } 46 | 47 | p := root{ 48 | Links: halgo.Links{}. 49 | Link("invoices", "http://example.com/invoices{?q,sort}"), 50 | } 51 | 52 | b, _ := json.MarshalIndent(p, "", " ") 53 | 54 | fmt.Println(string(b)) 55 | // Output: 56 | // { 57 | // "_links": { 58 | // "invoices": { 59 | // "href": "http://example.com/invoices{?q,sort}", 60 | // "templated": true 61 | // } 62 | // } 63 | // } 64 | } 65 | 66 | func ExampleLinks_multiple() { 67 | type person struct { 68 | halgo.Links 69 | Id int 70 | Name string 71 | } 72 | 73 | p := person{ 74 | Id: 1, 75 | Name: "James", 76 | 77 | Links: halgo.Links{}. 78 | Add("aliases", halgo.Link{Href: "http://example.com/users/4"}, halgo.Link{Href: "http://example.com/users/19"}), 79 | } 80 | 81 | b, _ := json.MarshalIndent(p, "", " ") 82 | 83 | fmt.Println(string(b)) 84 | // Output: 85 | // { 86 | // "_links": { 87 | // "aliases": [ 88 | // { 89 | // "href": "http://example.com/users/4" 90 | // }, 91 | // { 92 | // "href": "http://example.com/users/19" 93 | // } 94 | // ] 95 | // }, 96 | // "Id": 1, 97 | // "Name": "James" 98 | // } 99 | } 100 | 101 | func ExampleNavigator() { 102 | var me struct{ Username string } 103 | 104 | halgo.Navigator("http://haltalk.herokuapp.com/"). 105 | Followf("ht:me", halgo.P{"name": "jagregory"}). 106 | Unmarshal(&me) 107 | 108 | fmt.Println(me.Username) 109 | // Output: jagregory 110 | } 111 | 112 | func ExampleNavigator_logging() { 113 | var me struct{ Username string } 114 | 115 | nav := halgo.Navigator("http://haltalk.herokuapp.com/") 116 | nav.HttpClient = halgo.LoggingHttpClient{http.DefaultClient} 117 | 118 | nav.Followf("ht:me", halgo.P{"name": "jagregory"}). 119 | Unmarshal(&me) 120 | 121 | fmt.Printf("Username: %s", me.Username) 122 | // Output: 123 | // GET http://haltalk.herokuapp.com/ 124 | // GET http://haltalk.herokuapp.com/users/jagregory 125 | // Username: jagregory 126 | } 127 | -------------------------------------------------------------------------------- /httpclient.go: -------------------------------------------------------------------------------- 1 | package halgo 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // HttpClient exposes Do from net/http Client. 9 | type HttpClient interface { 10 | Do(req *http.Request) (*http.Response, error) 11 | } 12 | 13 | // LoggingHttpClient is an example HttpClient implementation which wraps 14 | // an existing HttpClient and prints the request URL to STDOUT whenever 15 | // one occurs. 16 | type LoggingHttpClient struct { 17 | HttpClient 18 | } 19 | 20 | func (c LoggingHttpClient) Do(req *http.Request) (*http.Response, error) { 21 | fmt.Printf("%s %s\n", req.Method, req.URL) 22 | return c.HttpClient.Do(req) 23 | } 24 | -------------------------------------------------------------------------------- /links.go: -------------------------------------------------------------------------------- 1 | package halgo 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/jtacoma/uritemplates" 7 | "regexp" 8 | ) 9 | 10 | // Links represents a collection of HAL links. You can embed this struct 11 | // in your own structs for sweet, sweet HAL serialisation goodness. 12 | // 13 | // type MyStruct struct { 14 | // halgo.Links 15 | // } 16 | // 17 | // my := MyStruct{ 18 | // Links: halgo.Links{}. 19 | // Self("http://example.com/"). 20 | // Next("http://example.com/1"), 21 | // } 22 | type Links struct { 23 | Items map[string]linkSet `json:"_links,omitempty"` 24 | // Curies CurieSet 25 | } 26 | 27 | // Self creates a link with the rel as "self". Optionally can act as a 28 | // format string with parameters. 29 | // 30 | // Self("http://example.com/a/1") 31 | // Self("http://example.com/a/%d", id) 32 | func (l Links) Self(href string, args ...interface{}) Links { 33 | return l.Link("self", href, args...) 34 | } 35 | 36 | // Next creates a link with the rel as "next". Optionally can act as a 37 | // format string with parameters. 38 | // 39 | // Next("http://example.com/a/1") 40 | // Next("http://example.com/a/%d", id) 41 | func (l Links) Next(href string, args ...interface{}) Links { 42 | return l.Link("next", href, args...) 43 | } 44 | 45 | // Prev creates a link with the rel as "prev". Optionally can act as a 46 | // format string with parameters. 47 | // 48 | // Prev("http://example.com/a/1") 49 | // Prev("http://example.com/a/%d", id) 50 | func (l Links) Prev(href string, args ...interface{}) Links { 51 | return l.Link("prev", href, args...) 52 | } 53 | 54 | // Link creates a link with a named rel. Optionally can act as a format 55 | // string with parameters. 56 | // 57 | // Link("abc", "http://example.com/a/1") 58 | // Link("abc", "http://example.com/a/%d", id) 59 | func (l Links) Link(rel, href string, args ...interface{}) Links { 60 | if len(args) != 0 { 61 | href = fmt.Sprintf(href, args...) 62 | } 63 | 64 | templated, _ := regexp.Match("{.*?}", []byte(href)) 65 | 66 | return l.Add(rel, Link{Href: href, Templated: templated}) 67 | } 68 | 69 | // Add creates multiple links with the same relation. 70 | // 71 | // Add("abc", halgo.Link{Href: "/a/1"}, halgo.Link{Href: "/a/2"}) 72 | func (l Links) Add(rel string, links ...Link) Links { 73 | if l.Items == nil { 74 | l.Items = make(map[string]linkSet) 75 | } 76 | 77 | set, exists := l.Items[rel] 78 | 79 | if exists { 80 | set = append(set, links...) 81 | } else { 82 | set = make([]Link, len(links)) 83 | copy(set, links) 84 | } 85 | 86 | l.Items[rel] = set 87 | 88 | return l 89 | } 90 | 91 | // P is a parameters map for expanding URL templates. 92 | // 93 | // halgo.P{"id": 1} 94 | type P map[string]interface{} 95 | 96 | // Href tries to find the href of a link with the supplied relation. 97 | // Returns LinkNotFoundError if a link doesn't exist. 98 | func (l Links) Href(rel string) (string, error) { 99 | return l.HrefParams(rel, nil) 100 | } 101 | 102 | // HrefParams tries to find the href of a link with the supplied relation, 103 | // then expands any URI template parameters. Returns LinkNotFoundError if 104 | // a link doesn't exist. 105 | func (l Links) HrefParams(rel string, params P) (string, error) { 106 | if rel == "" { 107 | return "", errors.New("Empty string not valid relation") 108 | } 109 | 110 | links := l.Items[rel] 111 | if len(links) > 0 { 112 | link := links[0] // TODO: handle multiple here 113 | return link.Expand(params) 114 | } 115 | 116 | return "", LinkNotFoundError{rel, l.Items} 117 | } 118 | 119 | // Link represents a HAL link 120 | type Link struct { 121 | // The "href" property is REQUIRED. 122 | // Its value is either a URI [RFC3986] or a URI Template [RFC6570]. 123 | // If the value is a URI Template then the Link Object SHOULD have a 124 | // "templated" attribute whose value is true. 125 | Href string `json:"href"` 126 | 127 | // The "templated" property is OPTIONAL. 128 | // Its value is boolean and SHOULD be true when the Link Object's "href" 129 | // property is a URI Template. 130 | // Its value SHOULD be considered false if it is undefined or any other 131 | // value than true. 132 | Templated bool `json:"templated,omitempty"` 133 | 134 | // The "type" property is OPTIONAL. 135 | // Its value is a string used as a hint to indicate the media type 136 | // expected when dereferencing the target resource. 137 | Type string `json:"type,omitempty"` 138 | 139 | // The "deprecation" property is OPTIONAL. 140 | // Its presence indicates that the link is to be deprecated (i.e. 141 | // removed) at a future date. Its value is a URL that SHOULD provide 142 | // further information about the deprecation. 143 | // A client SHOULD provide some notification (for example, by logging a 144 | // warning message) whenever it traverses over a link that has this 145 | // property. The notification SHOULD include the deprecation property's 146 | // value so that a client manitainer can easily find information about 147 | // the deprecation. 148 | Deprecation string `json:"deprecation,omitempty"` 149 | 150 | // The "name" property is OPTIONAL. 151 | // Its value MAY be used as a secondary key for selecting Link Objects 152 | // which share the same relation type. 153 | Name string `json:"name,omitempty"` 154 | 155 | // The "profile" property is OPTIONAL. 156 | // Its value is a string which is a URI that hints about the profile (as 157 | // defined by [I-D.wilde-profile-link]) of the target resource. 158 | Profile string `json:"profile,omitempty"` 159 | 160 | // The "title" property is OPTIONAL. 161 | // Its value is a string and is intended for labelling the link with a 162 | // human-readable identifier (as defined by [RFC5988]). 163 | Title string `json:"title,omitempty"` 164 | 165 | // The "hreflang" property is OPTIONAL. 166 | // Its value is a string and is intended for indicating the language of 167 | // the target resource (as defined by [RFC5988]). 168 | HrefLang string `json:"hreflang,omitempty"` 169 | } 170 | 171 | // Expand will expand the URL template of the link with the given params. 172 | func (l Link) Expand(params P) (string, error) { 173 | template, err := uritemplates.Parse(l.Href) 174 | if err != nil { 175 | return "", err 176 | } 177 | 178 | return template.Expand(map[string]interface{}(params)) 179 | } 180 | -------------------------------------------------------------------------------- /links_test.go: -------------------------------------------------------------------------------- 1 | package halgo 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | type MyResource struct { 9 | Links 10 | Name string 11 | } 12 | 13 | var exampleJson string = `{"_links":{"ea:admin":[{"href":"/admins/2","title":"Fred"},{"href":"/admins/5","title":"Kate"}],"ea:find":{"href":"/orders{?id}","templated":true},"next":{"href":"/orders?page=2"},"self":{"href":"/orders"}},"Name":"James"}` 14 | 15 | func TestMarshalLinksToJSON(t *testing.T) { 16 | res := MyResource{ 17 | Name: "James", 18 | Links: Links{}. 19 | Self("/orders"). 20 | Next("/orders?page=2"). 21 | Link("ea:find", "/orders{?id}"). 22 | Add("ea:admin", Link{Href: "/admins/2", Title: "Fred"}, Link{Href: "/admins/5", Title: "Kate"}), 23 | } 24 | 25 | b, err := json.Marshal(res) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | if string(b) != exampleJson { 31 | t.Errorf("Unexpected JSON %s", b) 32 | } 33 | } 34 | 35 | func TestEmptyMarshalLinksToJSON(t *testing.T) { 36 | res := MyResource{ 37 | Name: "James", 38 | Links: Links{}, 39 | } 40 | 41 | b, err := json.Marshal(res) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | if string(b) != `{"Name":"James"}` { 47 | t.Errorf("Unexpected JSON %s", b) 48 | } 49 | } 50 | 51 | func TestUnmarshalLinksToJSON(t *testing.T) { 52 | res := MyResource{} 53 | err := json.Unmarshal([]byte(exampleJson), &res) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | if res.Name != "James" { 59 | t.Error("Expected name to be unmarshaled") 60 | } 61 | 62 | href, err := res.Href("self") 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | if expected := "/orders"; href != expected { 67 | t.Errorf("Expected self to be %s, got %s", expected, href) 68 | } 69 | 70 | href, err = res.Href("next") 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | if expected := "/orders?page=2"; href != expected { 75 | t.Errorf("Expected next to be %s, got %s", expected, href) 76 | } 77 | 78 | href, err = res.HrefParams("ea:find", P{"id": 123}) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | if expected := "/orders?id=123"; href != expected { 83 | t.Errorf("Expected ea:find to be %s, got %s", expected, href) 84 | } 85 | 86 | // TODO: handle multiple here 87 | href, err = res.Href("ea:admin") 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | if expected := "/admins/2"; href != expected { 92 | t.Errorf("Expected ea:admin to be %s, got %s", expected, href) 93 | } 94 | } 95 | 96 | func TestLinkFormatting(t *testing.T) { 97 | l := Links{}. 98 | Link("no-format", "/a/url/%s"). 99 | Link("format", "/a/url/%d", 10) 100 | 101 | if v, _ := l.Href("no-format"); v != "/a/url/%s" { 102 | t.Errorf("Expected no-format to match '/a/url/%%s', got %s", v) 103 | } 104 | 105 | if v, _ := l.Href("format"); v != "/a/url/10" { 106 | t.Errorf("Expected no-format to match '/a/url/10', got %s", v) 107 | } 108 | } 109 | 110 | func TestAutoSettingOfTemplated(t *testing.T) { 111 | l := Links{}. 112 | Link("not-templated", "/a/b/c"). 113 | Link("templated", "/a/b/{c}") 114 | 115 | if l.Items["not-templated"][0].Templated != false { 116 | t.Error("not-templated should have Templated=false") 117 | } 118 | 119 | if l.Items["templated"][0].Templated != true { 120 | t.Error("not-templated should have Templated=true") 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /linkset.go: -------------------------------------------------------------------------------- 1 | package halgo 2 | 3 | import "encoding/json" 4 | 5 | // linkSet is represents a set of HAL links. Deserialisable from a single 6 | // JSON hash, or a collection of links. 7 | type linkSet []Link 8 | 9 | func (l linkSet) MarshalJSON() ([]byte, error) { 10 | if len(l) == 1 { 11 | return json.Marshal(l[0]) 12 | } 13 | 14 | other := make([]Link, len(l)) 15 | copy(other, l) 16 | 17 | return json.Marshal(other) 18 | } 19 | 20 | func (l *linkSet) UnmarshalJSON(d []byte) error { 21 | single := Link{} 22 | err := json.Unmarshal(d, &single) 23 | if err == nil { 24 | *l = []Link{single} 25 | return nil 26 | } 27 | 28 | if _, ok := err.(*json.UnmarshalTypeError); !ok { 29 | return err 30 | } 31 | 32 | multiple := []Link{} 33 | err = json.Unmarshal(d, &multiple) 34 | 35 | if err == nil { 36 | *l = multiple 37 | return nil 38 | } 39 | 40 | return err 41 | } 42 | -------------------------------------------------------------------------------- /navigator.go: -------------------------------------------------------------------------------- 1 | package halgo 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | ) 12 | 13 | // Navigator is a mechanism for navigating HAL-compliant REST APIs. You 14 | // start by creating a Navigator with a base URI, then Follow the links 15 | // exposed by the API until you reach the place where you want to perform 16 | // an action. 17 | // 18 | // For example, to request an API exposed at api.example.com and follow a 19 | // link named products and GET the resulting page you'd do this: 20 | // 21 | // res, err := Navigator("http://api.example.com"). 22 | // Follow("products"). 23 | // Get() 24 | // 25 | // To do the same thing but POST to the products page, you'd do this: 26 | // 27 | // res, err := Navigator("http://api.example.com"). 28 | // Follow("products"). 29 | // Post("application/json", someContent) 30 | // 31 | // Multiple links followed in sequence. 32 | // 33 | // res, err := Navigator("http://api.example.com"). 34 | // Follow("products"). 35 | // Follow("next") 36 | // Get() 37 | // 38 | // Links can also be expanded with Followf if they are URI templates. 39 | // 40 | // res, err := Navigator("http://api.example.com"). 41 | // Follow("products"). 42 | // Followf("page", halgo.P{"number": 10}) 43 | // Get() 44 | // 45 | // Navigation of relations is lazy. Requests will only be triggered when 46 | // you execute a method which returns a result. For example, this doesn't 47 | // perform any HTTP requests. 48 | // 49 | // Navigator("http://api.example.com"). 50 | // Follow("products") 51 | // 52 | // It's only when you add a call to Get, Post, PostForm, Patch, or 53 | // Unmarshal to the end will any requests be triggered. 54 | // 55 | // By default a Navigator will use http.DefaultClient as its mechanism for 56 | // making HTTP requests. If you want to supply your own HttpClient, you 57 | // can assign to nav.HttpClient after creation. 58 | // 59 | // nav := Navigator("http://api.example.com") 60 | // nav.HttpClient = MyHttpClient{} 61 | // 62 | // Any Client you supply must implement halgo.HttpClient, which 63 | // http.Client does implicitly. By creating decorators for the HttpClient, 64 | // logging and caching clients are trivial. See LoggingHttpClient for an 65 | // example. 66 | func Navigator(uri string) navigator { 67 | return navigator{ 68 | rootUri: uri, 69 | path: []relation{}, 70 | HttpClient: http.DefaultClient, 71 | } 72 | } 73 | 74 | // relation is an instruction of a relation to follow and any params to 75 | // expand with when executed. 76 | type relation struct { 77 | rel string 78 | params P 79 | } 80 | 81 | // navigator is the API navigator 82 | type navigator struct { 83 | // HttpClient is used to execute requests. By default it's 84 | // http.DefaultClient. By decorating a HttpClient instance you can 85 | // easily write loggers or caching mechanisms. 86 | HttpClient HttpClient 87 | 88 | // path is the follow queue. 89 | path []relation 90 | 91 | // rootUri is where the navigation will begin from. 92 | rootUri string 93 | } 94 | 95 | // Follow adds a relation to the follow queue of the navigator. 96 | func (n navigator) Follow(rel string) navigator { 97 | return n.Followf(rel, nil) 98 | } 99 | 100 | // Followf adds a relation to the follow queue of the navigator, with a 101 | // set of parameters to expand on execution. 102 | func (n navigator) Followf(rel string, params P) navigator { 103 | relations := append([]relation{}, n.path...) 104 | relations = append(relations, relation{rel: rel, params: params}) 105 | 106 | return navigator{ 107 | HttpClient: n.HttpClient, 108 | path: relations, 109 | rootUri: n.rootUri, 110 | } 111 | } 112 | 113 | // Location follows the Location header from a response. It makes the URI 114 | // absolute, if necessary. 115 | func (n navigator) Location(resp *http.Response) (navigator, error) { 116 | _, exists := resp.Header["Location"] 117 | if !exists { 118 | return n, fmt.Errorf("Response didn't contain a Location header") 119 | } 120 | loc := resp.Header.Get("Location") 121 | lurl, err := makeAbsoluteIfNecessary(loc, n.rootUri) 122 | if err != nil { 123 | return n, err 124 | } 125 | return navigator{ 126 | HttpClient: n.HttpClient, 127 | path: []relation{}, 128 | rootUri: lurl, 129 | }, nil 130 | } 131 | 132 | // url returns the URL of the tip of the follow queue. Will follow the 133 | // usual pattern of requests. 134 | func (n navigator) url() (string, error) { 135 | url := n.rootUri 136 | 137 | for _, link := range n.path { 138 | links, err := n.getLinks(url) 139 | if err != nil { 140 | return "", fmt.Errorf("Error getting links (%s, %v): %v", url, links, err) 141 | } 142 | 143 | if _, ok := links.Items[link.rel]; !ok { 144 | return "", LinkNotFoundError{link.rel, links.Items} 145 | } 146 | 147 | url, err = links.HrefParams(link.rel, link.params) 148 | if err != nil { 149 | return "", fmt.Errorf("Error getting url (%v, %v): %v", link.rel, link.params, err) 150 | } 151 | 152 | if url == "" { 153 | return "", InvalidUrlError{url} 154 | } 155 | 156 | url, err = makeAbsoluteIfNecessary(url, n.rootUri) 157 | if err != nil { 158 | return "", fmt.Errorf("Error making url absolute: %v", err) 159 | } 160 | } 161 | 162 | return url, nil 163 | } 164 | 165 | // makeAbsoluteIfNecessary takes the current url and the root url, and 166 | // will make the current URL absolute by using the root's Host, Scheme, 167 | // and credentials if current isn't already absolute. 168 | func makeAbsoluteIfNecessary(current, root string) (string, error) { 169 | currentUri, err := url.Parse(current) 170 | if err != nil { 171 | return "", err 172 | } 173 | 174 | if currentUri.IsAbs() { 175 | return current, nil 176 | } 177 | 178 | rootUri, err := url.Parse(root) 179 | if err != nil { 180 | return "", err 181 | } 182 | 183 | currentUri.Scheme = rootUri.Scheme 184 | currentUri.Host = rootUri.Host 185 | currentUri.User = rootUri.User 186 | 187 | return currentUri.String(), nil 188 | } 189 | 190 | // Get performs a GET request on the tip of the follow queue. 191 | // 192 | // When a navigator is evaluated it will first request the root, then 193 | // request each relation on the queue until it reaches the tip. Once the 194 | // tip is reached it will defer to the calling method. In the case of GET 195 | // the last request will just be returned. For Post it will issue a post 196 | // to the URL of the last relation. Any error along the way will terminate 197 | // the walk and return immediately. 198 | func (n navigator) Get() (*http.Response, error) { 199 | url, err := n.url() 200 | if err != nil { 201 | return nil, err 202 | } 203 | 204 | req, err := newHalRequest("GET", url, nil) 205 | if err != nil { 206 | return nil, err 207 | } 208 | 209 | return n.HttpClient.Do(req) 210 | } 211 | 212 | // Options performs an OPTIONS request on the tip of the follow queue. 213 | func (n navigator) Options() (*http.Response, error) { 214 | url, err := n.url() 215 | if err != nil { 216 | return nil, err 217 | } 218 | 219 | req, err := newHalRequest("OPTIONS", url, nil) 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | return n.HttpClient.Do(req) 225 | } 226 | 227 | // PostForm performs a POST request on the tip of the follow queue with 228 | // the given form data. 229 | // 230 | // See GET for a note on how the navigator executes requests. 231 | func (n navigator) PostForm(data url.Values) (*http.Response, error) { 232 | url, err := n.url() 233 | if err != nil { 234 | return nil, err 235 | } 236 | 237 | req, err := newHalRequest("PATCH", url, strings.NewReader(data.Encode())) 238 | if err != nil { 239 | return nil, err 240 | } 241 | 242 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 243 | 244 | return n.HttpClient.Do(req) 245 | } 246 | 247 | // Patch parforms a PATCH request on the tip of the follow queue with the 248 | // given bodyType and body content. 249 | // 250 | // See GET for a note on how the navigator executes requests. 251 | func (n navigator) Patch(bodyType string, body io.Reader) (*http.Response, error) { 252 | url, err := n.url() 253 | if err != nil { 254 | return nil, err 255 | } 256 | 257 | req, err := newHalRequest("PATCH", url, body) 258 | if err != nil { 259 | return nil, err 260 | } 261 | 262 | req.Header.Add("Content-Type", bodyType) 263 | 264 | return n.HttpClient.Do(req) 265 | } 266 | 267 | // Post performs a POST request on the tip of the follow queue with the 268 | // given bodyType and body content. 269 | // 270 | // See GET for a note on how the navigator executes requests. 271 | func (n navigator) Post(bodyType string, body io.Reader) (*http.Response, error) { 272 | url, err := n.url() 273 | if err != nil { 274 | return nil, err 275 | } 276 | 277 | req, err := newHalRequest("POST", url, body) 278 | if err != nil { 279 | return nil, err 280 | } 281 | 282 | req.Header.Add("Content-Type", bodyType) 283 | 284 | return n.HttpClient.Do(req) 285 | } 286 | 287 | // Delete performs a DELETE request on the tip of the follow queue. 288 | // 289 | // See GET for a note on how the navigator executes requests. 290 | func (n navigator) Delete() (*http.Response, error) { 291 | url, err := n.url() 292 | if err != nil { 293 | return nil, err 294 | } 295 | 296 | req, err := newHalRequest("DELETE", url, nil) 297 | if err != nil { 298 | return nil, err 299 | } 300 | 301 | return n.HttpClient.Do(req) 302 | } 303 | 304 | // Unmarshal is a shorthand for Get followed by json.Unmarshal. Handles 305 | // closing the response body and unmarshalling the body. 306 | func (n navigator) Unmarshal(v interface{}) error { 307 | res, err := n.Get() 308 | if err != nil { 309 | return err 310 | } 311 | defer res.Body.Close() 312 | 313 | body, err := ioutil.ReadAll(res.Body) 314 | if err != nil { 315 | return err 316 | } 317 | 318 | return json.Unmarshal(body, &v) 319 | } 320 | 321 | func newHalRequest(method, url string, body io.Reader) (*http.Request, error) { 322 | req, err := http.NewRequest(method, url, body) 323 | if err != nil { 324 | return nil, err 325 | } 326 | 327 | req.Header.Add("Accept", "application/hal+json, application/json") 328 | 329 | return req, nil 330 | } 331 | 332 | // getLinks does a GET on a particular URL and try to deserialise it into 333 | // a HAL links collection. 334 | func (n navigator) getLinks(uri string) (Links, error) { 335 | req, err := newHalRequest("GET", uri, nil) 336 | if err != nil { 337 | return Links{}, err 338 | } 339 | 340 | res, err := n.HttpClient.Do(req) 341 | if err != nil { 342 | return Links{}, err 343 | } 344 | defer res.Body.Close() 345 | 346 | body, err := ioutil.ReadAll(res.Body) 347 | if err != nil { 348 | return Links{}, err 349 | } 350 | 351 | var m Links 352 | 353 | if err := json.Unmarshal(body, &m); err != nil { 354 | return Links{}, fmt.Errorf("Unable to unmarshal '%s': %v", string(body), err) 355 | } 356 | 357 | return m, nil 358 | } 359 | -------------------------------------------------------------------------------- /navigator_test.go: -------------------------------------------------------------------------------- 1 | package halgo 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gorilla/mux" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func createTestHttpServer() (*httptest.Server, map[string]int) { 13 | r := mux.NewRouter() 14 | hits := make(map[string]int) 15 | 16 | r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 17 | hits["/"] += 1 18 | fmt.Fprintf(w, `{ 19 | "_links": { 20 | "self": { "href": "/" }, 21 | "next": { "href": "http://%s/2nd" }, 22 | "relative": { "href": "/2nd" }, 23 | "child": { "href": "/child" }, 24 | "one": { "href": "http://%s/a/{id}", "templated": true } 25 | } 26 | }`, r.Host, r.Host) 27 | }) 28 | 29 | r.HandleFunc("/2nd", func(w http.ResponseWriter, r *http.Request) { 30 | hits["/2nd"] += 1 31 | fmt.Sprintln(w, "OK") 32 | w.WriteHeader(200) 33 | }) 34 | 35 | r.HandleFunc("/a/{id}", func(w http.ResponseWriter, r *http.Request) { 36 | hits["/a/"+mux.Vars(r)["id"]] += 1 37 | fmt.Sprintln(w, "OK") 38 | w.WriteHeader(200) 39 | }) 40 | 41 | r.HandleFunc("/child", func(w http.ResponseWriter, r *http.Request) { 42 | hits["/child"] += 1 43 | fmt.Fprintf(w, `{ "_links": { "parent": { "href": "/" } } }`) 44 | }) 45 | 46 | return httptest.NewServer(r), hits 47 | } 48 | 49 | func TestNavigatingToUnknownLink(t *testing.T) { 50 | ts, _ := createTestHttpServer() 51 | defer ts.Close() 52 | 53 | _, err := Navigator(ts.URL).Follow("missing").Get() 54 | if err == nil { 55 | t.Fatal("Expected error to be raised for missing link") 56 | } 57 | 58 | _, err = Navigator(ts.URL).Follow("missing").Options() 59 | if err == nil { 60 | t.Fatal("Expected error to be raised for OPTIONS call to missing link") 61 | } 62 | 63 | if !strings.HasPrefix(err.Error(), "Response didn't contain 'missing' link relation:") { 64 | t.Errorf("Unexpected error message: %s", err.Error()) 65 | } 66 | 67 | if _, ok := err.(LinkNotFoundError); !ok { 68 | t.Error("Expected error to be LinkNotFoundError") 69 | } 70 | } 71 | 72 | func TestGettingTheRoot(t *testing.T) { 73 | ts, hits := createTestHttpServer() 74 | defer ts.Close() 75 | 76 | nav := Navigator(ts.URL) 77 | res, err := nav.Get() 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | 82 | if res.StatusCode != http.StatusOK { 83 | t.Errorf("Expected OK, got %d", res.StatusCode) 84 | } 85 | 86 | if res.Request.URL.String() != ts.URL { 87 | t.Errorf("Expected url to be %s, got %s", ts.URL, res.Request.URL) 88 | } 89 | 90 | if hits["/"] != 1 { 91 | t.Errorf("Expected 1 request to /, got %d", hits["/"]) 92 | } 93 | 94 | // If Get works, Options should work as well 95 | res, err = nav.Options() 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | } 101 | 102 | func TestGettingTheRootSelf(t *testing.T) { 103 | ts, hits := createTestHttpServer() 104 | defer ts.Close() 105 | 106 | nav := Navigator(ts.URL) 107 | res, err := nav.Follow("self").Get() 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | 112 | if res.StatusCode != http.StatusOK { 113 | t.Errorf("Expected OK, got %d", res.StatusCode) 114 | } 115 | 116 | if res.Request.URL.String() != ts.URL+"/" { 117 | t.Errorf("Expected url to be %s, got %s", ts.URL+"/", res.Request.URL) 118 | } 119 | 120 | if hits["/"] != 2 { 121 | t.Errorf("Expected 2 requests to /, got %d", hits["/"]) 122 | } 123 | } 124 | 125 | func TestGettingTheRootViaChild(t *testing.T) { 126 | ts, hits := createTestHttpServer() 127 | defer ts.Close() 128 | 129 | nav := Navigator(ts.URL) 130 | 131 | child := nav.Follow("child") 132 | curl, err := child.url() 133 | if err != nil { 134 | t.Fatal(err) 135 | } 136 | if !strings.HasSuffix(curl, "/child") { 137 | t.Errorf("Expected URL for child relation to end with %s, but got %s", "/child", curl) 138 | } 139 | 140 | root := child.Follow("parent") 141 | _, err = root.url() 142 | if err != nil { 143 | t.Fatal(err) 144 | } 145 | 146 | res, err := root.Get() 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | 151 | if res.StatusCode != http.StatusOK { 152 | t.Errorf("Expected OK, got %d", res.StatusCode) 153 | } 154 | 155 | if res.Request.URL.String() != ts.URL+"/" { 156 | t.Errorf("Expected url to be %s, got %s", ts.URL+"/", res.Request.URL) 157 | } 158 | 159 | if hits["/"] != 4 { 160 | t.Errorf("Expected 4 request to /, got %d", hits["/"]) 161 | } 162 | } 163 | 164 | func TestFollowingATemplatedLink(t *testing.T) { 165 | ts, hits := createTestHttpServer() 166 | defer ts.Close() 167 | 168 | nav := Navigator(ts.URL).Followf("one", P{"id": 1}) 169 | res, err := nav.Get() 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | 174 | if res.StatusCode != http.StatusOK { 175 | t.Errorf("Expected OK, got %d", res.StatusCode) 176 | } 177 | 178 | if res.Request.URL.String() != ts.URL+"/a/1" { 179 | t.Errorf("Expected url to be %s, got %s", ts.URL+"/a/1", res.Request.URL) 180 | } 181 | 182 | if hits["/"] != 1 { 183 | t.Errorf("Expected 1 request to /, got %d", hits["/"]) 184 | } 185 | 186 | if hits["/a/1"] != 1 { 187 | t.Errorf("Expected 1 request to /a/1, got %d", hits["/a/1"]) 188 | } 189 | } 190 | 191 | func TestFollowingARelativeLink(t *testing.T) { 192 | ts, hits := createTestHttpServer() 193 | defer ts.Close() 194 | 195 | res, err := Navigator(ts.URL).Follow("relative").Get() 196 | if err != nil { 197 | t.Fatal(err) 198 | } 199 | 200 | if res.StatusCode != http.StatusOK { 201 | t.Errorf("Expected OK, got %d", res.StatusCode) 202 | } 203 | 204 | if res.Request.URL.String() != ts.URL+"/2nd" { 205 | t.Errorf("Expected url to be %s, got %s", ts.URL+"/2nd", res.Request.URL) 206 | } 207 | 208 | if hits["/"] != 1 { 209 | t.Errorf("Expected 1 request to /, got %d", hits["/"]) 210 | } 211 | 212 | if hits["/2nd"] != 1 { 213 | t.Errorf("Expected 1 request to /a/1, got %d", hits["/2nd"]) 214 | } 215 | } 216 | 217 | func TestFollowingALink(t *testing.T) { 218 | ts, hits := createTestHttpServer() 219 | defer ts.Close() 220 | 221 | nav := Navigator(ts.URL).Follow("next") 222 | res, err := nav.Get() 223 | if err != nil { 224 | t.Fatal(err) 225 | } 226 | 227 | if res.StatusCode != http.StatusOK { 228 | t.Errorf("Expected OK, got %d", res.StatusCode) 229 | } 230 | 231 | if res.Request.URL.String() != ts.URL+"/2nd" { 232 | t.Errorf("Expected url to be %s, got %s", ts.URL+"/2nd", res.Request.URL) 233 | } 234 | 235 | if hits["/"] != 1 { 236 | t.Errorf("Expected 1 request to /, got %d", hits["/"]) 237 | } 238 | 239 | if hits["/2nd"] != 1 { 240 | t.Errorf("Expected 1 request to /2nd, got %d", hits["/2nd"]) 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /query_test.go: -------------------------------------------------------------------------------- 1 | package halgo 2 | 3 | import "testing" 4 | 5 | var hrefTests = []struct { 6 | name string 7 | expected string 8 | url string 9 | }{ 10 | {"normal", "/example", "/example"}, 11 | {"parameterised", "/example", "/example{?q}"}, 12 | } 13 | 14 | func TestHref(t *testing.T) { 15 | for _, test := range hrefTests { 16 | links := Links{}.Link(test.name, test.url) 17 | href, err := links.Href(test.name) 18 | if err != nil { 19 | t.Error(err) 20 | } 21 | if href != test.expected { 22 | t.Errorf("%s: Expected href to be '%s', got '%s'", test.name, test.expected, href) 23 | } 24 | } 25 | } 26 | 27 | var hrefParamsTests = []struct { 28 | name string 29 | expected string 30 | url string 31 | params P 32 | }{ 33 | {"nil parameters", "/example", "/example{?q}", nil}, 34 | {"empty parameters", "/example", "/example{?q}", P{}}, 35 | {"mismatched parameters", "/example", "/example{?q}", P{"c": "test"}}, 36 | {"single parameter", "/example?q=test", "/example{?q}", P{"q": "test"}}, 37 | {"multiple parameters", "/example?q=test&page=1", "/example{?q,page}", P{"q": "test", "page": 1}}, 38 | } 39 | 40 | func TestHrefParams(t *testing.T) { 41 | for _, test := range hrefParamsTests { 42 | links := Links{}.Link(test.name, test.url) 43 | href, err := links.HrefParams(test.name, test.params) 44 | if err != nil { 45 | t.Error(err) 46 | } 47 | if href != test.expected { 48 | t.Errorf("%s: Expected href to be '%s', got '%s'", test.name, test.expected, href) 49 | } 50 | } 51 | } 52 | --------------------------------------------------------------------------------