├── .gitignore ├── .travis.yml ├── CONTRIBUTORS ├── LICENSE ├── Makefile ├── README.md ├── build.sh ├── cache.go ├── cache_test.go ├── example ├── README.md ├── app.py ├── hasher.py ├── test.sh ├── testing_data │ ├── 262076ab58b2423e21e681e7b710312c │ ├── 451e5ecd5fd81e89cc52fb648f93fab7 │ ├── 536c0b3471dcf3f782d0956fa001570c │ ├── 9835adf25e3ecc09431cdf3079bb822a │ ├── c884f9c06bdfd2dac66e6af8e2e3c4c1 │ ├── dbc78ad575723d20eb5469356ac19562 │ ├── f131cff22faf4cb6acd94098b42a9452 │ ├── foo_bar_hash │ └── spec.json └── tests.py ├── handlers.go ├── handlers_test.go ├── hash.go ├── hash_test.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | 25 | *.swp 26 | 27 | httpbin/ 28 | built/ 29 | 30 | chameleon 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: go 4 | 5 | go: 6 | - 1.6 7 | - tip 8 | 9 | notifications: 10 | email: false 11 | 12 | before_install: 13 | - export PATH=$PATH:$HOME/gopath/bin 14 | - go get golang.org/x/tools/cmd/cover 15 | - go get github.com/mattn/goveralls 16 | - go get github.com/GeertJohan/fgt 17 | - go get github.com/alecthomas/gometalinter 18 | - gometalinter --install 19 | 20 | install: 21 | - go get . 22 | 23 | script: 24 | - make testlint 25 | - make example 26 | - go test -v -tags testing -covermode=set -coverprofile=profile.cov ./... 27 | - goveralls -coverprofile=profile.cov -service=travis-ci -repotoken 5ZqQy0HNoFzGLyCfwptZj0Av4BUdOrZWA 28 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Please keep this file in alphabetical order 2 | ------------------------------------------- 3 | 4 | BharathMG 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Nick Presta 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build install example test cover cover-web testlint lint 2 | 3 | build: 4 | go build -race 5 | 6 | install: 7 | go install -race . 8 | 9 | example: install 10 | ./example/test.sh 11 | 12 | test: 13 | go test -race -cover -v -tags testing ./... 14 | 15 | cover: 16 | t=`mktemp 2>/dev/null || mktemp -t 'cover'` && \ 17 | go test -v -tags testing -race -covermode=set -coverprofile=$$t ./... ; \ 18 | go tool cover -func=$$t ; \ 19 | unlink $$t 20 | 21 | cover-web: 22 | t=`mktemp 2>/dev/null || mktemp -t 'cover'` && \ 23 | go test -v -tags testing -race -covermode=set -coverprofile=$$t ./... ; \ 24 | go tool cover -html=$$t ; \ 25 | unlink $$t 26 | 27 | testlint: 28 | fgt gometalinter . 29 | 30 | lint: 31 | gometalinter . 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chameleon 2 | 3 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/nickpresta/chameleon?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | [![Build Status](https://img.shields.io/travis/nickpresta/chameleon/master.svg?style=flat)](https://travis-ci.org/nickpresta/chameleon) 6 | [![Coveralls](https://img.shields.io/coveralls/nickpresta/chameleon/master.svg?style=flat)](https://coveralls.io/r/nickpresta/chameleon) 7 | [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](https://tldrlegal.com/license/mit-license) 8 | 9 | ## What is chameleon? 10 | 11 | chameleon is a caching reverse proxy. 12 | 13 | chameleon supports recording and replaying requests with the ability to customize how responses are stored. 14 | 15 | ## Why is chameleon useful? 16 | 17 | * Proxy rate-limited APIs for local development 18 | * Create reliable test environments 19 | * Test services in places you normally couldn't due to firewalling, etc (CI servers being common) 20 | * Improve speed of tests by never leaving your local network 21 | * Inspect recorded APIs responses for exploratory testing 22 | * Stub out unimplemented API endpoints during development 23 | 24 | ## What can't I do with chameleon? 25 | 26 | * Have tests that exercise a given service **right now** as results are cached 27 | * Total control on how things are cached, frequency, rate-limiting, etc (pull requests are welcome, though!) 28 | 29 | ## How to get chameleon? 30 | 31 | chameleon has **no** runtime dependencies. You can download a 32 | [prebuilt binary](https://github.com/nickpresta/chameleon/releases) for your platform. 33 | 34 | If you have Go installed, you may `go get github.com/nickpresta/chameleon` to download it to your `$GOPATH` directory. 35 | 36 | ## How to use chameleon 37 | 38 | * Check out the [example](./example) directory for a small app that uses chameleon to create reliable tests with a custom hasher. 39 | 40 | To run chameleon, you can: 41 | 42 | chameleon -data ./httpbin -url http://httpbin.org -verbose 43 | 44 | The directory `httpbin` must already exist before running. 45 | 46 | See `chameleon -help` for more information. 47 | 48 | ### Specifying custom hash 49 | 50 | There may be a reason in your tests to manually create responses - perhaps the backing service doesn't exist yet, or in test mode a service behaves differently than production. When this is the case, you can create custom responses and signal to chameleon the hash you want to use for a given request. 51 | 52 | Set the `chameleon-request-hash` header with a unique hash value (which is a valid filename) and chameleon will look for that hash in the `spec.json` file and for all subsequent requests. 53 | 54 | This allows you to not only have total control over the response for a given request but also makes your test code easier to reason about -- it is clear where the "special case" response is coming from. 55 | 56 | ### Getting the hash for a given request 57 | 58 | All responses from chameleon will have a `chameleon-request-hash` header set which is the hash used for that request. This header is present even if you did not set it on the incoming request. 59 | 60 | ### Preseeding the cache 61 | 62 | If you want to configure the cache at runtime without having to depend on an external service, you may preseed the cache 63 | via HTTP. This is particularly useful for mocking out services which don't yet exist. 64 | 65 | To preseed a request, issue a JSON `POST` request to chameleon at the `_seed` endpoint with the following payload: 66 | 67 | Field | Description 68 | ----- | ----------- 69 | `Request` | Request is the request payload including a URL, Method and Body 70 | `Response` | Response is the response to be cached and sent back for a given request 71 | 72 | **Request** 73 | 74 | Field | Description 75 | ----- | ----------- 76 | `Body` | Body is the content for the request. May be empty where body doesn't make sense (e.g. `GET` requests) 77 | `Method` | Method is the HTTP method used to match the incoming request. Case insensitive, supports arbitrary methods 78 | `URL` | URL is the absolute or relative URL to match in requests. Only the path and querystring are used 79 | 80 | **Response** 81 | 82 | Field | Description 83 | ----- | ----------- 84 | `Body` | Body is the content for the request. May be empty where body doesn't make sense (e.g. `GET` requests) 85 | `Headers` | Headers is a map of headers in the format of string key to string value 86 | `StatusCode` | StatusCode is the [HTTP status code](http://en.wikipedia.org/wiki/List_of_HTTP_status_codes) of the response 87 | 88 | Repeated, duplicate requests to preseed the cache will be discarded and the cache unaffected. 89 | 90 | Successful new preseed requests will return an `HTTP 201 CREATED` on success or `HTTP 500 INTERNAL SERVER ERROR`. 91 | Duplicate preseed requests will return an `HTTP 200 OK` on success or `HTTP 500 INTERNAL SERVER ERROR` on failure. 92 | 93 | Here is an example of preseeding the cache with a JSON response for a `GET` request for `/foobar`. 94 | 95 | ```python 96 | import requests 97 | 98 | preseed = json.dumps({ 99 | 'Request': { 100 | 'Body': '', 101 | 'URL': '/foobar', 102 | 'Method': 'GET', 103 | }, 104 | 'Response': { 105 | 'Body': '{"key": "value"}', 106 | 'Headers': { 107 | 'Content-Type': 'application/json', 108 | 'Other-Header': 'something-else', 109 | }, 110 | 'StatusCode': 200, 111 | }, 112 | }) 113 | 114 | response = requests.post('http://localhost:6005/_seed', data=preseed) 115 | if response.status_code in (200, 201): 116 | # Created, or duplicate 117 | else: 118 | # Error, print it out 119 | print(response.content) 120 | 121 | # Continue tests as normal 122 | # Making requests to `/foobar` will return `{"key": "value"}` 123 | # without hitting the proxied service 124 | ``` 125 | 126 | Check out the [example](./example) directory to see preseeding in action. 127 | 128 | ### How chameleon caches responses 129 | 130 | chameleon makes a hash for a given request URI, request method and request body and uses that to cache content. What that means: 131 | 132 | * a request of `GET /foo/` will be cached differently than `GET /bar/` 133 | * a request of `GET /foo/5` will be cached differently than `GET /foo/6` 134 | * a request of `DELETE /foo/5` will be cached differently than `DELETE /foo/6` 135 | * a request of `POST /foo` with a body of `{"hi":"hello}` will be cached differently than a request of `POST /foo` with a body of `{"spam":"eggs"}`. To ignore the request body, set a header of `chameleon-no-hash-body` to any value. This will instruct chameleon to ignore the body as part of the hash. 136 | 137 | ### Writing custom hasher 138 | 139 | You can specify a custom hasher, which could be any program in any language, to determine what makes a request unique. 140 | 141 | chameleon will communicate with this program via STDIN/STDOUT and feed the hasher a serialized `Request` (see below). 142 | You are then responsible for returning data to chameleon to be used for that given request (which will be hashed). 143 | 144 | This feature is especially useful if you have to cache content based on the body of a request 145 | (XML payload, specific keys in JSON payload, etc). 146 | 147 | See the [example hasher](./example/hasher.py) for a sample hasher that emulates the default hasher. 148 | 149 | #### Structure of Request 150 | 151 | Below is an example Request serialized to JSON. 152 | 153 | ```json 154 | { 155 | "BodyBase64":"eyJmb28iOiAiYmFyIn0=", 156 | "ContentLength":14, 157 | "Headers":{ 158 | "Accept":[ 159 | "application/json" 160 | ], 161 | "Accept-Encoding":[ 162 | "gzip, deflate" 163 | ], 164 | "Authorization":[ 165 | "Basic dXNlcjpwYXNzd29yZA==" 166 | ], 167 | "Connection":[ 168 | "keep-alive" 169 | ], 170 | "Content-Length":[ 171 | "14" 172 | ], 173 | "Content-Type":[ 174 | "application/json; charset=utf-8" 175 | ], 176 | "User-Agent":[ 177 | "HTTPie/0.7.2" 178 | ] 179 | }, 180 | "Method":"POST", 181 | "URL":{ 182 | "Host":"httpbin.org", 183 | "Path":"/post", 184 | "RawQuery":"q=search+term%23home", 185 | "Scheme":"https" 186 | } 187 | } 188 | ``` 189 | 190 | Field | Description 191 | ----- | ----------- 192 | BodyBase64 | Body is the request's body, base64 encoded 193 | ContentLength | ContentLength records the length of the associated content after being base64 decoded 194 | Headers | Headers is a map of request lines to value lists. HTTP defines that header names are case-insensitive. Header names have been canonicalized, making the first character and any characters following a hyphen uppercase and the rest lowercase 195 | Method | Method specifies the HTTP method (`GET`, `POST`, `PUT`, etc.) 196 | URL | URL is an object containing `Host`, the HTTP Host in the form of 'host' or 'host:port', `Path`, the request path including trailing slash, `RawQuery`, encoded query string values without '?', and `Scheme`, the URL scheme 'http', 'https' 197 | 198 | ## Getting help 199 | 200 | Please [open an issue](https://github.com/nickpresta/chameleon/issues) for any bugs encountered, features requests, or 201 | general troubleshooting. 202 | 203 | ## Authors 204 | 205 | [Nick Presta](http://nickpresta.ca) ([@NickPresta](https://twitter.com/NickPresta)) 206 | 207 | Thanks to [@mdibernardo](https://twitter.com/mdibernardo) for the inspiration. 208 | 209 | ## License 210 | 211 | Please see [LICENSE](./LICENSE) 212 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | OUT="./built/" 4 | if [ ! -d "$OUT" ]; then 5 | mkdir "$OUT" 6 | else 7 | rm -rf "$OUT" 8 | mkdir "$OUT" 9 | fi 10 | 11 | VERSION="$1" 12 | if [ $# -eq 0 ]; then 13 | VERSION="dev" 14 | fi 15 | 16 | cd "$OUT" 17 | gox -output "chameleon_${VERSION}_{{.OS}}_{{.Arch}}" github.com/nickpresta/chameleon 18 | 19 | for file in chameleon_*; do 20 | if [[ "$file" == *.exe ]]; then 21 | name="chameleon.exe" 22 | else 23 | name="chameleon" 24 | fi 25 | mv "$file" $name 26 | zip "$file.zip" $name 27 | rm $name 28 | done 29 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http/httptest" 7 | "path" 8 | "strings" 9 | "sync" 10 | ) 11 | 12 | // CachedResponse respresents a response to be cached. 13 | type CachedResponse struct { 14 | StatusCode int 15 | Body []byte 16 | Headers map[string]string 17 | } 18 | 19 | // SpecResponse represents a specification for a response. 20 | type SpecResponse struct { 21 | StatusCode int `json:"status_code"` 22 | ContentFile string `json:"content"` 23 | Headers map[string]string `json:"headers"` 24 | } 25 | 26 | // Spec represents a full specification to describe a response and how to look up its index. 27 | type Spec struct { 28 | SpecResponse `json:"response"` 29 | Key string `json:"key"` 30 | } 31 | 32 | // A FileSystem interface is used to provide a mechanism of storing and retreiving files to/from disk. 33 | type FileSystem interface { 34 | WriteFile(path string, content []byte) error 35 | ReadFile(path string) ([]byte, error) 36 | } 37 | 38 | // DefaultFileSystem provides a default implementation of a filesystem on disk. 39 | type DefaultFileSystem struct { 40 | } 41 | 42 | // WriteFile writes content to disk at path. 43 | func (fs DefaultFileSystem) WriteFile(path string, content []byte) error { 44 | return ioutil.WriteFile(path, content, 0644) 45 | } 46 | 47 | // ReadFile reads content from disk at path. 48 | func (fs DefaultFileSystem) ReadFile(path string) ([]byte, error) { 49 | return ioutil.ReadFile(path) 50 | } 51 | 52 | // A Cacher interface is used to provide a mechanism of storage for a given request and response. 53 | type Cacher interface { 54 | Get(key string) *CachedResponse 55 | Put(key string, r *httptest.ResponseRecorder) *CachedResponse 56 | } 57 | 58 | // DiskCacher is the default cacher which writes to disk 59 | type DiskCacher struct { 60 | cache map[string]*CachedResponse 61 | dataDir string 62 | specPath string 63 | mutex *sync.RWMutex 64 | FileSystem 65 | } 66 | 67 | // NewDiskCacher creates a new disk cacher for a given data directory. 68 | func NewDiskCacher(dataDir string) DiskCacher { 69 | return DiskCacher{ 70 | cache: make(map[string]*CachedResponse), 71 | dataDir: dataDir, 72 | specPath: path.Join(dataDir, "spec.json"), 73 | mutex: new(sync.RWMutex), 74 | FileSystem: DefaultFileSystem{}, 75 | } 76 | } 77 | 78 | // SeedCache populates the DiskCacher with entries from disk. 79 | func (c *DiskCacher) SeedCache() { 80 | c.mutex.Lock() 81 | defer c.mutex.Unlock() 82 | 83 | specs := c.loadSpecs() 84 | 85 | for _, spec := range specs { 86 | body, err := c.FileSystem.ReadFile(path.Join(c.dataDir, spec.SpecResponse.ContentFile)) 87 | if err != nil { 88 | panic(err) 89 | } 90 | response := &CachedResponse{ 91 | StatusCode: spec.StatusCode, 92 | Headers: spec.Headers, 93 | Body: body, 94 | } 95 | c.cache[spec.Key] = response 96 | } 97 | } 98 | 99 | // Get fetches a CachedResponse for a given key 100 | func (c DiskCacher) Get(key string) *CachedResponse { 101 | c.mutex.RLock() 102 | defer c.mutex.RUnlock() 103 | 104 | return c.cache[key] 105 | } 106 | 107 | func (c DiskCacher) loadSpecs() []Spec { 108 | specContent, err := c.FileSystem.ReadFile(c.specPath) 109 | if err != nil { 110 | specContent = []byte{'[', ']'} 111 | } 112 | 113 | var specs []Spec 114 | err = json.Unmarshal(specContent, &specs) 115 | if err != nil { 116 | panic(err) 117 | } 118 | 119 | return specs 120 | } 121 | 122 | // Put stores a CachedResponse for a given key and response 123 | func (c DiskCacher) Put(key string, resp *httptest.ResponseRecorder) *CachedResponse { 124 | c.mutex.Lock() 125 | defer c.mutex.Unlock() 126 | 127 | skipDisk := resp.Header().Get("_chameleon-seeded-skip-disk") != "" 128 | if skipDisk { 129 | resp.Header().Del("_chameleon-seeded-skip-disk") 130 | } 131 | 132 | specHeaders := make(map[string]string) 133 | for k, v := range resp.Header() { 134 | specHeaders[k] = strings.Join(v, ", ") 135 | } 136 | 137 | if !skipDisk { 138 | specs := c.loadSpecs() 139 | 140 | newSpec := Spec{ 141 | Key: key, 142 | SpecResponse: SpecResponse{ 143 | StatusCode: resp.Code, 144 | ContentFile: key, 145 | Headers: specHeaders, 146 | }, 147 | } 148 | 149 | specs = append(specs, newSpec) 150 | 151 | contentFilePath := path.Join(c.dataDir, key) 152 | err := c.FileSystem.WriteFile(contentFilePath, resp.Body.Bytes()) 153 | if err != nil { 154 | panic(err) 155 | } 156 | 157 | specBytes, err := json.MarshalIndent(specs, "", " ") 158 | err = c.FileSystem.WriteFile(c.specPath, specBytes) 159 | if err != nil { 160 | panic(err) 161 | } 162 | } 163 | 164 | c.cache[key] = &CachedResponse{ 165 | StatusCode: resp.Code, 166 | Headers: specHeaders, 167 | Body: resp.Body.Bytes(), 168 | } 169 | 170 | return c.cache[key] 171 | } 172 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | type mockFileSystem struct { 13 | } 14 | 15 | func (fs mockFileSystem) WriteFile(path string, content []byte) error { 16 | return nil 17 | } 18 | 19 | func (fs mockFileSystem) ReadFile(path string) ([]byte, error) { 20 | if strings.HasSuffix(path, "-error") { 21 | return nil, fmt.Errorf("SOMETHING BROKE") 22 | } 23 | 24 | // Return specs when spec.json is requested (which should be always) 25 | if strings.HasSuffix(path, "spec.json") { 26 | specs := []Spec{ 27 | Spec{ 28 | Key: "key", 29 | SpecResponse: SpecResponse{ 30 | StatusCode: 418, 31 | ContentFile: "key", 32 | Headers: map[string]string{"Content-Type": "text/plain"}, 33 | }, 34 | }, 35 | } 36 | var specsContent bytes.Buffer 37 | dec := json.NewEncoder(&specsContent) 38 | _ = dec.Encode(specs) 39 | return specsContent.Bytes(), nil 40 | } 41 | // Otherwise we're returning a file that has been cached 42 | return []byte("CACHED CONTENT FILE"), nil 43 | } 44 | 45 | func TestDiskCacherGet(t *testing.T) { 46 | cacher := NewDiskCacher("") 47 | cacher.FileSystem = mockFileSystem{} 48 | cacher.SeedCache() 49 | 50 | response := cacher.Get("key") 51 | if response.StatusCode != 418 { 52 | t.Errorf("Got: `%v`; Expected: `418`", response.StatusCode) 53 | } 54 | if response.Headers["Content-Type"] != "text/plain" { 55 | t.Errorf("Got: `%v`; Expected: `text/plain`", response.Headers["Content-Type"]) 56 | } 57 | if string(response.Body) != "CACHED CONTENT FILE" { 58 | t.Errorf("Got: `%v`; Expected: `CACHED CONTENT FILE`", string(response.Body)) 59 | } 60 | } 61 | 62 | func TestDiskCacherPut(t *testing.T) { 63 | cacher := NewDiskCacher("") 64 | cacher.FileSystem = mockFileSystem{} 65 | cacher.SeedCache() 66 | 67 | recorder := httptest.NewRecorder() 68 | var body bytes.Buffer 69 | _, _ = body.WriteString("THIS IS A NEW BODY") 70 | recorder.Header().Set("Content-Type", "text/plain") 71 | recorder.Code = 700 72 | recorder.Body = &body 73 | response := cacher.Put("new_key", recorder) 74 | 75 | if response.StatusCode != 700 { 76 | t.Errorf("Got: `%v`; Expected: `700`", response.StatusCode) 77 | } 78 | if response.Headers["Content-Type"] != "text/plain" { 79 | t.Errorf("Got: `%v`; Expected: `text/plain`", response.Headers["Content-Type"]) 80 | } 81 | if string(response.Body) != "THIS IS A NEW BODY" { 82 | t.Errorf("Got: `%v`; Expected: `THIS IS A NEW BODY`", string(response.Body)) 83 | } 84 | } 85 | 86 | func TestDiskCacherSeedCacheNoSpecs(t *testing.T) { 87 | cacher := NewDiskCacher("") 88 | cacher.FileSystem = mockFileSystem{} 89 | cacher.specPath = "-error" 90 | 91 | cacher.SeedCache() 92 | if len(cacher.cache) != 0 { 93 | t.Errorf("Got: `%v`; Expected: `0`", len(cacher.cache)) 94 | } 95 | } 96 | 97 | func TestDiskCacherPutSkipDiskSeeded(t *testing.T) { 98 | cacher := NewDiskCacher("") 99 | cacher.FileSystem = mockFileSystem{} 100 | cacher.SeedCache() 101 | 102 | recorder := httptest.NewRecorder() 103 | recorder.Header().Set("_chameleon-seeded-skip-disk", "true") 104 | response := cacher.Put("new_key", recorder) 105 | 106 | if _, ok := response.Headers["_chameleon-seeded-skip-disk"]; ok { 107 | t.Errorf("Unexpected header `_chameleon-seeded-skip-disk`") 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # chameleon Example 2 | 3 | This example illustrates how to setup chameleon with a custom hasher and how you might integrate it into your application. 4 | 5 | ## Application 6 | 7 | This application's purpose to is accept a given HTTP status code (like `200`) and return the text associated with 8 | that status code (`OK` in the `200` case). 9 | 10 | The app in this directory calls [https://httpbin.org](https://httpbin.org) to fetch a response for a given status code, 11 | grabs the "message" for that status code (`OK`, `I'M A TEAPOT`, etc) and returns that in the response to the user. 12 | 13 | To run this application (you need Python 2.x): 14 | 15 | $ TEST_APP_PORT=10005 python app.py 16 | 17 | Then use cURL, your browser, etc, and issue an HTTP GET request to `localhost:10005/418`. You should see `I'M A TEAPOT` 18 | as the response body. 19 | 20 | ## Testing this service 21 | 22 | There are some accompanying user tests (E2E, API, what ever you call them) in the file `tests.py`. Run it like so: 23 | 24 | $ TEST_APP_PORT=10005 python tests.py 25 | 26 | You should see a bunch of unit tests pass that look like this (note the time it takes): 27 | 28 | $ TEST_APP_PORT=10005 python tests.py 29 | ......... 30 | ---------------------------------------------------------------------- 31 | Ran 9 tests in 3.9s 32 | 33 | OK 34 | 35 | You could imagine tests that check JSON error payloads conform to a certain structure, that response headers are present, 36 | and a whole list of other things you care about in an end-to-end test scenario. 37 | 38 | ## Problems 39 | 40 | Imagine you are writing an app that depended on an external service to do its job. Perhaps this is the Twitter Search API, or something equally restrictive in the number of times you're allowed to interact with it. 41 | 42 | What would you do if your external service was rate limiting you? How about only allowing access from specific 43 | IP addresses? What if the external service was slow and unreliable? 44 | 45 | You could proxy and cache the backend service and allow your E2E tests to behave normally and with real, valid data. 46 | 47 | ## How to integrate chameleon 48 | 49 | This assumes you're running chameleon from this `example` directory. 50 | 51 | 1. Set up chameleon to proxy calls to https://httpbin.org: 52 | 53 | $ mkdir httpbin 54 | $ chameleon -data ./httpbin -port 6005 -verbose -url https://httpbin.org/ -hasher 'python ./hasher.py' 55 | 56 | 1. Instruct our application to use chameleon to make requests. We set the `TEST_SERVICE_URL` to chameleon: 57 | 58 | $ TEST_APP_PORT=10005 TEST_SERVICE_URL=http://localhost:6005/ python app.py 59 | 60 | 1. Run our tests again: 61 | 62 | 63 | $ TEST_APP_PORT=10005 TEST_CHAMELEON_PORT=6005 python tests.py 64 | ......... 65 | ---------------------------------------------------------------------- 66 | Ran 9 tests in 3.398s 67 | 68 | OK 69 | 70 | You will notice that our test run isn't much faster. If you flip over to your view of chameleon, you will see: 71 | 72 | Starting proxy for 'https://httpbin.org/' 73 | -> Proxying [not cached: 116f933e981e92c994619116ee37fd30] to https://httpbin.org/status/200 74 | -> Proxying [not cached: 7c68a3b062b22caf6b2ca517027611bc] to https://httpbin.org/status/418 75 | -> Proxying [not cached: f6970b3f15df6d952f33387988c04967] to https://httpbin.org/status/500 76 | -> Proxying [cached: 116f933e981e92c994619116ee37fd30] to https://httpbin.org/status/200 77 | 78 | We can see that chameleon actually hit `https://httpbin.org/status/:code` three times and then the fourth time, 79 | it had a cache for the `200` code so it returned the cached version. 80 | 81 | If we run our tests again, we see: 82 | 83 | $ TEST_APP_PORT=10005 TEST_CHAMELEON_PORT=6005 python tests.py 84 | ......... 85 | ---------------------------------------------------------------------- 86 | Ran 9 tests in 0.415s 87 | 88 | OK 89 | 90 | In all nine cases, chameleon already has the responses in memory. This resulted in a much faster test run, 91 | and if our backend service started to throttle us, or we wanted to run these tests from somewhere that couldn't 92 | reach httpbin, we still could. 93 | 94 | ## Conclusions 95 | 96 | It can be fairly trivial to integrate chameleon into your testing workflow. In fact, this example was the most 97 | complicated example of running chameleon. For simple services, you may not need a custom hasher, in which case the default 98 | hasher does the Right Thing™ (as described in the docs). 99 | -------------------------------------------------------------------------------- /example/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import BaseHTTPServer 4 | import os 5 | import urllib2 6 | import urlparse 7 | 8 | SERVICE_URL = os.getenv('TEST_SERVICE_URL', 'https://httpbin.org/') 9 | TEST_APP_PORT = int(os.getenv('TEST_APP_PORT', 9001)) 10 | 11 | STATUS_SERVICE_URL = urlparse.urljoin(SERVICE_URL, '/status/') 12 | POST_SERVICE_URL = urlparse.urljoin(SERVICE_URL, '/post') 13 | PUT_SERVICE_URL = urlparse.urljoin(SERVICE_URL, '/put') 14 | PATCH_SERVICE_URL = urlparse.urljoin(SERVICE_URL, '/patch') 15 | DELETE_SERVICE_URL = urlparse.urljoin(SERVICE_URL, '/delete') 16 | 17 | 18 | class MyHandler(BaseHTTPServer.BaseHTTPRequestHandler): 19 | 20 | def _do_patch_post_put(self, url, method, headers=None): 21 | if headers is None: 22 | headers = {} 23 | headers.update({'Content-Type': 'application/json'}) 24 | content_len = int(self.headers.getheader('content-length', 0)) 25 | body = self.rfile.read(content_len) 26 | 27 | req = urllib2.Request(url, body, headers) 28 | req.get_method = lambda: method 29 | try: 30 | resp = urllib2.urlopen(req) 31 | except urllib2.HTTPError as exc: 32 | resp = exc 33 | 34 | self.send_response(200) 35 | self.send_header('Content-type', 'application/json') 36 | self.end_headers() 37 | self.wfile.write(resp.read()) 38 | 39 | def do_PATCH(self): 40 | self._do_patch_post_put(PATCH_SERVICE_URL, 'PATCH') 41 | 42 | def do_PUT(self): 43 | self._do_patch_post_put(PUT_SERVICE_URL, 'PUT') 44 | 45 | def do_POST(self): 46 | self._do_patch_post_put(POST_SERVICE_URL, 'POST') 47 | 48 | def do_DELETE(self): 49 | req = urllib2.Request(DELETE_SERVICE_URL) 50 | req.get_method = lambda: 'DELETE' 51 | try: 52 | resp = urllib2.urlopen(req) 53 | except urllib2.HTTPError as exc: 54 | resp = exc 55 | 56 | self.send_response(200) 57 | self.send_header('Content-type', 'application/json') 58 | self.end_headers() 59 | self.wfile.write(resp.read()) 60 | 61 | def do_GET(self): 62 | # requests to /200 will forward the request to STATUS_SERVICE_URL/200, etc 63 | # and return a response with the status code text string 64 | url = urlparse.urljoin(STATUS_SERVICE_URL, self.path[1:]) 65 | try: 66 | resp = urllib2.urlopen(url) 67 | except urllib2.HTTPError as exc: 68 | resp = exc 69 | self.send_response(200) 70 | self.send_header('Content-type', 'text/plain') 71 | self.end_headers() 72 | self.wfile.write(resp.msg.upper()) 73 | 74 | def do_HASHED(self): 75 | # Custom method that doesn't hash a post with body 76 | self._do_patch_post_put(POST_SERVICE_URL, 'POST', {'chameleon-no-hash-body': 'true'}) 77 | 78 | def do_SEEDED(self): 79 | url = urlparse.urljoin(SERVICE_URL, self.path[1:]) 80 | try: 81 | resp = urllib2.urlopen(url) 82 | except urllib2.HTTPError as exc: 83 | resp = exc 84 | self.send_response(resp.getcode()) 85 | self.send_header('Content-type', resp.headers['content-type']) 86 | self.end_headers() 87 | self.wfile.write(resp.read()) 88 | 89 | def do_REQUESTHASH(self): 90 | content_len = int(self.headers.getheader('content-length', 0)) 91 | body = self.rfile.read(content_len) 92 | 93 | req = urllib2.Request(POST_SERVICE_URL, body, self.headers) 94 | req.get_method = lambda: 'POST' 95 | try: 96 | resp = urllib2.urlopen(req) 97 | except urllib2.HTTPError as exc: 98 | resp = exc 99 | 100 | self.send_response(200) 101 | for k, v in resp.headers.dict.viewitems(): 102 | self.send_header(k, v) 103 | self.end_headers() 104 | self.wfile.write(resp.read()) 105 | 106 | 107 | def main(): 108 | print('Serving on port {}'.format(TEST_APP_PORT)) 109 | server = BaseHTTPServer.HTTPServer(('localhost', TEST_APP_PORT), MyHandler) 110 | server.serve_forever() 111 | 112 | 113 | if __name__ == '__main__': 114 | main() 115 | -------------------------------------------------------------------------------- /example/hasher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import base64 4 | import json 5 | import sys 6 | 7 | 8 | def hasher(request): 9 | out = request['Method'] + request['URL']['Path'] 10 | if request['Headers'].get('Chameleon-Hash-Body', [''])[0] == 'true': 11 | out += base64.b64decode(request['BodyBase64']) 12 | return out 13 | 14 | 15 | def main(stdin): 16 | request = json.loads(sys.stdin.read()) 17 | out = hasher(request) 18 | sys.stdout.write(out) 19 | 20 | 21 | if __name__ == '__main__': 22 | main(sys.stdin) 23 | -------------------------------------------------------------------------------- /example/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export TEST_APP_PORT=10010 4 | export TEST_CHAMELEON_PORT=6010 5 | 6 | trap 'kill $(jobs -p) > /dev/null 2>&1' EXIT # Cleanup our servers on exit 7 | 8 | cd $(dirname $0) 9 | 10 | chameleon -data ./testing_data -url https://httpbin.org/ -hasher="python ./hasher.py" \ 11 | -host localhost:$TEST_CHAMELEON_PORT -verbose & 12 | TEST_SERVICE_URL=http://localhost:$TEST_CHAMELEON_PORT/ python app.py > /dev/null 2>&1 & 13 | 14 | sleep 3 # Let the servers spin up 15 | 16 | python tests.py > results.txt 2>&1 17 | TEST_RESULT=$? 18 | 19 | cat results.txt 20 | rm -f results.txt 21 | 22 | exit $TEST_RESULT 23 | -------------------------------------------------------------------------------- /example/testing_data/262076ab58b2423e21e681e7b710312c: -------------------------------------------------------------------------------- 1 | 2 | -=[ teapot ]=- 3 | 4 | _...._ 5 | .' _ _ `. 6 | | ."` ^ `". _, 7 | \_;`"---"`|// 8 | | ;/ 9 | \_ _/ 10 | `"""` 11 | -------------------------------------------------------------------------------- /example/testing_data/451e5ecd5fd81e89cc52fb648f93fab7: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickpresta/chameleon/e27d43b04abddb6de4fe330201dfe8c35bd02ce2/example/testing_data/451e5ecd5fd81e89cc52fb648f93fab7 -------------------------------------------------------------------------------- /example/testing_data/536c0b3471dcf3f782d0956fa001570c: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickpresta/chameleon/e27d43b04abddb6de4fe330201dfe8c35bd02ce2/example/testing_data/536c0b3471dcf3f782d0956fa001570c -------------------------------------------------------------------------------- /example/testing_data/9835adf25e3ecc09431cdf3079bb822a: -------------------------------------------------------------------------------- 1 | { 2 | "args": {}, 3 | "data": "{\"spam\": \"eggs\"}", 4 | "files": {}, 5 | "form": {}, 6 | "headers": { 7 | "Accept-Encoding": "identity", 8 | "Connect-Time": "1", 9 | "Connection": "close", 10 | "Content-Length": "16", 11 | "Content-Type": "application/json", 12 | "Host": "httpbin.org", 13 | "Total-Route-Time": "0", 14 | "User-Agent": "Python-urllib/2.7", 15 | "Via": "1.1 vegur", 16 | "X-Request-Id": "4baca827-0052-462a-9995-c65680a1f10d" 17 | }, 18 | "json": { 19 | "spam": "eggs" 20 | }, 21 | "origin": "99.245.54.15", 22 | "url": "https://httpbin.org/put" 23 | } 24 | -------------------------------------------------------------------------------- /example/testing_data/c884f9c06bdfd2dac66e6af8e2e3c4c1: -------------------------------------------------------------------------------- 1 | { 2 | "args": {}, 3 | "data": "{\"foo\": \"bar\"}", 4 | "files": {}, 5 | "form": {}, 6 | "headers": { 7 | "Accept-Encoding": "identity", 8 | "Connect-Time": "2", 9 | "Connection": "close", 10 | "Content-Length": "14", 11 | "Content-Type": "application/json", 12 | "Host": "httpbin.org", 13 | "Total-Route-Time": "0", 14 | "User-Agent": "Python-urllib/2.7", 15 | "Via": "1.1 vegur", 16 | "X-Request-Id": "62f3b7d8-260c-4d6f-934e-40ecac8f9c6a" 17 | }, 18 | "json": { 19 | "foo": "bar" 20 | }, 21 | "origin": "99.245.54.15", 22 | "url": "https://httpbin.org/post" 23 | } 24 | -------------------------------------------------------------------------------- /example/testing_data/dbc78ad575723d20eb5469356ac19562: -------------------------------------------------------------------------------- 1 | { 2 | "args": {}, 3 | "data": "", 4 | "files": {}, 5 | "form": {}, 6 | "headers": { 7 | "Accept-Encoding": "identity", 8 | "Connect-Time": "1", 9 | "Connection": "close", 10 | "Content-Length": "0", 11 | "Host": "httpbin.org", 12 | "Total-Route-Time": "0", 13 | "User-Agent": "Python-urllib/2.7", 14 | "Via": "1.1 vegur", 15 | "X-Request-Id": "11347282-b40d-47a3-aeb0-e19ba6cc1817" 16 | }, 17 | "json": null, 18 | "origin": "99.245.54.15", 19 | "url": "https://httpbin.org/delete" 20 | } 21 | -------------------------------------------------------------------------------- /example/testing_data/f131cff22faf4cb6acd94098b42a9452: -------------------------------------------------------------------------------- 1 | { 2 | "args": {}, 3 | "data": "{\"hi\": \"hello\"}", 4 | "files": {}, 5 | "form": {}, 6 | "headers": { 7 | "Accept-Encoding": "identity", 8 | "Connect-Time": "1", 9 | "Connection": "close", 10 | "Content-Length": "15", 11 | "Content-Type": "application/json", 12 | "Host": "httpbin.org", 13 | "Total-Route-Time": "0", 14 | "User-Agent": "Python-urllib/2.7", 15 | "Via": "1.1 vegur", 16 | "X-Request-Id": "c400e705-56ce-400b-aac0-71a39dd309b0" 17 | }, 18 | "json": { 19 | "hi": "hello" 20 | }, 21 | "origin": "99.245.54.15", 22 | "url": "https://httpbin.org/patch" 23 | } 24 | -------------------------------------------------------------------------------- /example/testing_data/foo_bar_hash: -------------------------------------------------------------------------------- 1 | { 2 | "args": {}, 3 | "data": "{\"foo\": \"bar\"}", 4 | "files": {}, 5 | "form": {}, 6 | "headers": { 7 | "Accept-Encoding": "identity", 8 | "Chameleon-Request-Hash": "foo_bar_hash", 9 | "Content-Length": "14", 10 | "Content-Type": "application/json", 11 | "Host": "httpbin.org", 12 | "User-Agent": "Python-urllib/2.7", 13 | "X-Forwarded-Ssl": "on" 14 | }, 15 | "json": { 16 | "foo": "bar" 17 | }, 18 | "origin": "99.245.54.15", 19 | "url": "http://httpbin.org/post" 20 | } 21 | -------------------------------------------------------------------------------- /example/testing_data/spec.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "response": { 4 | "status_code": 200, 5 | "content": "451e5ecd5fd81e89cc52fb648f93fab7", 6 | "headers": { 7 | "Access-Control-Allow-Credentials": "true", 8 | "Access-Control-Allow-Origin": "*", 9 | "Content-Length": "0", 10 | "Content-Type": "text/html; charset=utf-8", 11 | "Date": "Mon, 12 Jan 2015 00:42:41 GMT", 12 | "Server": "gunicorn/18.0", 13 | "Via": "1.1 vegur" 14 | } 15 | }, 16 | "key": "451e5ecd5fd81e89cc52fb648f93fab7" 17 | }, 18 | { 19 | "response": { 20 | "status_code": 418, 21 | "content": "262076ab58b2423e21e681e7b710312c", 22 | "headers": { 23 | "Access-Control-Allow-Credentials": "true", 24 | "Access-Control-Allow-Origin": "*", 25 | "Content-Length": "135", 26 | "Date": "Mon, 12 Jan 2015 00:42:41 GMT", 27 | "Server": "gunicorn/18.0", 28 | "Via": "1.1 vegur", 29 | "X-More-Info": "http://tools.ietf.org/html/rfc2324" 30 | } 31 | }, 32 | "key": "262076ab58b2423e21e681e7b710312c" 33 | }, 34 | { 35 | "response": { 36 | "status_code": 500, 37 | "content": "536c0b3471dcf3f782d0956fa001570c", 38 | "headers": { 39 | "Access-Control-Allow-Credentials": "true", 40 | "Access-Control-Allow-Origin": "*", 41 | "Content-Length": "0", 42 | "Content-Type": "text/html; charset=utf-8", 43 | "Date": "Mon, 12 Jan 2015 00:42:41 GMT", 44 | "Server": "gunicorn/18.0", 45 | "Via": "1.1 vegur" 46 | } 47 | }, 48 | "key": "536c0b3471dcf3f782d0956fa001570c" 49 | }, 50 | { 51 | "response": { 52 | "status_code": 200, 53 | "content": "dbc78ad575723d20eb5469356ac19562", 54 | "headers": { 55 | "Access-Control-Allow-Credentials": "true", 56 | "Access-Control-Allow-Origin": "*", 57 | "Content-Length": "470", 58 | "Content-Type": "application/json", 59 | "Date": "Mon, 12 Jan 2015 00:42:42 GMT", 60 | "Server": "gunicorn/18.0", 61 | "Via": "1.1 vegur" 62 | } 63 | }, 64 | "key": "dbc78ad575723d20eb5469356ac19562" 65 | }, 66 | { 67 | "response": { 68 | "status_code": 200, 69 | "content": "f131cff22faf4cb6acd94098b42a9452", 70 | "headers": { 71 | "Access-Control-Allow-Credentials": "true", 72 | "Access-Control-Allow-Origin": "*", 73 | "Content-Length": "549", 74 | "Content-Type": "application/json", 75 | "Date": "Mon, 12 Jan 2015 00:42:42 GMT", 76 | "Server": "gunicorn/18.0", 77 | "Via": "1.1 vegur" 78 | } 79 | }, 80 | "key": "f131cff22faf4cb6acd94098b42a9452" 81 | }, 82 | { 83 | "response": { 84 | "status_code": 200, 85 | "content": "c884f9c06bdfd2dac66e6af8e2e3c4c1", 86 | "headers": { 87 | "Access-Control-Allow-Credentials": "true", 88 | "Access-Control-Allow-Origin": "*", 89 | "Content-Length": "546", 90 | "Content-Type": "application/json", 91 | "Date": "Mon, 12 Jan 2015 00:42:42 GMT", 92 | "Server": "gunicorn/18.0", 93 | "Via": "1.1 vegur" 94 | } 95 | }, 96 | "key": "c884f9c06bdfd2dac66e6af8e2e3c4c1" 97 | }, 98 | { 99 | "response": { 100 | "status_code": 200, 101 | "content": "9835adf25e3ecc09431cdf3079bb822a", 102 | "headers": { 103 | "Access-Control-Allow-Credentials": "true", 104 | "Access-Control-Allow-Origin": "*", 105 | "Content-Length": "549", 106 | "Content-Type": "application/json", 107 | "Date": "Mon, 12 Jan 2015 00:42:43 GMT", 108 | "Server": "gunicorn/18.0", 109 | "Via": "1.1 vegur" 110 | } 111 | }, 112 | "key": "9835adf25e3ecc09431cdf3079bb822a" 113 | }, 114 | { 115 | "response": { 116 | "status_code": 200, 117 | "content": "foo_bar_hash", 118 | "headers": { 119 | "Access-Control-Allow-Credentials": "true", 120 | "Access-Control-Allow-Origin": "*", 121 | "Content-Length": "452", 122 | "Content-Type": "application/json", 123 | "Date": "Sun, 18 Jan 2015 23:30:57 GMT", 124 | "Server": "nginx" 125 | } 126 | }, 127 | "key": "foo_bar_hash" 128 | } 129 | ] -------------------------------------------------------------------------------- /example/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import os 5 | import unittest 6 | import urllib2 7 | 8 | TEST_APP_PORT = int(os.getenv('TEST_APP_PORT', 9001)) 9 | TEST_CHAMELEON_PORT = int(os.getenv('TEST_CHAMELEON_PORT', 6005)) 10 | 11 | 12 | def get_name(code): 13 | url = 'http://localhost:{}/{}'.format(TEST_APP_PORT, code) 14 | try: 15 | resp = urllib2.urlopen(url) 16 | except urllib2.HTTPError as exc: 17 | resp = exc 18 | return resp.read() 19 | 20 | 21 | def preseed(url, method): 22 | payload = json.dumps({ 23 | 'Request': { 24 | 'URL': url, 25 | 'Method': method, 26 | 'Body': '', 27 | }, 28 | 'Response': { 29 | 'StatusCode': 942, 30 | 'Body': '{"key": "value"}', 31 | 'Headers': { 32 | 'Content-Type': 'application/json', 33 | } 34 | }, 35 | }) 36 | req = urllib2.Request('http://localhost:{}/_seed'.format(TEST_CHAMELEON_PORT), payload, {'Content-type': 'application/json'}) 37 | req.get_method = lambda: 'POST' 38 | resp = urllib2.urlopen(req) 39 | 40 | return resp 41 | 42 | 43 | class MyTest(unittest.TestCase): 44 | 45 | def test_200_returns_ok(self): 46 | self.assertEqual('OK', get_name(200)) 47 | 48 | def test_418_returns_teapot(self): 49 | self.assertEqual("I'M A TEAPOT", get_name(418)) 50 | 51 | def test_500_internal_server_error(self): 52 | self.assertEqual('INTERNAL SERVER ERROR', get_name(500)) 53 | 54 | def test_content_type_is_text_plain(self): 55 | url = 'http://localhost:{}/200'.format(TEST_APP_PORT) 56 | resp = urllib2.urlopen(url) 57 | self.assertEqual('text/plain', resp.headers['content-type']) 58 | 59 | def test_post_returns_post_body(self): 60 | url = 'http://localhost:{}/post'.format(TEST_APP_PORT) 61 | req = urllib2.Request(url, json.dumps({'foo': 'bar'}), {'Content-type': 'application/json'}) 62 | req.get_method = lambda: 'POST' 63 | resp = urllib2.urlopen(req) 64 | parsed = json.loads(resp.read()) 65 | self.assertEqual({'foo': 'bar'}, parsed['json']) 66 | 67 | # now with hashed 68 | url = 'http://localhost:{}/post_with_body'.format(TEST_APP_PORT) 69 | req = urllib2.Request(url, json.dumps({'post': 'body'}), {'Content-type': 'application/json'}) 70 | req.get_method = lambda: 'HASHED' 71 | resp = urllib2.urlopen(req) 72 | parsed = json.loads(resp.read()) 73 | self.assertEqual({'foo': 'bar'}, parsed['json']) 74 | 75 | def test_patch_returns_body(self): 76 | url = 'http://localhost:{}/patch'.format(TEST_APP_PORT) 77 | req = urllib2.Request(url, json.dumps({'hi': 'hello'}), {'Content-type': 'application/json'}) 78 | req.get_method = lambda: 'PATCH' 79 | resp = urllib2.urlopen(req) 80 | parsed = json.loads(resp.read()) 81 | self.assertEqual({'hi': 'hello'}, parsed['json']) 82 | 83 | def test_put_returns_body(self): 84 | url = 'http://localhost:{}/put'.format(TEST_APP_PORT) 85 | req = urllib2.Request(url, json.dumps({'spam': 'eggs'}), {'Content-type': 'application/json'}) 86 | req.get_method = lambda: 'PUT' 87 | resp = urllib2.urlopen(req) 88 | parsed = json.loads(resp.read()) 89 | self.assertEqual({'spam': 'eggs'}, parsed['json']) 90 | 91 | def test_delete_returns_200(self): 92 | url = 'http://localhost:{}/delete'.format(TEST_APP_PORT) 93 | req = urllib2.Request(url) 94 | req.get_method = lambda: 'DELETE' 95 | resp = urllib2.urlopen(req) 96 | self.assertEqual(200, resp.getcode()) 97 | 98 | def test_preseed(self): 99 | resp = preseed('/encoding/utf8', 'GET') # Preseed this URL and Method with some data 100 | self.assertIn(resp.getcode(), (200, 201)) 101 | url = 'http://localhost:{}/encoding/utf8'.format(TEST_APP_PORT) 102 | req = urllib2.Request(url) 103 | req.get_method = lambda: 'SEEDED' 104 | try: 105 | resp = urllib2.urlopen(req) 106 | except urllib2.HTTPError as exc: 107 | resp = exc 108 | self.assertEqual('application/json', resp.headers['content-type']) 109 | self.assertEqual(942, resp.getcode()) 110 | self.assertEqual({'key': 'value'}, json.loads(resp.read())) 111 | 112 | def test_specify_hash_in_request(self): 113 | url = 'http://localhost:{}/post'.format(TEST_APP_PORT) 114 | req = urllib2.Request(url, json.dumps({'foo': 'bar'}), { 115 | 'Content-type': 'application/json', 'chameleon-request-hash': 'foo_bar_hash'}) 116 | req.get_method = lambda: 'REQUESTHASH' 117 | resp = urllib2.urlopen(req) 118 | content = resp.read() 119 | parsed = json.loads(content) 120 | self.assertEqual({'foo': 'bar'}, parsed['json']) 121 | self.assertEqual('foo_bar_hash', resp.headers['chameleon-request-hash']) 122 | 123 | 124 | if __name__ == '__main__': 125 | unittest.main() 126 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/url" 12 | "strings" 13 | ) 14 | 15 | type preseedResponse struct { 16 | Request struct { 17 | Body string 18 | URL string 19 | Method string 20 | } 21 | Response struct { 22 | Body string 23 | StatusCode int 24 | Headers map[string]string 25 | } 26 | } 27 | 28 | // PreseedHandler preseeds a Cacher, according to a Hasher 29 | func PreseedHandler(cacher Cacher, hasher Hasher) http.HandlerFunc { 30 | return func(w http.ResponseWriter, r *http.Request) { 31 | dec := json.NewDecoder(r.Body) 32 | var preseedResp preseedResponse 33 | err := dec.Decode(&preseedResp) 34 | if err != nil { 35 | w.WriteHeader(500) 36 | fmt.Fprint(w, err) 37 | return 38 | } 39 | 40 | fakeReq, err := http.NewRequest( 41 | preseedResp.Request.Method, 42 | preseedResp.Request.URL, 43 | strings.NewReader(preseedResp.Request.Body), 44 | ) 45 | if err != nil { 46 | w.WriteHeader(500) 47 | fmt.Fprint(w, err) 48 | return 49 | } 50 | hash := hasher.Hash(fakeReq) 51 | response := cacher.Get(hash) 52 | 53 | w.Header().Add("chameleon-request-hash", hash) 54 | if response != nil { 55 | log.Printf("-> Proxying [preseeding;cached: %v] to %v\n", hash, preseedResp.Request.URL) 56 | w.WriteHeader(200) 57 | return 58 | } 59 | 60 | log.Printf("-> Proxying [preseeding;not cached: %v] to %v\n", hash, preseedResp.Request.URL) 61 | 62 | rec := httptest.NewRecorder() 63 | rec.Body = bytes.NewBufferString(preseedResp.Response.Body) 64 | rec.Code = preseedResp.Response.StatusCode 65 | for name, value := range preseedResp.Response.Headers { 66 | rec.Header().Set(name, value) 67 | } 68 | 69 | // Signal to the cacher to skip the disk 70 | rec.Header().Set("_chameleon-seeded-skip-disk", "true") 71 | 72 | // Don't need the response 73 | _ = cacher.Put(hash, rec) 74 | w.WriteHeader(201) 75 | } 76 | } 77 | 78 | // CachedProxyHandler proxies a given URL and stores/fetches content from a Cacher, according to a Hasher 79 | func CachedProxyHandler(serverURL *url.URL, cacher Cacher, hasher Hasher) http.HandlerFunc { 80 | parsedURL, err := url.Parse(serverURL.String()) 81 | if err != nil { 82 | panic(err) 83 | } 84 | 85 | return func(w http.ResponseWriter, r *http.Request) { 86 | // Change the host for the request for this configuration 87 | r.Host = parsedURL.Host 88 | r.URL.Host = r.Host 89 | r.URL.Scheme = parsedURL.Scheme 90 | r.RequestURI = "" 91 | 92 | hash := r.Header.Get("chameleon-request-hash") 93 | if hash == "" { 94 | hash = hasher.Hash(r) 95 | } 96 | response := cacher.Get(hash) 97 | 98 | if response != nil { 99 | log.Printf("-> Proxying [cached: %v] to %v\n", hash, r.URL) 100 | } else { 101 | // We don't have a cached response yet 102 | log.Printf("-> Proxying [not cached: %v] to %v\n", hash, r.URL) 103 | 104 | // Create a recorder, so we can get data out and modify it (if needed) 105 | rec := httptest.NewRecorder() 106 | ProxyHandler(rec, r) // Actually call our handler 107 | 108 | response = cacher.Put(hash, rec) 109 | } 110 | 111 | for k, v := range response.Headers { 112 | w.Header().Add(k, v) 113 | } 114 | w.Header().Add("chameleon-request-hash", hash) 115 | w.WriteHeader(response.StatusCode) 116 | // If this fails, there isn't much to do 117 | _, _ = io.Copy(w, bytes.NewReader(response.Body)) 118 | } 119 | } 120 | 121 | func copyHeaders(dst, src http.Header) { 122 | for k, vv := range src { 123 | for _, v := range vv { 124 | dst.Add(k, v) 125 | } 126 | } 127 | } 128 | 129 | // ProxyHandler implements a standard HTTP handler to proxy a given request and returns the response 130 | func ProxyHandler(w http.ResponseWriter, r *http.Request) { 131 | client := &http.Client{} 132 | resp, err := client.Do(r) 133 | if err != nil { 134 | http.Error(w, err.Error(), http.StatusInternalServerError) 135 | return 136 | } 137 | 138 | defer func() { 139 | // If this fails, there isn't much to do 140 | _ = resp.Body.Close() 141 | }() 142 | copyHeaders(w.Header(), resp.Header) 143 | w.WriteHeader(resp.StatusCode) 144 | // If this fails, there isn't much to do 145 | _, _ = io.Copy(w, resp.Body) // Proxy through 146 | } 147 | -------------------------------------------------------------------------------- /handlers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func init() { 16 | log.SetOutput(ioutil.Discard) 17 | } 18 | 19 | var fakeResp = &CachedResponse{ 20 | StatusCode: 418, 21 | Body: []byte("Hello, World!"), 22 | Headers: map[string]string{"Foo": "Bar", "chameleon-request-hash": "abcdef12345"}, 23 | } 24 | 25 | type mockCacher struct { 26 | data map[string]*CachedResponse 27 | } 28 | 29 | func (m mockCacher) Get(key string) *CachedResponse { 30 | return m.data[key] 31 | } 32 | 33 | func (m mockCacher) Put(key string, r *httptest.ResponseRecorder) *CachedResponse { 34 | specHeaders := make(map[string]string) 35 | for k, v := range r.Header() { 36 | specHeaders[k] = strings.Join(v, ", ") 37 | } 38 | 39 | m.data[key] = &CachedResponse{ 40 | StatusCode: r.Code, 41 | Body: r.Body.Bytes(), 42 | Headers: specHeaders, 43 | } 44 | return m.data[key] 45 | } 46 | 47 | func TestCachedProxyHandler(t *testing.T) { 48 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | w.Header().Set("Content-Type", "text/plain") 50 | w.Header().Set("Foo", fakeResp.Headers["Foo"]) 51 | w.WriteHeader(fakeResp.StatusCode) 52 | fmt.Fprintf(w, string(fakeResp.Body)) 53 | })) 54 | defer server.Close() 55 | 56 | serverURL, _ := url.Parse(server.URL) 57 | handler := CachedProxyHandler( 58 | serverURL, 59 | mockCacher{data: make(map[string]*CachedResponse)}, 60 | DefaultHasher{}, 61 | ) 62 | 63 | w := httptest.NewRecorder() 64 | 65 | q := serverURL.Query() 66 | q.Set("q", "golang") 67 | serverURL.RawQuery = q.Encode() 68 | serverURL.Path = "/search" 69 | req, _ := http.NewRequest("POST", serverURL.String(), strings.NewReader("POST BODY")) 70 | req.Header.Set("Sample", "Header") 71 | req.Header.Set("chameleon-request-hash", fakeResp.Headers["chameleon-request-hash"]) 72 | handler.ServeHTTP(w, req) 73 | 74 | // Check that the Proxy worked (response is the same as request) 75 | if w.Code != fakeResp.StatusCode { 76 | t.Errorf("Got: `%v`; Expected: `%v`", w.Code, fakeResp.StatusCode) 77 | } 78 | if w.Header().Get("Foo") != fakeResp.Headers["Foo"] { 79 | t.Errorf("Got: `%v`; Expected: `%v`", w.Header().Get("Foo"), fakeResp.Headers["Foo"]) 80 | } 81 | body, _ := ioutil.ReadAll(w.Body) 82 | if !bytes.Equal(body, fakeResp.Body) { 83 | t.Errorf("Got: `%v`; Expected: `%v`", string(body), string(fakeResp.Body)) 84 | } 85 | if w.Header().Get("chameleon-request-hash") == "" { 86 | t.Errorf("Hash was not returned with response.") 87 | } 88 | if w.Header().Get("chameleon-request-hash") != fakeResp.Headers["chameleon-request-hash"] { 89 | t.Errorf("Got: `%v`; Expected: `%v`", w.Header().Get("chameleon-request-hash"), fakeResp.Headers["chameleon-request-hash"]) 90 | } 91 | } 92 | 93 | func TestPreseedHandler(t *testing.T) { 94 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 95 | t.Error("Should not have hit the server. Response was preseeded") 96 | })) 97 | defer server.Close() 98 | 99 | serverURL, _ := url.Parse(server.URL) 100 | cache := mockCacher{data: make(map[string]*CachedResponse)} 101 | cachedProxyHandler := CachedProxyHandler( 102 | serverURL, 103 | cache, 104 | DefaultHasher{}, 105 | ) 106 | preseedHandler := PreseedHandler( 107 | cache, 108 | DefaultHasher{}, 109 | ) 110 | 111 | // Seed /foobar 112 | serverURL.Path = "/_seed" 113 | req, _ := http.NewRequest("POST", serverURL.String(), strings.NewReader( 114 | `{ 115 | "Request": { 116 | "URL": "/foobar", 117 | "Method": "GET", 118 | "Body": "" 119 | }, 120 | "Response": { 121 | "Body": "FOOBAR BODY", 122 | "StatusCode": 942, 123 | "Headers": { 124 | "Content-Type": "application/json" 125 | } 126 | } 127 | }`, 128 | )) 129 | w := httptest.NewRecorder() 130 | preseedHandler.ServeHTTP(w, req) 131 | 132 | if w.Code != 201 { 133 | t.Errorf("Got: `%v`; Expected: `201`; Error was `%v`", w.Code, w.Body.String()) 134 | } 135 | 136 | serverURL.Path = "/foobar" 137 | req, _ = http.NewRequest("GET", serverURL.String(), nil) 138 | w = httptest.NewRecorder() 139 | cachedProxyHandler.ServeHTTP(w, req) 140 | 141 | if w.Body.String() != "FOOBAR BODY" { 142 | t.Errorf("Got: `%v`; Expected: `FOOBAR BODY`", w.Body.String()) 143 | } 144 | 145 | if w.Code != 942 { 146 | t.Errorf("Got: `%v`; Expected: `942`", w.Code) 147 | } 148 | if w.Header().Get("Content-Type") != "application/json" { 149 | t.Errorf("Got: `%v`; Expected: `application/json`", w.Header().Get("Content-Type")) 150 | } 151 | if w.Header().Get("chameleon-request-hash") == "" { 152 | t.Errorf("Hash was not returned with response.") 153 | } 154 | } 155 | 156 | func TestPreseedHandlerWithRequestBody(t *testing.T) { 157 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 158 | body, _ := ioutil.ReadAll(r.Body) 159 | if string(body) != `{"post":"body"}` { 160 | t.Errorf("Got: `%v`; Expected `{\"post\":\"body\"}`", string(body)) 161 | w.WriteHeader(500) 162 | return 163 | } 164 | w.WriteHeader(200) 165 | })) 166 | defer server.Close() 167 | 168 | serverURL, _ := url.Parse(server.URL) 169 | cache := mockCacher{data: make(map[string]*CachedResponse)} 170 | cachedProxyHandler := CachedProxyHandler( 171 | serverURL, 172 | cache, 173 | DefaultHasher{}, 174 | ) 175 | preseedHandler := PreseedHandler( 176 | cache, 177 | DefaultHasher{}, 178 | ) 179 | 180 | // Seed /foobar 181 | serverURL.Path = "/_seed" 182 | req, _ := http.NewRequest("POST", serverURL.String(), strings.NewReader( 183 | `{ 184 | "Request": { 185 | "URL": "/foobar", 186 | "Method": "POST", 187 | "Body": "{\"foo\":\"bar\"}" 188 | }, 189 | "Response": { 190 | "Body": "FOOBAR BODY", 191 | "StatusCode": 942, 192 | "Headers": { 193 | "Content-Type": "application/json" 194 | } 195 | } 196 | }`, 197 | )) 198 | w := httptest.NewRecorder() 199 | preseedHandler.ServeHTTP(w, req) 200 | 201 | serverURL.Path = "/foobar" 202 | req, _ = http.NewRequest("POST", serverURL.String(), strings.NewReader(`{"foo":"bar"}`)) 203 | w = httptest.NewRecorder() 204 | cachedProxyHandler.ServeHTTP(w, req) 205 | 206 | req, _ = http.NewRequest("POST", serverURL.String(), strings.NewReader(`{"post":"body"}`)) 207 | w = httptest.NewRecorder() 208 | cachedProxyHandler.ServeHTTP(w, req) 209 | 210 | if w.Code != 200 { 211 | t.Errorf("Server wasn't hit with the correct body") 212 | } 213 | if w.Header().Get("chameleon-request-hash") == "" { 214 | t.Errorf("Hash was not returned with response.") 215 | } 216 | } 217 | 218 | func TestPreseedHandlerBadJSON(t *testing.T) { 219 | preseedHandler := PreseedHandler( 220 | mockCacher{}, 221 | DefaultHasher{}, 222 | ) 223 | 224 | req, _ := http.NewRequest("POST", "/_seed", strings.NewReader("BAD JSON")) 225 | w := httptest.NewRecorder() 226 | preseedHandler.ServeHTTP(w, req) 227 | 228 | if w.Code != 500 { 229 | t.Errorf("Got: `%v`; Expected: `500`", w.Code) 230 | } 231 | if w.Header().Get("chameleon-request-hash") != "" { 232 | t.Errorf("Hash was returned for bad json.") 233 | } 234 | } 235 | 236 | func TestPreseedHandlerCachesDuplicateRequest(t *testing.T) { 237 | preseedHandler := PreseedHandler( 238 | mockCacher{data: make(map[string]*CachedResponse)}, 239 | DefaultHasher{}, 240 | ) 241 | 242 | payload := `{ 243 | "Request": { 244 | "URL": "/foobar", 245 | "Method": "GET", 246 | "Body": "" 247 | }, 248 | "Response": { 249 | "Body": "FOOBAR BODY", 250 | "StatusCode": 942, 251 | "Headers": { 252 | "Content-Type": "application/json" 253 | } 254 | } 255 | }` 256 | 257 | req, _ := http.NewRequest("POST", "/_seed", strings.NewReader(payload)) 258 | w := httptest.NewRecorder() 259 | preseedHandler.ServeHTTP(w, req) 260 | 261 | if w.Code != 201 { 262 | t.Errorf("Got: `%v`; Expected: `201`", w.Code) 263 | } 264 | 265 | req, _ = http.NewRequest("POST", "/_seed", strings.NewReader(payload)) 266 | w = httptest.NewRecorder() 267 | preseedHandler.ServeHTTP(w, req) 268 | 269 | if w.Code != 200 { 270 | t.Errorf("Got: `%v`; Expected: `200`", w.Code) 271 | } 272 | if w.Header().Get("chameleon-request-hash") == "" { 273 | t.Errorf("Hash was not returned with response.") 274 | } 275 | } 276 | 277 | func TestPreseedHandlerBadURL(t *testing.T) { 278 | preseedHandler := PreseedHandler( 279 | mockCacher{}, 280 | DefaultHasher{}, 281 | ) 282 | 283 | payload := `{ 284 | "Request": { 285 | "URL": "%&%", 286 | "Method": "GET", 287 | "Body": "" 288 | }, 289 | "Response": { 290 | "Body": "FOOBAR BODY", 291 | "StatusCode": 942, 292 | "Headers": { 293 | "Content-Type": "application/json" 294 | } 295 | } 296 | }` 297 | 298 | req, _ := http.NewRequest("POST", "/_seed", strings.NewReader(payload)) 299 | w := httptest.NewRecorder() 300 | preseedHandler.ServeHTTP(w, req) 301 | 302 | if w.Code != 500 { 303 | t.Errorf("Got: `%v`; Expected: `500`", w.Code) 304 | } 305 | if w.Header().Get("chameleon-request-hash") != "" { 306 | t.Errorf("Hash was returned for bad url.") 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /hash.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "encoding/json" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "net/http" 12 | "os/exec" 13 | "strings" 14 | ) 15 | 16 | // Request embeds an *http.Request to support custom JSON encoding. 17 | type request struct { 18 | *http.Request 19 | } 20 | 21 | type requestURL struct { 22 | Host string 23 | Path string 24 | RawQuery string 25 | Scheme string 26 | } 27 | 28 | type serializedRequest struct { 29 | BodyBase64 []byte 30 | ContentLength int64 31 | Headers http.Header 32 | Method string 33 | URL requestURL 34 | } 35 | 36 | // A Hasher interface is used to generate a key for a given request. 37 | type Hasher interface { 38 | Hash(r *http.Request) string 39 | } 40 | 41 | // DefaultHasher is the default implementation of a Hasher 42 | type DefaultHasher struct { 43 | } 44 | 45 | // Hash returns a hash for a given request. 46 | // The default behavior is to hash the URL, request method and body 47 | // but if the header 'chameleon-no-hash-body' exists, the body 48 | // will not be included in the hash. 49 | func (k DefaultHasher) Hash(r *http.Request) string { 50 | hasher := md5.New() 51 | hash := r.URL.RequestURI() + r.Method 52 | // This method always succeeds 53 | _, _ = hasher.Write([]byte(hash)) 54 | 55 | if r.Body != nil && r.Header.Get("chameleon-no-hash-body") == "" { 56 | var buf bytes.Buffer 57 | _, err := buf.ReadFrom(r.Body) 58 | if err != nil { 59 | panic(err) 60 | } 61 | bufBytes := buf.Bytes() 62 | 63 | _, err = io.Copy(hasher, bytes.NewReader(bufBytes)) 64 | if err != nil { 65 | panic(err) 66 | } 67 | // Put the body back on the request so it can read again 68 | r.Body = ioutil.NopCloser(bytes.NewReader(bufBytes)) 69 | } 70 | 71 | return hex.EncodeToString(hasher.Sum(nil)) 72 | } 73 | 74 | // A Commander interface is used to run shell commands. 75 | type Commander interface { 76 | NewCmd(string, io.Writer, io.Reader) *exec.Cmd 77 | Run(*exec.Cmd) ([]byte, error) 78 | } 79 | 80 | // DefaultCommander is a default implementation of the Commander interface 81 | type DefaultCommander struct { 82 | } 83 | 84 | // NewCmd creates a new instance of an *exec.Cmd 85 | func (c DefaultCommander) NewCmd(command string, stderr io.Writer, stdin io.Reader) *exec.Cmd { 86 | cmd := exec.Command("sh", "-c", command) 87 | if stderr != nil { 88 | cmd.Stderr = stderr 89 | } 90 | if stdin != nil { 91 | cmd.Stdin = stdin 92 | } 93 | return cmd 94 | } 95 | 96 | // Run executes cmd with option STDIN 97 | func (c DefaultCommander) Run(cmd *exec.Cmd) ([]byte, error) { 98 | out, err := cmd.Output() 99 | defer func() { 100 | // If this fails, there isn't much to do 101 | _ = cmd.Process.Kill() 102 | }() 103 | 104 | return out, err 105 | } 106 | 107 | // CmdHasher is an implementation of a Hasher which uses other commands to generate a hash via STDIN/STDOUT. 108 | type CmdHasher struct { 109 | Commander 110 | Command string 111 | } 112 | 113 | // MarshalJSON returns a JSON representation of a Request. 114 | // This differs from using the built-in JSON Marshal on an *http.Request 115 | // by embedding the body (base64 encoded), and removing fields that 116 | // aren't important. 117 | func (r *request) MarshalJSON() ([]byte, error) { 118 | var body bytes.Buffer 119 | var bodyBytes []byte 120 | if r.Body != nil { 121 | _, err := body.ReadFrom(r.Body) 122 | if err != nil { 123 | return nil, err 124 | } 125 | bodyBytes = body.Bytes() 126 | r.Body = ioutil.NopCloser(bytes.NewReader(bodyBytes)) 127 | } 128 | 129 | return json.Marshal(serializedRequest{ 130 | BodyBase64: bodyBytes, 131 | ContentLength: r.ContentLength, 132 | Headers: r.Header, 133 | Method: r.Method, 134 | URL: requestURL{ 135 | Host: r.URL.Host, 136 | Path: r.URL.Path, 137 | RawQuery: r.URL.RawQuery, 138 | Scheme: r.URL.Scheme, 139 | }, 140 | }) 141 | } 142 | 143 | // Hash returns a hash for a given request. 144 | // This implementation defers to an external command for a hash and communicates via STDIN/STDOUT. 145 | func (k CmdHasher) Hash(r *http.Request) string { 146 | 147 | encodedReq, err := json.Marshal(&request{r}) 148 | if err != nil { 149 | panic(err) 150 | } 151 | stdin := strings.NewReader(string(encodedReq)) 152 | 153 | var stderr bytes.Buffer 154 | cmd := k.NewCmd(k.Command, &stderr, stdin) 155 | out, err := k.Run(cmd) 156 | 157 | if err != nil { 158 | log.Printf("%v:\nSTDOUT:\n%v\n\nSTDERR:\n%v", err, string(out), stderr.String()) 159 | panic(err) 160 | } 161 | 162 | hasher := md5.New() 163 | // This method always succeeds 164 | _, _ = hasher.Write(out) 165 | return hex.EncodeToString(hasher.Sum(nil)) 166 | } 167 | -------------------------------------------------------------------------------- /hash_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "io" 8 | "net/http" 9 | "os/exec" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | type testCommander struct { 15 | DefaultCommander 16 | stdin *bytes.Buffer 17 | } 18 | 19 | func (c testCommander) NewCmd(command string, stderr io.Writer, stdin io.Reader) *exec.Cmd { 20 | cmd := c.DefaultCommander.NewCmd(command, stderr, stdin) 21 | // Copy the STDIN sent to "command" to our bytes.Buffer for inspection later 22 | cmd.Stdin = io.TeeReader(cmd.Stdin, c.stdin) 23 | return cmd 24 | } 25 | 26 | func TestDefaultHasherExcludesBody(t *testing.T) { 27 | hasher := DefaultHasher{} 28 | 29 | body := "HASH THIS BODY" 30 | req, _ := http.NewRequest("POST", "/foobar", strings.NewReader(body)) 31 | req.Header.Set("chameleon-no-hash-body", "true") 32 | hash := hasher.Hash(req) 33 | 34 | md5Hasher := md5.New() 35 | md5Hasher.Write([]byte(req.URL.RequestURI() + req.Method)) 36 | expected := hex.EncodeToString(md5Hasher.Sum(nil)) 37 | if hash != expected { 38 | t.Errorf("Got: `%v`; Expected: `%v`", hash, expected) 39 | } 40 | } 41 | 42 | func TestDefaultHasherIncludesBody(t *testing.T) { 43 | hasher := DefaultHasher{} 44 | 45 | body := "HASH THIS BODY" 46 | reqWithHeader, _ := http.NewRequest("POST", "/foobar", strings.NewReader(body)) 47 | reqWithHeader.Header.Set("chameleon-hash-body", "true") 48 | reqWithoutHeader, _ := http.NewRequest("POST", "/foobar", strings.NewReader(body)) 49 | withHeader := hasher.Hash(reqWithHeader) 50 | withoutHeader := hasher.Hash(reqWithoutHeader) 51 | 52 | if withoutHeader != withHeader { 53 | t.Errorf("Request hashes do not match: `%v` != `%v`", withoutHeader, withHeader) 54 | } 55 | } 56 | 57 | func TestCmdHasher(t *testing.T) { 58 | var stdin bytes.Buffer 59 | hasher := CmdHasher{Command: "/bin/cat", Commander: testCommander{stdin: &stdin}} 60 | req, _ := http.NewRequest("POST", "/foobar", strings.NewReader("HASH THIS BODY")) 61 | req.Header.Set("chameleon-hash-body", "true") 62 | hash := hasher.Hash(req) 63 | 64 | md5Hasher := md5.New() 65 | // our command just echoes back what we gave it, so all of stdin should be included in the hash 66 | md5Hasher.Write(stdin.Bytes()) 67 | expected := hex.EncodeToString(md5Hasher.Sum(nil)) 68 | if hash != expected { 69 | t.Errorf("Got: `%v`; Expected: `%v`", hash, expected) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // +build !testing 2 | 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "runtime" 13 | ) 14 | 15 | var ( 16 | proxiedURL = flag.String("url", "", "Fully qualified, absolute URL to proxy (e.g. https://example.com)") 17 | dataDir = flag.String("data", "", "Path to a directory in which to hold the responses for this url") 18 | host = flag.String("host", "localhost:6005", "Host/port on which to bind") 19 | cHasher = flag.String("hasher", "", "Custom hasher program for all requests (e.g. python ./hasher.py)") 20 | verbose = flag.Bool("verbose", false, "Turn on verbose logging") 21 | ) 22 | 23 | func main() { 24 | flag.Parse() 25 | if *proxiedURL == "" || *dataDir == "" { 26 | flag.Usage() 27 | os.Exit(-1) 28 | } 29 | 30 | serverURL, err := url.Parse(*proxiedURL) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | if !*verbose { 36 | log.SetOutput(ioutil.Discard) 37 | } 38 | 39 | runtime.GOMAXPROCS(runtime.NumCPU()) 40 | 41 | log.Printf("Starting proxy for '%v' on %v\n", serverURL.String(), *host) 42 | var hasher Hasher 43 | if *cHasher != "" { 44 | hasher = CmdHasher{Command: *cHasher, Commander: DefaultCommander{}} 45 | } else { 46 | hasher = DefaultHasher{} 47 | } 48 | cacher := NewDiskCacher(*dataDir) 49 | cacher.SeedCache() 50 | mux := http.NewServeMux() 51 | mux.Handle("/_seed", PreseedHandler(cacher, hasher)) 52 | mux.Handle("/", CachedProxyHandler(serverURL, cacher, hasher)) 53 | log.Fatal(http.ListenAndServe(*host, mux)) 54 | } 55 | --------------------------------------------------------------------------------