├── .circleci └── config.yml ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── c.yml ├── cmd ├── main.go └── zap │ ├── config.go │ ├── config_test.go │ ├── structs.go │ ├── text.go │ ├── text_test.go │ ├── web.go │ └── web_test.go ├── e2e.sh ├── go.mod ├── go.sum ├── goreleaser.yml └── zap_demo.gif /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Golang CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-go/ for more details 4 | docker-image: &docker-image cimg/go:1.20 5 | version: 2 6 | jobs: 7 | lint: 8 | docker: 9 | - image: *docker-image 10 | steps: 11 | - checkout 12 | - run: diff -u <(echo -n) <(go fmt $(go list ./...)) 13 | - run: go vet $(go list ./...) 14 | - run: go test -short -v ./... -race -coverprofile=coverage.txt -covermode=atomic ./cmd 15 | - run: go get -v -t -d ./... 16 | build_and_test: 17 | docker: 18 | # specify the version 19 | - image: *docker-image 20 | 21 | steps: 22 | - checkout 23 | - run: go build -o zap -v ./cmd/ 24 | - run: go test -short -v ./... -race -coverprofile=coverage.txt -covermode=atomic ./cmd 25 | - run: go test -v ./... 26 | - run: ./e2e.sh 27 | release: 28 | docker: 29 | - image: *docker-image 30 | steps: 31 | - checkout 32 | - run: curl -sfL https://goreleaser.com/static/run | bash 33 | 34 | workflows: 35 | version: 2 36 | build_and_test: 37 | jobs: 38 | - lint 39 | - build_and_test 40 | main: 41 | jobs: 42 | - release: 43 | # Only run this job on git tag pushes 44 | filters: 45 | branches: 46 | ignore: /.*/ 47 | tags: 48 | only: /v[0-9]+(\.[0-9]+)*(-.*)*/ 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in Zap. 4 | 5 | --- 6 | 7 | ## Basic Information 8 | 9 | - OS: 10 | - Browser: 11 | - Zap version: 12 | - Any software that might interfere (VPN, DNS, etc) 13 | - 14 | 15 | 16 | ## Describe the bug 17 | 18 | What's wrong? 19 | 20 | ## Steps to reproduce the behavior: 21 | 22 | 1. Step 1 23 | 2. Step 2 24 | 25 | ## Expected behavior 26 | 27 | A clear and concise description of what you expected to happen. 28 | 29 | ## Logs 30 | 31 | If applicable, add screenshots to help explain your problem. 32 | 33 | 34 | ## Additional context 35 | 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # goreleaser 2 | dist/ 3 | 4 | # OS 5 | *.swp 6 | 7 | # compiled files 8 | zap 9 | 10 | # Include our actual code, which is excluded by the "zap" directive above. 11 | !/cmd/zap 12 | 13 | # Goland 14 | .idea/* 15 | 16 | # coverage reports 17 | coverage.txt 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to Zap 2 | 3 | Patches are welcome! Please use the standard GitHub workflow - fork this 4 | repo and submit a PR. I'll usually get to it within a few days. If I miss your PR email, feel free to ping me directly at isgsmirnov@gmail.com after about a week. 5 | 6 | ## Setting up dev environment 7 | 8 | - Install [Goland](https://www.jetbrains.com/go/), [Atom](https://atom.io/), 9 | or your favorite web editor with Golang support. 10 | - Note: This project relies on Go Modules, introduces in Go 1.11+. 11 | 12 | ``` 13 | git clone $your_fork zap 14 | cd zap 15 | go get 16 | go build -o zap -v ./cmd/ # sanity check 17 | 18 | # install test deps and run all tests 19 | go test -short -v ./... -race -coverprofile=coverage.txt -covermode=atomic ./cmd 20 | ./e2e.sh 21 | ``` 22 | 23 | ## Handy commands for local development: 24 | 25 | - `go build -o zap -v ./cmd/ && ./zap` to run locally 26 | - `curl -I -L -H 'Host: g' localhost:8927/z` - to test locally e2e 27 | - `goconvey -excludedDirs dist` - launches web UI for go tests. 28 | - `./e2e.sh` runs CLI tests. 29 | 30 | 31 | ## Contributors 32 | 33 | - [Ivan Smirnov](http://ivansmirnov.name) 34 | - [Sergey Smirnov](https://smirnov.nyc/) 35 | - [Chris Egerton](https://github.com/C0urante) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 Ivan Smirnov 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zap 2 | 3 | [![CircleCI](https://circleci.com/gh/issmirnov/zap.svg?style=svg)](https://circleci.com/gh/issmirnov/zap) 4 | [![Release](https://img.shields.io/github/release/issmirnov/zap.svg?style=flat-square)](https://github.com/issmirnov/zap/releases/latest) 5 | ![Total Downloads](https://img.shields.io/github/downloads/issmirnov/zap/total.svg) 6 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/issmirnov/zap?style=flat-square)](https://goreportcard.com/report/github.com/issmirnov/zap) 8 | [![Powered By: GoReleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=flat-square)](https://github.com/goreleaser) 9 | 10 | Zap is a powerful tool that allows you to define universal web shortcuts in a simple config file. Faster than bookmarks, and works in any app! 11 | 12 | ![zap demo gif](zap_demo.gif) 13 | 14 | ## Overview 15 | 16 | ZAP is fast golang app that sends 302 redirects. It's insanely fast, maxing out at over 150k qps. It helps people be more efficient by providing simple shortcuts for common pages. 17 | 18 | It can help save keystrokes on any level of the URL. In the example above, the user types `gh/z` and zap expands `gh` into `github.com` and `z` into `issmirnov/zap`. There is no limit to how deep you can go. Zap can be useful for shortening common paths. If your or your company has many projects at `company.com/long/and/annoying/path/name_here` zap can turn this into `c/name_here`, or `c/p/name_here` - it's all in your hands. 19 | 20 | Zap runs as an HTTP service, and can live on the standard web ports or behind a proxy. It features hot reloading of the config, super low memory footprint and amazing durability under heavy loads. 21 | 22 | ## Installation 23 | 24 | ### Ansible 25 | 26 | If you know how to use ansible, head over to the [ansible galaxy](https://galaxy.ansible.com/issmirnov/zap/) and install this role as `issmirnov.zap`. I've done the heavy lifting for you. If you want to do this by hand, read on. 27 | 28 | ### OSX: brew install 29 | 30 | 1. `brew install issmirnov/apps/zap` 31 | 2. `sudo brew services start zap` 32 | 3. Add shortcuts to `/usr/local/etc/zap/c.yml` 33 | 3. Enjoy! 34 | 35 | If you already have port 80 in use, you can run zap behind a reverse proxy. 36 | 37 | 1. Change the port in the zap plist config: `sed -i '' 's/8927/80/'  /usr/local/Cellar/zap/*/homebrew.mxcl.zap.plist` 38 | 2. Start zap as user service: `brew services start zap` 39 | 3. Configure your web server to act as a reverse proxy. Here's an example for nginx: 40 | 41 | ```nginx 42 | # File: /usr/local/etc/nginx/servers/zap.conf 43 | server { 44 | listen 80; # Keep as 80, make sure nginx listens on 80 too 45 | server_name e g; # Put all of your top level shortcuts here - so just the first children in the config. 46 | location / { 47 | proxy_http_version 1.1; 48 | proxy_pass http://localhost:8927; 49 | proxy_set_header X-Forwarded-Host $host; 50 | #proxy_set_header Host $host; 51 | } 52 | } 53 | ``` 54 | 55 | Restart your web server and test the result: `curl -I -L -H 'Host: g' localhost/z` 56 | 57 | ### Ubuntu 58 | 59 | Note: This section applies to systemd installations only (Ubuntu, Redhat, Fedora). If you are running ubuntu 14.10 or below, you'll have to use [upstart](https://www.digitalocean.com/community/tutorials/the-upstart-event-system-what-it-is-and-how-to-use-it). 60 | 61 | 1. Add the zap user: `sudo adduser --system --no-create-home --group zap` 62 | 63 | 2. Create the service definition at `/etc/systemd/system/zap.service`. If you are running zap behind a web server, use the following config: 64 | 65 | ```ini 66 | [Unit] 67 | Description=Zap (URL text expander) 68 | After=syslog.target 69 | After=network.target 70 | 71 | [Service] 72 | Type=simple 73 | User=zap 74 | Group=zap 75 | WorkingDirectory=/etc/zap 76 | ExecStart=/usr/local/bin/zap -port 8927 -config c.yml 77 | Restart=always 78 | RestartSec=2s 79 | 80 | [Install] 81 | WantedBy=multi-user.target 82 | ``` 83 | 84 | If you are running standalone: 85 | ```ini 86 | [Unit] 87 | Description=Zap (URL text expander) 88 | After=syslog.target 89 | After=network.target 90 | 91 | [Service] 92 | Type=simple 93 | User=root 94 | Group=zap 95 | WorkingDirectory=/etc/zap 96 | ExecStart=/usr/local/bin/zap -port 80 -config c.yml 97 | Restart=always 98 | RestartSec=2s 99 | 100 | [Install] 101 | WantedBy=multi-user.target 102 | ``` 103 | 104 | You'll notice the difference is that we have to run as `root` in order to bind to port 80. *If you know of a way to launch a go app under setuid and then drop privileges, please send a PR* 105 | 106 | 3. Start your new service: `sudo systemctl start zap` and make sure it's running: `sudo systemctl status zap` 107 | 108 | 109 | ### Configuration 110 | 111 | The config file is located at `/usr/local/etc/zap/c.yml` on OSX. For ubuntu, you will have to create `/etc/zap/c.yml` by hand. 112 | 113 | Open up `c.yml` and update the mappings you would like. You can nest arbitrarily deep. Expansions work on strings and ints. Notice that we have three reserved keywords available: `expand`, `query`, and `port`. 114 | 115 | - `expand` - takes a short token and expands it to the specified string. Turns `z` into `zap/`, 116 | - `query` - acts almost like the `expand` option, but drops the separating slash between query expansion and search term (`example.com?q=foo` instead of `example.com?q=/foo`). 117 | - `port` - only valid as the first child under a host. Takes an int and appends it as `:$INT` to the host defined. See the usage in the [sample config](c.yml) 118 | 119 | Additionally, you can use `"*"` to capture a path element that should be retained as-is while also allowing for expansion of later elements to take place. 120 | 121 | Important gotcha: yaml has [reserved types](http://yaml.org/type/bool.html) and thus `n`, `y`, `no` and the like need to be quoted. See the sample config. 122 | 123 | When you add a new shortcut, you need to indicate to your web browser that it's not a search term. You can do this by typing it in once with just a slash. For example, if you add a shortcut `g/z` -> `github.com/issmirnov/zap`, if you try `g/z` right away you will get taken to the search page. Instead, try `g/` once, and then `g/z`. This initial step only needs to be taken once per new shortcut. 124 | 125 | Zap supports hot reloading, so simply save the file when you are done and test out your new shortcut. Note: If the shortcut does not work, make sure your YAML is correct and that zap is not printing any errors. You can test this by stopping zap and starting it manually - it should print any issues to stdout. You can also view the parsed config with `curl localhost:$ZAP_PORT/varz` - this will print the JSON representation of the config. If you see unexpected values, check your [YAML syntax](https://learnxinyminutes.com/docs/yaml/). 126 | 127 | For the advanced users: remember to reload your webserver and `dnsmasq`, depending on your setup. 128 | 129 | #### Examples 130 | 131 | You can configure your `c.yml` file endlessly. Here are some examples to get inspire your creativity: 132 | 133 | ```yaml 134 | e: 135 | expand: example.com 136 | ssl_off: yes 137 | f: 138 | expand: facebook.com 139 | g: 140 | expand: groups 141 | s: 142 | expand: BerkeleyFreeAndForSale 143 | p: 144 | expand: "2204685680" 145 | php: 146 | expand: groups/2204685680/ 147 | g: 148 | expand: github.com 149 | d: 150 | expand: issmirnov/dotfiles 151 | s: 152 | query: search?q= 153 | ak: 154 | expand: apache/kafka 155 | z: 156 | expand: issmirnov/zap 157 | r: 158 | expand: reddit.com/r 159 | l: 160 | expand: localhost 161 | ssl_off: yes 162 | p: 163 | port: 8080 164 | "n": 165 | port: 9001 166 | ak: 167 | expand: kafka.apache.org 168 | hi: 169 | expand: contact 170 | "*": 171 | j: 172 | expand: javadoc/index.html?overview-summary.html 173 | d: 174 | expand: documentation.html 175 | ``` 176 | 177 | With this config, you can use the following queries: 178 | 179 | - `g/z` -> github.com/issmirnov/zap 180 | - `f/zuck` -> facebook.com/zuck 181 | - `f/php` -> facebook.com/groups/2204685680/ 182 | - `r/catsstandingup` -> reddit.com/r/catsstandingup 183 | - `ak/hi` -> kafka.apache.org/contact 184 | - `ak/23/j` -> kafka.apache.org/23/javadoc/index.html?overview-summary.html 185 | 186 | ### Troubleshooting 187 | 188 | - If you zap doesn't appear to be running, try `sudo brew services restart zap`. If you are running the standalone version you need sudo access for port 80. 189 | - If you are using Safari, you will get Google searches instead. If you know of a workaround, please let me know. 190 | 191 | ### Additional Information 192 | 193 | 194 | #### Zap flags: 195 | 196 | - `-config` - path to config file. Default is `./c.yml` 197 | - `-port` - port to bind to. Default is 8927. Use 80 in standalone mode. 198 | - `-host` - default is 127.0.0.1. Use 0.0.0.0 for a public server. 199 | - `-advertise` - which address to use when populating `/etc/hosts`. 200 | This is useful when running zap behind `dnsmasq`, so that the host bind and advertised address can differ. 201 | 202 | 203 | ### DNS management via /etc/hosts 204 | 205 | Zap will attempt to keep the `/etc/hosts` file in sync with the configuration specified. This is assumed to be a reasonable default. If you wish to disable this behavior, run zap under a user that does not have write permissions to that file. 206 | 207 | As long as you don't touch the delimiters used by zap (`### Zap Shortcuts :start ##` and `### Zap Shortcuts :end ##`) you can edit the hosts file as you wish. If those delimiters are missing, zap will append them to the file. You shouldn't have problems if you manage your hosts file in a reasonable manner. 208 | 209 | For the advanced users running zap on a server on an internal network, I suggest looking into `dnsmasqd` - this will allow all your clients to utilize these shortcuts globally. 210 | 211 | 212 | ## Benchmarks 213 | 214 | Benchmarked with [wrk2](https://github.com/giltene/wrk2) on Ubuntu 16.04 using an i5 4590 CPU. 215 | ```bash 216 | # Maxing out QPS. 217 | $ wrk -t2 -c10 -d30s -R500000 http://127.0.0.1:8989/h 218 | Thread Stats Avg Stdev Max +/- Stdev 219 | Latency 3.40s 1.96s 6.79s 57.82% 220 | Req/Sec 80.22k 320.00 80.57k 50.00% 221 | Requests/sec: 161077.54 222 | 223 | # Getting max users while longest request under 15ms 224 | $ ./wrk -t2 -c10 -d20s -R120000 http://127.0.0.1:8989/h 225 | Thread Stats Avg Stdev Max +/- Stdev 226 | Latency 1.15ms 0.93ms 14.38ms 81.79% 227 | Req/Sec 63.24k 7.18k 110.89k 76.92% 228 | Requests/sec: 119932.12 229 | 230 | ``` 231 | As you can see, zap peaks at around ~160k qps, and can sustain ~120k qps with an average response under 15ms. 232 | 233 | Note: The config used was: 234 | 235 | ```yaml 236 | e: 237 | expand: example.com 238 | a: 239 | expand: apples 240 | b: 241 | expand: bananas 242 | g: 243 | expand: github.com 244 | d: 245 | expand: issmirnov/dotfiles 246 | s: 247 | query: search?q= 248 | z: 249 | expand: issmirnov/zap 250 | '127.0.0.1:8989': 251 | expand: '127.0.0.1:8989' 252 | h: 253 | expand: healthz 254 | ``` 255 | 256 | ## Contributing 257 | 258 | See [CONTRIBUTING.md](CONTRIBUTING.md) 259 | 260 | ## Contributors 261 | 262 | - [Ivan Smirnov](http://ivansmirnov.name) 263 | - [Chris Egerton](https://github.com/C0urante) 264 | -------------------------------------------------------------------------------- /c.yml: -------------------------------------------------------------------------------- 1 | # Configuration file for https://github.com/issmirnov/zap 2 | # 3 | # NOTE: YAML has special keywords that do funky things. 4 | # When in doubt, use quotes on strings. 5 | a: 6 | expand: amazon.com 7 | c: 8 | expand: gp/cart/view.html 9 | h: 10 | expand: gp/help/customer/contact-us 11 | o: 12 | expand: gp/css/order-history/ 13 | s: # a/s/foo will search amazon for "foo" 14 | query: s?k= 15 | c: 16 | expand: calendar.google.com 17 | work: # c/work will take you to your second gmail login 18 | expand: calendar/b/1 19 | e: 20 | expand: example.com 21 | ssl_off: yes 22 | f: 23 | expand: facebook.com 24 | g: 25 | expand: groups 26 | s: 27 | expand: BerkeleyFreeAndForSale 28 | g: 29 | expand: github.com 30 | d: 31 | expand: issmirnov/dotfiles 32 | s: 33 | query: search?q= 34 | z: 35 | expand: issmirnov/zap 36 | r: 37 | expand: reddit.com/r 38 | l: 39 | expand: localhost 40 | ssl_off: yes 41 | p: 42 | port: 8080 43 | "n": 44 | port: 9001 45 | m: 46 | expand: gmail.google.com 47 | work: # m/work will take you to the third gmail account 48 | expand: mail/u/2 49 | maps: 50 | expand: maps.google.com 51 | 52 | z: 53 | expand: zero.com 54 | ssl_off: yes 55 | zz: 56 | expand: zero.ssl.on.com 57 | ssl_off: no 58 | l: 59 | expand: localhost 60 | ssl_off: yes 61 | a: 62 | port: 8080 63 | s: 64 | expand: service 65 | # Wildcard expansions allow you to query specific java versions. 66 | # Example: "ak/11/j" -> "https://kafka.apache.org/11/javadoc/index.html?overview-summary.html" 67 | ak: 68 | expand: kafka.apache.org 69 | hi: 70 | expand: contact 71 | "*": 72 | d: 73 | expand: documentation.html 74 | j: 75 | expand: javadoc/index.html?overview-summary.html 76 | 77 | # Note: chrome will block redirects to the "chrome://" schema. This makes sense, otherwise folks could abuse 78 | # chrome://restart or change settings without users knowing. That said, this expansion is kept here as a reference 79 | # on the "schema" usage. If you often need to use sftp:// or other schemas, this should work for you. 80 | ch: 81 | # expand: "/" 82 | v: 83 | expand: version # should expand to chrome://version 84 | 'n': 85 | expand: net-internals 86 | d: 87 | expand: '#dns' 88 | schema: chrome -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "path" 10 | 11 | "github.com/issmirnov/zap/cmd/zap" 12 | 13 | "github.com/fsnotify/fsnotify" 14 | 15 | "github.com/julienschmidt/httprouter" 16 | ) 17 | 18 | const appName = "zap" 19 | 20 | // Used in version printer, set by GoReleaser. 21 | var version = "develop" 22 | 23 | func main() { 24 | var ( 25 | configName = flag.String("config", "c.yml", "config file") 26 | port = flag.Int("port", 8927, "port to bind to") 27 | host = flag.String("host", "127.0.0.1", "host address to bind to") 28 | advertise = flag.String("advertise", "127.0.0.1", "IP to advertise, used in /etc/hosts") 29 | v = flag.Bool("v", false, "print version info") 30 | validate = flag.Bool("validate", false, "load config file and check for errors") 31 | ) 32 | flag.Parse() 33 | 34 | if *v { 35 | fmt.Println(version) 36 | os.Exit(0) 37 | } 38 | 39 | // load config for first time. 40 | c, err := zap.ParseYaml(*configName) 41 | if err != nil { 42 | log.Printf("Error parsing config file. Please fix syntax: %s\n", err) 43 | return 44 | } 45 | 46 | // Perform extended validation of config. 47 | if *validate { 48 | if err := zap.ValidateConfig(c); err != nil { 49 | fmt.Println(err.Error()) 50 | os.Exit(1) 51 | } 52 | fmt.Println("No errors detected.") 53 | os.Exit(0) 54 | } 55 | 56 | context := &zap.Context{Config: c, Advertise: *advertise} 57 | zap.UpdateHosts(context) // sync changes since last run. 58 | 59 | // Enable hot reload. 60 | watcher, err := fsnotify.NewWatcher() 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | defer watcher.Close() 65 | 66 | cb := zap.MakeReloadCallback(context, *configName) 67 | go zap.WatchConfigFileChanges(watcher, *configName, cb) 68 | err = watcher.Add(path.Dir(*configName)) 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | 73 | // Set up routes. 74 | router := SetupRouter(context) 75 | 76 | // TODO check for errors - addr in use, sudo issues, etc. 77 | fmt.Printf("Launching %s on %s:%d\n", appName, *host, *port) 78 | log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *host, *port), router)) 79 | } 80 | 81 | func SetupRouter(context *zap.Context) *httprouter.Router { 82 | router := httprouter.New() 83 | router.Handler("GET", "/", zap.CtxWrapper{Context: context, H: zap.IndexHandler}) 84 | router.Handler("GET", "/varz", zap.CtxWrapper{Context: context, H: zap.VarsHandler}) 85 | router.HandlerFunc("GET", "/healthz", zap.HealthHandler) 86 | 87 | // https://github.com/julienschmidt/httprouter is having issues with 88 | // wildcard handling. As a result, we have to register index handler 89 | // as the fallback. Fix incoming. 90 | router.NotFound = zap.CtxWrapper{Context: context, H: zap.IndexHandler} 91 | return router 92 | } 93 | -------------------------------------------------------------------------------- /cmd/zap/config.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/Jeffail/gabs/v2" 13 | "github.com/fsnotify/fsnotify" 14 | "github.com/ghodss/yaml" 15 | "github.com/hashicorp/go-multierror" 16 | "github.com/spf13/afero" 17 | ) 18 | 19 | const ( 20 | delimStart = "### Zap Shortcuts :start ##\n" 21 | delimEnd = "### Zap Shortcuts :end ##\n" 22 | expandKey = "expand" 23 | queryKey = "query" 24 | portKey = "port" 25 | passKey = "*" 26 | sslKey = "ssl_off" 27 | schemaKey = "schema" 28 | httpsPrefix = "https:/" // second slash appended in expandPath() call 29 | httpPrefix = "http:/" // second slash appended in expandPath() call 30 | ) 31 | 32 | // Sentinel value used to indicate set membership. 33 | var exists = struct{}{} 34 | 35 | // Afero is a filesystem wrapper providing util methods 36 | // and easy test mocks. 37 | var Afero = &afero.Afero{Fs: afero.NewOsFs()} 38 | 39 | // parseYamlString takes a raw string and attempts to load it. 40 | func parseYamlString(config string) (*gabs.Container, error) { 41 | d, jsonErr := yaml.YAMLToJSON([]byte(config)) 42 | if jsonErr != nil { 43 | fmt.Printf("Error encoding input to JSON.\n%s\n", jsonErr.Error()) 44 | return nil, jsonErr 45 | } 46 | j, _ := gabs.ParseJSON(d) 47 | return j, nil 48 | } 49 | 50 | // ParseYaml takes a file name and returns a gabs Config object. 51 | func ParseYaml(fname string) (*gabs.Container, error) { 52 | data, err := Afero.ReadFile(fname) 53 | if err != nil { 54 | fmt.Printf("Unable to read file: %s\n", err.Error()) 55 | return nil, err 56 | } 57 | d, jsonErr := yaml.YAMLToJSON([]byte(data)) 58 | if jsonErr != nil { 59 | fmt.Printf("Error encoding input to JSON.\n%s\n", jsonErr.Error()) 60 | return nil, jsonErr 61 | } 62 | j, _ := gabs.ParseJSON(d) 63 | return j, nil 64 | } 65 | 66 | // ValidateConfig verifies that there are no unexpected values in the Config file. 67 | // at each level of the Config, we should either have a KV for expansions, or a leaf node 68 | // with the values oneof "expand", "query", "ssl_off" that map to a string. 69 | func ValidateConfig(c *gabs.Container) error { 70 | var errors *multierror.Error 71 | children := c.ChildrenMap() 72 | seenKeys := make(map[string]struct{}) 73 | for k, v := range children { 74 | // Check if key already seen 75 | if _, ok := seenKeys[k]; ok { 76 | log.Printf("%s detected again", k) 77 | errors = multierror.Append(errors, fmt.Errorf("duplicate key detected %s", k)) 78 | } else { 79 | seenKeys[k] = exists // mark key as seen 80 | } 81 | 82 | // Validate all children 83 | switch k { 84 | case 85 | expandKey, 86 | schemaKey, 87 | queryKey: 88 | // check that v is a string, else return error. 89 | if _, ok := v.Data().(string); !ok { 90 | errors = multierror.Append(errors, fmt.Errorf("expected string value for %T, got: %v", k, v.Data())) 91 | } 92 | case portKey: 93 | // check that v is a float64, else return error. 94 | if _, ok := v.Data().(float64); !ok { 95 | errors = multierror.Append(errors, fmt.Errorf("expected float64 value for %T, got: %v", k, v.Data())) 96 | } 97 | case sslKey: 98 | // check that v is a boolean, else return error. 99 | if _, ok := v.Data().(bool); !ok { 100 | errors = multierror.Append(errors, fmt.Errorf("expected bool value for %T, got: %v", k, v.Data())) 101 | } 102 | default: 103 | // Check if we have an unknown string here. 104 | if _, ok := v.Data().(string); ok { 105 | errors = multierror.Append(errors, fmt.Errorf("unexpected string value under key %s, got: %v", k, v.Data())) 106 | } 107 | // recurse, collect any errors. 108 | if err := ValidateConfig(v); err != nil { 109 | errors = multierror.Append(errors, err) 110 | } 111 | } 112 | } 113 | return errors.ErrorOrNil() 114 | } 115 | 116 | // WatchConfigFileChanges will attach an fsnotify watcher to the config file, and trigger 117 | // the cb function when the file is updated. 118 | func WatchConfigFileChanges(watcher *fsnotify.Watcher, fname string, cb func()) { 119 | for { 120 | select { 121 | case event := <-watcher.Events: 122 | // You may wonder why we can't just listen for "Write" events. The reason is that vim (and other editors) 123 | // will create swap files, and when you write they delete the original and rename the swap file. This is great 124 | // for resolving system crashes, but also completely incompatible with inotify and other fswatch implementations. 125 | // Thus, we check that the file of interest might be created as well. 126 | updated := event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write 127 | zapconf := filepath.Clean(event.Name) == fname 128 | if updated && zapconf { 129 | cb() 130 | } 131 | case e := <-watcher.Errors: 132 | log.Println("error:", e) 133 | } 134 | } 135 | } 136 | 137 | // TODO: add tests. simulate touching a file. 138 | // UpdateHosts will attempt to write the zap list of shortcuts 139 | // to /etc/hosts. It will gracefully fail if there are not enough 140 | // permissions to do so. 141 | func UpdateHosts(c *Context) { 142 | hostPath := "/etc/hosts" 143 | 144 | // 1. read file, prep buffer. 145 | data, err := ioutil.ReadFile(hostPath) 146 | if err != nil { 147 | log.Println("open Config: ", err) 148 | } 149 | var replacement bytes.Buffer 150 | 151 | // 2. generate payload. 152 | replacement.WriteString(delimStart) 153 | children := c.Config.ChildrenMap() 154 | for k := range children { 155 | replacement.WriteString(fmt.Sprintf("%s %s\n", c.Advertise, k)) 156 | } 157 | replacement.WriteString(delimEnd) 158 | 159 | // 3. Generate new file content 160 | var updatedFile string 161 | if !strings.Contains(string(data), delimStart) { 162 | updatedFile = string(data) + replacement.String() 163 | } else { 164 | zapBlock := regexp.MustCompile("(###(.*)##)\n(.|\n)*(###(.*)##\n)") 165 | updatedFile = zapBlock.ReplaceAllString(string(data), replacement.String()) 166 | } 167 | 168 | // 4. Attempt write to file. 169 | err = ioutil.WriteFile(hostPath, []byte(updatedFile), 0644) 170 | if err != nil { 171 | log.Printf("Error writing to '%s': %s\n", hostPath, err.Error()) 172 | } 173 | } 174 | 175 | // MakeReloadCallback returns a func that that reads the config file and updates global state. 176 | func MakeReloadCallback(c *Context, configName string) func() { 177 | return func() { 178 | data, err := ParseYaml(configName) 179 | if err != nil { 180 | log.Printf("Error loading new Config: %s. Fallback to old Config.", err) 181 | return 182 | } 183 | err = ValidateConfig(data) 184 | if err != nil { 185 | log.Printf("Error validating new Config: %s. Fallback to old Config.", err) 186 | return 187 | } 188 | 189 | // Update Config atomically 190 | c.ConfigMtx.Lock() 191 | c.Config = data 192 | c.ConfigMtx.Unlock() 193 | 194 | // Sync DNS entries. 195 | UpdateHosts(c) 196 | return 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /cmd/zap/config_test.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Jeffail/gabs/v2" 7 | 8 | . "github.com/smartystreets/goconvey/convey" 9 | "github.com/spf13/afero" 10 | ) 11 | 12 | const duplicatedYAML = ` 13 | e: 14 | expand: example.com 15 | a: 16 | expand: apples 17 | b: 18 | expand: bananas 19 | g: 20 | expand: github.com 21 | d: 22 | expand: issmirnov/dotfiles 23 | z: 24 | expand: issmirnov/zap 25 | s: 26 | query: "search?q=" 27 | z: 28 | expand: zero.com 29 | ssl_off: yes 30 | zz: 31 | expand: zero.ssl.on.com 32 | ssl_off: no 33 | zz: 34 | expand: secondaryexpansion.com 35 | ` 36 | 37 | const badkeysYAML = ` 38 | e: 39 | bad_key: example.com 40 | a: 41 | expand: apples 42 | g: 43 | expand: github.com 44 | d: 45 | expand: issmirnov/dotfiles 46 | ` 47 | 48 | const badValuesYAML = ` 49 | e: 50 | expand: 2 51 | s: 52 | query: 3 53 | g: 54 | expand: github.com 55 | ssl_off: "not_bool" 56 | l: 57 | port: "not_int" 58 | ` 59 | 60 | const cYaml = ` 61 | e: 62 | expand: example.com 63 | a: 64 | expand: apples 65 | b: 66 | expand: bananas 67 | g: 68 | expand: github.com 69 | d: 70 | expand: issmirnov/dotfiles 71 | z: 72 | expand: issmirnov/zap 73 | s: 74 | query: "search?q=" 75 | me: 76 | expand: issmirnov 77 | z: 78 | expand: zap 79 | ak: 80 | query: apache/kafka 81 | c: 82 | query: +connect 83 | z: 84 | expand: zero.com 85 | ssl_off: yes 86 | zz: 87 | expand: zero.ssl.on.com 88 | ssl_off: no 89 | l: 90 | expand: localhost 91 | ssl_off: yes 92 | a: 93 | port: 8080 94 | s: 95 | expand: service 96 | ak: 97 | expand: kafka.apache.org 98 | hi: 99 | expand: contact 100 | "*": 101 | d: 102 | expand: documentation.html 103 | j: 104 | expand: javadoc/index.html?overview-summary.html 105 | wc: 106 | expand: wildcard.com 107 | "*": 108 | "*": 109 | "*": 110 | four: 111 | expand: "4" 112 | ch: 113 | # expand: "/" 114 | v: 115 | expand: version # should expand to chrome://version 116 | 'n': 117 | expand: net-internals 118 | d: 119 | expand: '#dns' 120 | schema: chrome 121 | ` 122 | 123 | func loadTestYaml() (*gabs.Container, error) { 124 | return parseYamlString(cYaml) 125 | } 126 | 127 | func TestParseYaml(t *testing.T) { 128 | Convey("Given a valid 'c.yml' file", t, func() { 129 | Afero = &afero.Afero{Fs: afero.NewMemMapFs()} 130 | Afero.WriteFile("c.yml", []byte(cYaml), 0644) 131 | c, err := ParseYaml("c.yml") 132 | Convey("ParseYaml should throw no error", func() { 133 | So(err, ShouldBeNil) 134 | }) 135 | Convey("the gabs object should have path 'zz' present", func() { 136 | So(c.ExistsP("zz"), ShouldBeTrue) 137 | }) 138 | }) 139 | } 140 | 141 | func TestValidateConfig(t *testing.T) { 142 | Convey("Given a correctly formatted yaml Config", t, func() { 143 | conf, _ := parseYamlString(cYaml) 144 | //fmt.Printf(err.Error()) 145 | Convey("The validator should pass", func() { 146 | So(ValidateConfig(conf), ShouldBeNil) 147 | }) 148 | }) 149 | 150 | // The YAML libraries don't have support for detecting duplicate keys 151 | // at parse time. Users will have to figure this out themselves. 152 | //Convey("Given a yaml Config with duplicated keys", t, func() { 153 | // conf, _ := parseYamlString(duplicatedYAML) 154 | // Convey("The validator should complain", func() { 155 | // So(ValidateConfig(conf), ShouldNotBeNil) 156 | // }) 157 | //}) 158 | 159 | Convey("Given a YAML Config with unknown keys", t, func() { 160 | conf, _ := parseYamlString(badkeysYAML) 161 | Convey("The validator should raise an error", func() { 162 | So(ValidateConfig(conf), ShouldNotBeNil) 163 | }) 164 | }) 165 | 166 | Convey("Given a YAML Config with malformed values", t, func() { 167 | conf, _ := parseYamlString(badValuesYAML) 168 | err := ValidateConfig(conf) 169 | Convey("The validator should raise a ton of errors", func() { 170 | So(err, ShouldNotBeNil) 171 | So(err.Error(), ShouldContainSubstring, "expected float64 value for string, got: not_int") 172 | So(err.Error(), ShouldContainSubstring, "expected string value for string, got: 3") 173 | So(err.Error(), ShouldContainSubstring, "expected bool value for string, got: not_bool") 174 | So(err.Error(), ShouldContainSubstring, "expected string value for string, got: 2") 175 | }) 176 | }) 177 | } 178 | -------------------------------------------------------------------------------- /cmd/zap/structs.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/Jeffail/gabs/v2" 9 | ) 10 | 11 | type Context struct { 12 | // Config is a Json container with path configs 13 | Config *gabs.Container 14 | 15 | // ConfigMtx Enables safe hot reloading of Config. 16 | ConfigMtx sync.Mutex 17 | 18 | // Advertise IP, used in /etc/hosts in case bind address differs. 19 | Advertise string 20 | } 21 | 22 | type CtxWrapper struct { 23 | *Context 24 | H func(*Context, http.ResponseWriter, *http.Request) (int, error) 25 | } 26 | 27 | func (cw CtxWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { 28 | status, err := cw.H(cw.Context, w, r) // this runs the actual handler, defined in struct. 29 | if err != nil { 30 | switch status { 31 | case http.StatusInternalServerError: 32 | http.Error(w, fmt.Sprintf("HTTP %d: %q", status, err), status) 33 | // TODO - add bad request? 34 | default: 35 | http.Error(w, err.Error(), status) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cmd/zap/text.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "bytes" 5 | "container/list" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/Jeffail/gabs/v2" 10 | ) 11 | 12 | const ( 13 | expand = iota 14 | query 15 | port 16 | ) 17 | 18 | // tokenize takes a string delimited by slashes and splits it up into tokens 19 | // returns a linked list. 20 | func tokenize(path string) *list.List { 21 | 22 | // Creates the list. 23 | l := list.New() 24 | for _, tok := range strings.Split(path, "/") { 25 | l.PushBack(tok) 26 | } 27 | return l 28 | } 29 | 30 | // Returns string to write to result, boolean flag indicating whether to advance 31 | // token, and error if needed. 32 | // The option to advance the token is needed when we want to suppress the slash separator. 33 | func getPrefix(c *gabs.Container) (string, int, error) { 34 | d := c.Path(expandKey).Data() 35 | if d != nil { 36 | s, oks := d.(string) 37 | i, oki := d.(float64) 38 | if oks { 39 | return s, expand, nil 40 | } else if oki { 41 | return fmt.Sprintf("%.f", i), expand, nil 42 | } 43 | return "", 0, fmt.Errorf("unexpected type of expansion value, got %T instead of int or string", d) 44 | } 45 | 46 | q := c.Path(queryKey).Data() 47 | if q != nil { 48 | if s, ok := q.(string); ok { 49 | return s, query, nil 50 | } 51 | return "", 0, fmt.Errorf("casting query key to string failed for %T:%v", q, q) 52 | } 53 | 54 | p := c.Path(portKey).Data() 55 | if p != nil { 56 | if s, ok := p.(float64); ok { 57 | return fmt.Sprintf(":%.f", s), port, nil 58 | } 59 | return "", 0, fmt.Errorf("casting port key to float64 failed for %T:%v", p, p) 60 | } 61 | 62 | return "", 0, fmt.Errorf("error in Config, no key matching 'expand', 'query', 'port' or 'schema' in %s", c.String()) 63 | } 64 | 65 | // ExpandPath takes a Config, list of tokens (parsed from request) and the results buffer 66 | // At each level of recursion, it matches the token to the action described in the Config, and writes it 67 | // to the result buffer. There is special care needed to handle slashes correctly, which makes this function 68 | // quite nontrivial. Tests are crucial to ensure correctness. 69 | func ExpandPath(c *gabs.Container, token *list.Element, res *bytes.Buffer) { 70 | expandPath(c, token, res, true) 71 | } 72 | 73 | // Internal helper function that adds contextual information about whether a leading slash 74 | // should be added to the beginning of the path 75 | func expandPath(c *gabs.Container, token *list.Element, res *bytes.Buffer, prependSlash bool) { 76 | if token == nil { 77 | return 78 | } 79 | children := c.ChildrenMap() 80 | tokVal := token.Value.(string) 81 | if child, ok := children[tokVal]; !isReserved(tokVal) && ok { 82 | p, action, err := getPrefix(child) 83 | if err != nil { 84 | fmt.Println(err.Error()) 85 | return 86 | } 87 | 88 | prependChildSlash := true 89 | 90 | switch action { 91 | case expand: // Generic case: maybe write slash, then expanded token. 92 | if prependSlash { 93 | res.WriteString("/") 94 | } 95 | res.WriteString(p) 96 | 97 | case query: // Maybe write a slash, then expanded query, then recurse with no prepended slashes 98 | if prependSlash { 99 | res.WriteString("/") 100 | } 101 | res.WriteString(p) 102 | prependChildSlash = false 103 | 104 | case port: // A little bit of a special case - unlike "expand" and "query", we never want a leading slash. 105 | res.WriteString(p) 106 | 107 | default: 108 | panic("Programmer error, this should never happen.") 109 | } 110 | expandPath(child, token.Next(), res, prependChildSlash) 111 | return 112 | } else if child, ok := children[passKey]; ok { 113 | if prependSlash { 114 | res.WriteString("/") 115 | } 116 | res.WriteString(token.Value.(string)) 117 | expandPath(child, token.Next(), res, true) 118 | return 119 | } 120 | 121 | // if tokens left over, append the rest 122 | for e := token; e != nil; e = e.Next() { 123 | if prependSlash { 124 | res.WriteString("/") 125 | } else { 126 | prependSlash = true 127 | } 128 | res.WriteString(e.Value.(string)) 129 | } 130 | } 131 | 132 | func isReserved(pathElem string) bool { 133 | switch pathElem { 134 | case 135 | expandKey, 136 | queryKey, 137 | portKey, 138 | passKey, 139 | schemaKey, 140 | sslKey: 141 | return true 142 | default: 143 | return false 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /cmd/zap/text_test.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestTokenizer(t *testing.T) { 11 | Convey("Given a string 'g/z'", t, func() { 12 | l := tokenize("g/z") 13 | Convey("The resulting list should", func() { 14 | 15 | Convey("Have length 2", func() { 16 | So(l.Len(), ShouldEqual, 2) 17 | }) 18 | Convey("The first element should be equal to 'g'", func() { 19 | So(l.Front().Value, ShouldEqual, "g") 20 | }) 21 | Convey("The last element should be equal to 'z'", func() { 22 | So(l.Back().Value, ShouldEqual, "z") 23 | }) 24 | }) 25 | }) 26 | 27 | Convey("Given a string 'e/a/extratext'", t, func() { 28 | l := tokenize("e/a/extratext") 29 | Convey("The resulting list should have length 3", func() { 30 | So(l.Len(), ShouldEqual, 3) 31 | }) 32 | }) 33 | 34 | Convey("Given a string 'e/a/extratext/'", t, func() { 35 | l := tokenize("e/a/extratext/") 36 | Convey("The resulting list should have length 4", func() { 37 | So(l.Len(), ShouldEqual, 4) // Since we have nil terminator. 38 | }) 39 | }) 40 | } 41 | 42 | func TestExpander(t *testing.T) { 43 | 44 | Convey("Given 'g/z'", t, func() { 45 | c, _ := loadTestYaml() 46 | l := tokenize("g/z") 47 | var res bytes.Buffer 48 | res.WriteString(httpsPrefix) 49 | 50 | ExpandPath(c, l.Front(), &res) 51 | 52 | Convey("result should equal 'https://github.com/issmirnov/zap'", func() { 53 | So(res.String(), ShouldEqual, "https://github.com/issmirnov/zap") 54 | }) 55 | }) 56 | // Convey("Given 'e/n'", t, func() { 57 | // c, _ := loadTestYaml() 58 | // l := tokenize("e/n ") 59 | // var res bytes.Buffer 60 | // res.WriteString(httpsPrefix) 61 | // 62 | // ExpandPath(c, l.Front(), &res) 63 | // 64 | // Convey("result should equal 'https://example.com/999'", func() { 65 | // So(res.String(), ShouldEqual, "https://example.com/999") 66 | // }) 67 | // }) 68 | Convey("Given 'g/z/extratext'", t, func() { 69 | c, _ := loadTestYaml() 70 | l := tokenize("g/z/extratext") 71 | var res bytes.Buffer 72 | res.WriteString(httpsPrefix) 73 | 74 | ExpandPath(c, l.Front(), &res) 75 | 76 | Convey("result should equal 'https://github.com/issmirnov/zap/extratext'", func() { 77 | So(res.String(), ShouldEqual, "https://github.com/issmirnov/zap/extratext") 78 | }) 79 | }) 80 | Convey("Given 'g/'", t, func() { 81 | c, _ := loadTestYaml() 82 | l := tokenize("g/") 83 | var res bytes.Buffer 84 | res.WriteString(httpsPrefix) 85 | 86 | ExpandPath(c, l.Front(), &res) 87 | 88 | Convey("result should equal 'https://github.com/'", func() { 89 | So(res.String(), ShouldEqual, "https://github.com/") 90 | }) 91 | }) 92 | Convey("Given 'g/z/very/deep/path'", t, func() { 93 | c, _ := loadTestYaml() 94 | l := tokenize("g/z/very/deep/path") 95 | var res bytes.Buffer 96 | res.WriteString(httpsPrefix) 97 | 98 | ExpandPath(c, l.Front(), &res) 99 | 100 | Convey("result should equal 'https://github.com/issmirnov/zap/very/deep/path'", func() { 101 | So(res.String(), ShouldEqual, "https://github.com/issmirnov/zap/very/deep/path") 102 | }) 103 | }) 104 | Convey("Given 'g/s/foobar'", t, func() { 105 | c, _ := loadTestYaml() 106 | l := tokenize("g/s/foobar") 107 | var res bytes.Buffer 108 | res.WriteString(httpsPrefix) 109 | 110 | ExpandPath(c, l.Front(), &res) 111 | 112 | Convey("result should equal 'https://github.com/search?q=foobar'", func() { 113 | So(res.String(), ShouldEqual, "https://github.com/search?q=foobar") 114 | }) 115 | }) 116 | Convey("Given 'g/s/foo/bar/baz'", t, func() { 117 | c, _ := loadTestYaml() 118 | l := tokenize("g/s/foo/bar/baz") 119 | var res bytes.Buffer 120 | res.WriteString(httpsPrefix) 121 | 122 | ExpandPath(c, l.Front(), &res) 123 | 124 | Convey("result should equal 'https://github.com/search?q=foo/bar/baz'", func() { 125 | So(res.String(), ShouldEqual, "https://github.com/search?q=foo/bar/baz") 126 | }) 127 | }) 128 | Convey("Given 'g/s/foo/bar/baz/'", t, func() { 129 | c, _ := loadTestYaml() 130 | l := tokenize("g/s/foo/bar/baz/") 131 | var res bytes.Buffer 132 | res.WriteString(httpsPrefix) 133 | 134 | ExpandPath(c, l.Front(), &res) 135 | 136 | Convey("result should equal 'https://github.com/search?q=foo/bar/baz/'", func() { 137 | So(res.String(), ShouldEqual, "https://github.com/search?q=foo/bar/baz/") 138 | }) 139 | }) 140 | Convey("Given 'g/query/homebrew'", t, func() { 141 | c, _ := loadTestYaml() 142 | l := tokenize("g/query/homebrew") 143 | var res bytes.Buffer 144 | res.WriteString(httpsPrefix) 145 | 146 | ExpandPath(c, l.Front(), &res) 147 | 148 | Convey("result should equal 'https://github.com/query/homebrew'", func() { 149 | So(res.String(), ShouldEqual, "https://github.com/query/homebrew") 150 | }) 151 | }) 152 | Convey("Given 'wc/1/*/3/four'", t, func() { 153 | c, _ := loadTestYaml() 154 | l := tokenize("wc/1/*/3/four") 155 | var res bytes.Buffer 156 | res.WriteString(httpsPrefix) 157 | 158 | ExpandPath(c, l.Front(), &res) 159 | 160 | Convey("result should equal 'https://wildcard.com/1/*/3/4'", func() { 161 | So(res.String(), ShouldEqual, "https://wildcard.com/1/*/3/4") 162 | }) 163 | }) 164 | Convey("Given 'wc/1/2/3/four'", t, func() { 165 | c, _ := loadTestYaml() 166 | l := tokenize("wc/1/2/3/four") 167 | var res bytes.Buffer 168 | res.WriteString(httpsPrefix) 169 | 170 | ExpandPath(c, l.Front(), &res) 171 | 172 | Convey("result should equal 'https://wildcard.com/1/2/3/4'", func() { 173 | So(res.String(), ShouldEqual, "https://wildcard.com/1/2/3/4") 174 | }) 175 | }) 176 | Convey("Given 'ak/hi'", t, func() { 177 | c, _ := loadTestYaml() 178 | l := tokenize("ak/hi") 179 | var res bytes.Buffer 180 | res.WriteString(httpsPrefix) 181 | 182 | ExpandPath(c, l.Front(), &res) 183 | 184 | Convey("result should equal 'https://kafka.apache.org/contact", func() { 185 | So(res.String(), ShouldEqual, "https://kafka.apache.org/contact") 186 | }) 187 | }) 188 | Convey("Given 'ak/23'", t, func() { 189 | c, _ := loadTestYaml() 190 | l := tokenize("ak/23") 191 | var res bytes.Buffer 192 | res.WriteString(httpsPrefix) 193 | 194 | ExpandPath(c, l.Front(), &res) 195 | 196 | Convey("result should equal 'https://kafka.apache.org/23", func() { 197 | So(res.String(), ShouldEqual, "https://kafka.apache.org/23") 198 | }) 199 | }) 200 | Convey("Given 'ak/23/j", t, func() { 201 | c, _ := loadTestYaml() 202 | l := tokenize("ak/23/j") 203 | var res bytes.Buffer 204 | res.WriteString(httpsPrefix) 205 | 206 | ExpandPath(c, l.Front(), &res) 207 | 208 | Convey("result should equal 'https://kafka.apache.org/23/javadoc/index.html?overview-summary.html", func() { 209 | So(res.String(), ShouldEqual, "https://kafka.apache.org/23/javadoc/index.html?overview-summary.html") 210 | }) 211 | }) 212 | Convey("Given 'ak/expand/j'", t, func() { 213 | c, _ := loadTestYaml() 214 | l := tokenize("ak/expand/j") 215 | var res bytes.Buffer 216 | res.WriteString(httpsPrefix) 217 | 218 | ExpandPath(c, l.Front(), &res) 219 | 220 | Convey("result should equal 'https://kafka.apache.org/expand/javadoc/index.html?overview-summary.html", func() { 221 | So(res.String(), ShouldEqual, "https://kafka.apache.org/expand/javadoc/index.html?overview-summary.html") 222 | }) 223 | }) 224 | Convey("Given 'g/s/me'", t, func() { 225 | c, _ := loadTestYaml() 226 | l := tokenize("g/s/me") 227 | var res bytes.Buffer 228 | res.WriteString(httpsPrefix) 229 | 230 | ExpandPath(c, l.Front(), &res) 231 | 232 | Convey("result should equal 'https://github.com/search?q=issmirnov'", func() { 233 | So(res.String(), ShouldEqual, "https://github.com/search?q=issmirnov") 234 | }) 235 | }) 236 | Convey("Given 'g/s/me/z'", t, func() { 237 | c, _ := loadTestYaml() 238 | l := tokenize("g/s/me/z") 239 | var res bytes.Buffer 240 | res.WriteString(httpsPrefix) 241 | 242 | ExpandPath(c, l.Front(), &res) 243 | 244 | Convey("result should equal 'https://github.com/search?q=issmirnov/zap'", func() { 245 | So(res.String(), ShouldEqual, "https://github.com/search?q=issmirnov/zap") 246 | }) 247 | }) 248 | Convey("Given 'g/s/ak'", t, func() { 249 | c, _ := loadTestYaml() 250 | l := tokenize("g/s/ak") 251 | var res bytes.Buffer 252 | res.WriteString(httpsPrefix) 253 | 254 | ExpandPath(c, l.Front(), &res) 255 | 256 | Convey("result should equal 'https://github.com/search?q=apache/kafka'", func() { 257 | So(res.String(), ShouldEqual, "https://github.com/search?q=apache/kafka") 258 | }) 259 | }) 260 | Convey("Given 'g/s/ak/c'", t, func() { 261 | c, _ := loadTestYaml() 262 | l := tokenize("g/s/ak/c") 263 | var res bytes.Buffer 264 | res.WriteString(httpsPrefix) 265 | 266 | ExpandPath(c, l.Front(), &res) 267 | 268 | Convey("result should equal 'https://github.com/search?q=apache/kafka+connect'", func() { 269 | So(res.String(), ShouldEqual, "https://github.com/search?q=apache/kafka+connect") 270 | }) 271 | }) 272 | } 273 | -------------------------------------------------------------------------------- /cmd/zap/web.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "encoding/json" 10 | 11 | "github.com/Jeffail/gabs/v2" 12 | ) 13 | 14 | // IndexHandler handles all the non status expansions. 15 | func IndexHandler(ctx *Context, w http.ResponseWriter, r *http.Request) (int, error) { 16 | var host string 17 | if r.Header.Get("X-Forwarded-Host") != "" { 18 | host = r.Header.Get("X-Forwarded-Host") 19 | } else { 20 | host = r.Host 21 | } 22 | 23 | var hostConfig *gabs.Container 24 | var ok bool 25 | 26 | // Check if host present in Config. 27 | children := ctx.Config.ChildrenMap() 28 | if hostConfig, ok = children[host]; !ok { 29 | return 404, fmt.Errorf("Shortcut '%s' not found in Config.", host) 30 | } 31 | 32 | tokens := tokenize(host + r.URL.Path) 33 | 34 | // Set up handles on token and Config. We might need to skip ahead if there's a custom schema set. 35 | tokensStart := tokens.Front() 36 | conf := ctx.Config 37 | 38 | var path bytes.Buffer 39 | if s := hostConfig.Path(sslKey).Data(); s != nil && s.(bool) { 40 | path.WriteString(httpPrefix) 41 | } else if s := hostConfig.Path(schemaKey).Data(); s != nil && s.(string) != "" { 42 | path.WriteString(hostConfig.Path(schemaKey).Data().(string) + ":/") 43 | // move one token ahead to parse expansions correctly. 44 | conf = conf.ChildrenMap()[tokensStart.Value.(string)] 45 | tokensStart = tokensStart.Next() 46 | } else { 47 | // Default to regular https prefix. 48 | path.WriteString(httpsPrefix) 49 | } 50 | 51 | ExpandPath(conf, tokensStart, &path) 52 | 53 | // send result 54 | http.Redirect(w, r, path.String(), http.StatusFound) 55 | 56 | return 302, nil 57 | } 58 | 59 | // HealthHandler responds to /healthz request. 60 | func HealthHandler(w http.ResponseWriter, r *http.Request) { 61 | w.WriteHeader(http.StatusOK) 62 | io.WriteString(w, `OK`) 63 | } 64 | 65 | // VarsHandler responds to /varz request and prints Config. 66 | func VarsHandler(c *Context, w http.ResponseWriter, r *http.Request) (int, error) { 67 | w.WriteHeader(http.StatusOK) 68 | io.WriteString(w, jsonPrettyPrint(c.Config.String())) 69 | return 200, nil 70 | } 71 | 72 | // https://stackoverflow.com/a/36544455/5117259 73 | func jsonPrettyPrint(in string) string { 74 | var out bytes.Buffer 75 | err := json.Indent(&out, []byte(in), "", "\t") 76 | if err != nil { 77 | return in 78 | } 79 | out.WriteString("\n") 80 | return out.String() 81 | } 82 | -------------------------------------------------------------------------------- /cmd/zap/web_test.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "golang.org/x/exp/errors/fmt" 9 | 10 | "github.com/ghodss/yaml" 11 | . "github.com/smartystreets/goconvey/convey" 12 | ) 13 | 14 | // TODO: add tests that use erroneous Config. 15 | // Will likely require injecting custom logger and intercepting error msgs. 16 | 17 | // See https://elithrar.github.io/article/testing-http-handlers-go/ for comments. 18 | func TestIndexHandler(t *testing.T) { 19 | Convey("Given app is set up with default Config", t, func() { 20 | c, err := loadTestYaml() 21 | So(err, ShouldBeNil) 22 | context := &Context{Config: c} 23 | appHandler := &CtxWrapper{context, IndexHandler} 24 | handler := http.Handler(appHandler) 25 | Convey("When we GET http://g/z", func() { 26 | req, err := http.NewRequest("GET", "/z", nil) 27 | So(err, ShouldBeNil) 28 | req.Host = "g" 29 | 30 | rr := httptest.NewRecorder() 31 | handler.ServeHTTP(rr, req) 32 | 33 | Convey("The result should be a 302 to https://github.com/issmirnov/zap", func() { 34 | So(rr.Code, ShouldEqual, http.StatusFound) 35 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/issmirnov/zap") 36 | }) 37 | }) 38 | Convey("When we GET http://g/z/", func() { 39 | req, err := http.NewRequest("GET", "/z/", nil) 40 | So(err, ShouldBeNil) 41 | req.Host = "g" 42 | 43 | rr := httptest.NewRecorder() 44 | handler.ServeHTTP(rr, req) 45 | 46 | Convey("The result should be a 302 to https://github.com/issmirnov/zap/", func() { 47 | So(rr.Code, ShouldEqual, http.StatusFound) 48 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/issmirnov/zap/") 49 | }) 50 | }) 51 | Convey("When we GET http://g/z/ with 'X-Forwarded-Host' set", func() { 52 | req, err := http.NewRequest("GET", "/z/", nil) 53 | So(err, ShouldBeNil) 54 | req.Header = map[string][]string{"X-Forwarded-Host": {"g"}} 55 | 56 | rr := httptest.NewRecorder() 57 | handler.ServeHTTP(rr, req) 58 | 59 | Convey("The result should be a 302 to https://github.com/issmirnov/zap/", func() { 60 | So(rr.Code, ShouldEqual, http.StatusFound) 61 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/issmirnov/zap/") 62 | }) 63 | }) 64 | Convey("When we GET http://g/z/very/deep/path", func() { 65 | req, err := http.NewRequest("GET", "/z/very/deep/path", nil) 66 | So(err, ShouldBeNil) 67 | req.Host = "g" 68 | 69 | rr := httptest.NewRecorder() 70 | handler.ServeHTTP(rr, req) 71 | 72 | Convey("The result should be a 302 to https://github.com/issmirnov/zap/very/deep/path", func() { 73 | So(rr.Code, ShouldEqual, http.StatusFound) 74 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/issmirnov/zap/very/deep/path") 75 | }) 76 | }) 77 | Convey("When we GET http://g/z/very/deep/path/", func() { 78 | req, err := http.NewRequest("GET", "/z/very/deep/path/", nil) 79 | So(err, ShouldBeNil) 80 | req.Host = "g" 81 | 82 | rr := httptest.NewRecorder() 83 | handler.ServeHTTP(rr, req) 84 | 85 | Convey("The result should be a 302 to https://github.com/issmirnov/zap/very/deep/path/", func() { 86 | So(rr.Code, ShouldEqual, http.StatusFound) 87 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/issmirnov/zap/very/deep/path/") 88 | }) 89 | }) 90 | Convey("When we GET http://g/", func() { 91 | req, err := http.NewRequest("GET", "/", nil) 92 | So(err, ShouldBeNil) 93 | req.Host = "g" 94 | 95 | rr := httptest.NewRecorder() 96 | handler.ServeHTTP(rr, req) 97 | 98 | Convey("The result should be a 302 to https://github.com/", func() { 99 | So(rr.Code, ShouldEqual, http.StatusFound) 100 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/") 101 | }) 102 | }) 103 | Convey("When we GET http://fake/path", func() { 104 | req, err := http.NewRequest("GET", "/path", nil) 105 | So(err, ShouldBeNil) 106 | req.Host = "fake" 107 | 108 | rr := httptest.NewRecorder() 109 | handler.ServeHTTP(rr, req) 110 | 111 | Convey("The result should be a 404", func() { 112 | So(rr.Code, ShouldEqual, http.StatusNotFound) 113 | }) 114 | }) 115 | Convey("When we GET http://g/s/", func() { 116 | req, err := http.NewRequest("GET", "/s/", nil) 117 | So(err, ShouldBeNil) 118 | req.Host = "g" 119 | 120 | rr := httptest.NewRecorder() 121 | handler.ServeHTTP(rr, req) 122 | 123 | Convey("The result should be a 302 to https://github.com/search?q=", func() { 124 | So(rr.Code, ShouldEqual, http.StatusFound) 125 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/search?q=") 126 | }) 127 | }) 128 | Convey("When we GET http://g/s/foo", func() { 129 | req, err := http.NewRequest("GET", "/s/foo", nil) 130 | So(err, ShouldBeNil) 131 | req.Host = "g" 132 | 133 | rr := httptest.NewRecorder() 134 | handler.ServeHTTP(rr, req) 135 | 136 | Convey("The result should be a 302 to https://github.com/search?q=foo", func() { 137 | So(rr.Code, ShouldEqual, http.StatusFound) 138 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/search?q=foo") 139 | }) 140 | }) 141 | Convey("When we GET http://g/s", func() { 142 | req, err := http.NewRequest("GET", "/s", nil) 143 | So(err, ShouldBeNil) 144 | req.Host = "g" 145 | 146 | rr := httptest.NewRecorder() 147 | handler.ServeHTTP(rr, req) 148 | 149 | Convey("The result should be a 302 to https://github.com/search?q=", func() { 150 | So(rr.Code, ShouldEqual, http.StatusFound) 151 | So(rr.Header().Get("Location"), ShouldEqual, "https://github.com/search?q=") 152 | }) 153 | }) 154 | Convey("When we GET http://z/ with ssl_off", func() { 155 | req, err := http.NewRequest("GET", "/", nil) 156 | So(err, ShouldBeNil) 157 | req.Host = "z" 158 | 159 | rr := httptest.NewRecorder() 160 | handler.ServeHTTP(rr, req) 161 | 162 | Convey("The result should be a 302 to http://zero.com/", func() { 163 | So(rr.Code, ShouldEqual, http.StatusFound) 164 | So(rr.Header().Get("Location"), ShouldEqual, "http://zero.com/") 165 | }) 166 | }) 167 | Convey("When we GET http://zz/ with ssl_off: no ", func() { 168 | req, err := http.NewRequest("GET", "/", nil) 169 | So(err, ShouldBeNil) 170 | req.Host = "zz" 171 | 172 | rr := httptest.NewRecorder() 173 | handler.ServeHTTP(rr, req) 174 | 175 | Convey("The result should be a 302 to https://zero.ssl.on.com", func() { 176 | So(rr.Code, ShouldEqual, http.StatusFound) 177 | So(rr.Header().Get("Location"), ShouldEqual, "https://zero.ssl.on.com/") 178 | }) 179 | }) 180 | 181 | Convey("When we GET http://l/a with ssl_off ", func() { 182 | req, err := http.NewRequest("GET", "/a", nil) 183 | So(err, ShouldBeNil) 184 | req.Host = "l" 185 | 186 | rr := httptest.NewRecorder() 187 | handler.ServeHTTP(rr, req) 188 | 189 | Convey("The result should be a 302 to http://localhost:8080", func() { 190 | So(rr.Code, ShouldEqual, http.StatusFound) 191 | So(rr.Header().Get("Location"), ShouldEqual, "http://localhost:8080") 192 | }) 193 | }) 194 | 195 | Convey("When we GET http://l/a/ with ssl_off ", func() { 196 | req, err := http.NewRequest("GET", "/a/", nil) 197 | So(err, ShouldBeNil) 198 | req.Host = "l" 199 | 200 | rr := httptest.NewRecorder() 201 | handler.ServeHTTP(rr, req) 202 | 203 | Convey("The result should be a 302 to http://localhost:8080/", func() { 204 | So(rr.Code, ShouldEqual, http.StatusFound) 205 | So(rr.Header().Get("Location"), ShouldEqual, "http://localhost:8080/") 206 | }) 207 | }) 208 | Convey("When we GET http://l/a/s with ssl_off", func() { 209 | req, err := http.NewRequest("GET", "/a/s", nil) 210 | So(err, ShouldBeNil) 211 | req.Host = "l" 212 | 213 | rr := httptest.NewRecorder() 214 | handler.ServeHTTP(rr, req) 215 | 216 | Convey("The result should be a 302 to http://localhost:8080/service", func() { 217 | So(rr.Code, ShouldEqual, http.StatusFound) 218 | So(rr.Header().Get("Location"), ShouldEqual, "http://localhost:8080/service") 219 | }) 220 | }) 221 | Convey("When we GET http://l/a/s/ with ssl_off", func() { 222 | req, err := http.NewRequest("GET", "/a/s/", nil) 223 | So(err, ShouldBeNil) 224 | req.Host = "l" 225 | 226 | rr := httptest.NewRecorder() 227 | handler.ServeHTTP(rr, req) 228 | 229 | Convey("The result should be a 302 to http://localhost:8080/service/", func() { 230 | So(rr.Code, ShouldEqual, http.StatusFound) 231 | So(rr.Header().Get("Location"), ShouldEqual, "http://localhost:8080/service/") 232 | }) 233 | }) 234 | 235 | Convey("When we GET http://ch/ with schema set to 'chrome' ", func() { 236 | req, err := http.NewRequest("GET", "/", nil) 237 | So(err, ShouldBeNil) 238 | req.Host = "ch" 239 | 240 | rr := httptest.NewRecorder() 241 | handler.ServeHTTP(rr, req) 242 | 243 | expected := "chrome://" 244 | Convey(fmt.Sprintf("The result should be a 302 to %s", expected), func() { 245 | So(rr.Code, ShouldEqual, http.StatusFound) 246 | So(rr.Header().Get("Location"), ShouldEqual, expected) 247 | }) 248 | }) 249 | 250 | Convey("When we GET http://ch/foobar with schema set to 'chrome' where 'foobar' isn't in the Config ", func() { 251 | req, err := http.NewRequest("GET", "/foobar", nil) 252 | So(err, ShouldBeNil) 253 | req.Host = "ch" 254 | 255 | rr := httptest.NewRecorder() 256 | handler.ServeHTTP(rr, req) 257 | 258 | expected := "chrome://foobar" 259 | Convey(fmt.Sprintf("The result should be a 302 to %s", expected), func() { 260 | So(rr.Code, ShouldEqual, http.StatusFound) 261 | So(rr.Header().Get("Location"), ShouldEqual, expected) 262 | }) 263 | }) 264 | 265 | Convey("When we GET http://ch/v with schema set to 'chrome' ", func() { 266 | req, err := http.NewRequest("GET", "/v", nil) 267 | So(err, ShouldBeNil) 268 | req.Host = "ch" 269 | 270 | rr := httptest.NewRecorder() 271 | handler.ServeHTTP(rr, req) 272 | 273 | expected := "chrome://version" 274 | Convey(fmt.Sprintf("The result should be a 302 to %s", expected), func() { 275 | So(rr.Code, ShouldEqual, http.StatusFound) 276 | So(rr.Header().Get("Location"), ShouldEqual, expected) 277 | }) 278 | }) 279 | 280 | Convey("When we GET http://ch/n/d with schema set to 'chrome' ", func() { 281 | req, err := http.NewRequest("GET", "/n/d", nil) 282 | So(err, ShouldBeNil) 283 | req.Host = "ch" 284 | 285 | rr := httptest.NewRecorder() 286 | handler.ServeHTTP(rr, req) 287 | 288 | expected := "chrome://net-internals/#dns" 289 | Convey(fmt.Sprintf("The result should be a 302 to %s", expected), func() { 290 | So(rr.Code, ShouldEqual, http.StatusFound) 291 | So(rr.Header().Get("Location"), ShouldEqual, expected) 292 | }) 293 | }) 294 | 295 | }) 296 | } 297 | 298 | // BenchmarkIndexHandler tests request processing speed when Context is preloaded. 299 | // Run with go test -run=BenchmarkIndexHandler -bench=. // results: 500000x 2555 ns/op 300 | func BenchmarkIndexHandler(b *testing.B) { 301 | c, _ := loadTestYaml() 302 | context := &Context{Config: c} 303 | appHandler := &CtxWrapper{context, IndexHandler} 304 | handler := http.Handler(appHandler) 305 | req, _ := http.NewRequest("GET", "/z", nil) 306 | req.Host = "g" 307 | rr := httptest.NewRecorder() 308 | for n := 0; n < b.N; n++ { 309 | handler.ServeHTTP(rr, req) 310 | } 311 | } 312 | 313 | func TestHealthCheckHandler(t *testing.T) { 314 | Convey("When we GET /healthz", t, func() { 315 | req, err := http.NewRequest("GET", "/healthz", nil) 316 | So(err, ShouldBeNil) 317 | req.Host = "sd" 318 | 319 | // We create a ResponseWriter (which satisfies http.ResponseWriter) to record the response. 320 | rr := httptest.NewRecorder() 321 | handler := http.HandlerFunc(HealthHandler) 322 | handler.ServeHTTP(rr, req) 323 | 324 | Convey("We should get a 200", func() { 325 | So(rr.Code, ShouldEqual, http.StatusOK) 326 | So(rr.Body.String(), ShouldEqual, "OK") 327 | }) 328 | }) 329 | } 330 | 331 | func TestVarzHandler(t *testing.T) { 332 | Convey("Given app is set up with default Config", t, func() { 333 | c, err := loadTestYaml() 334 | So(err, ShouldBeNil) 335 | context := &Context{Config: c} 336 | 337 | appHandler := &CtxWrapper{context, VarsHandler} 338 | handler := http.Handler(appHandler) 339 | Convey("When we GET /varz", func() { 340 | req, err := http.NewRequest("GET", "/varz", nil) 341 | So(err, ShouldBeNil) 342 | req.Host = "sd" 343 | 344 | rr := httptest.NewRecorder() 345 | handler.ServeHTTP(rr, req) 346 | 347 | Convey("We should get a 200", func() { 348 | So(rr.Code, ShouldEqual, http.StatusOK) 349 | }) 350 | Convey("It should be valid json", func() { 351 | _, err := yaml.YAMLToJSON(rr.Body.Bytes()) 352 | So(err, ShouldBeNil) 353 | }) 354 | Convey("It should equal the Config file", func() { 355 | conf, err := yaml.YAMLToJSON(c.Bytes()) 356 | So(err, ShouldBeNil) 357 | 358 | resp, err := yaml.YAMLToJSON(rr.Body.Bytes()) 359 | So(err, ShouldBeNil) 360 | 361 | // This does not work: "So(resp, ShouldEqual, []byte(jsonPrettyPrint(string(conf))))" 362 | // We get a nicely formatted response, but when we feed it into YAMLToJSON it collapses our nice 363 | // newlines. As a result, directly comparing the byte arrays here is a nogo. Therefore, we cheat 364 | // and utilize the separately tested jsonPrettyPrint to idempotently indent the JSON and compare that. 365 | So(jsonPrettyPrint(string(resp)), ShouldEqual, jsonPrettyPrint(string(conf))) 366 | }) 367 | }) 368 | }) 369 | } 370 | -------------------------------------------------------------------------------- /e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PORT=16000 3 | # sanity check for build env 4 | if [[ ! -e zap ]]; then 5 | echo "no zap binary present" 6 | exit 1 7 | fi 8 | 9 | # start zap, fork. 10 | ./zap --port $PORT 1>/dev/null 2>/dev/null & 11 | ZAP_PID=$! 12 | 13 | # wait for port to open 14 | while ! nc -z localhost $PORT /dev/null; do sleep 1; done 15 | 16 | # pull results. 17 | RESP="$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -H 'Host: g' localhost:$PORT/z)" 18 | 19 | # Check response 20 | expected="302 https://github.com/issmirnov/zap" 21 | if [[ $RESP != $expected ]];then 22 | echo "Status code or location don't match expectations" 23 | echo "expected: $expected" 24 | echo "got: $RESP" 25 | kill -9 $ZAP_PID 26 | exit 2 27 | fi 28 | 29 | # cleanup 30 | kill -9 $ZAP_PID 31 | echo "End to end test passed." 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/issmirnov/zap 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/Jeffail/gabs/v2 v2.0.0 7 | github.com/fsnotify/fsnotify v1.4.7 8 | github.com/ghodss/yaml v1.0.0 9 | github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c // indirect 10 | github.com/hashicorp/go-multierror v1.0.0 11 | github.com/julienschmidt/httprouter v1.2.0 12 | github.com/kr/pretty v0.1.0 // indirect 13 | github.com/smartystreets/assertions v1.0.0 // indirect 14 | github.com/smartystreets/goconvey v0.0.0-20190710185942-9d28bd7c0945 15 | github.com/spf13/afero v1.2.2 16 | golang.org/x/exp/errors v0.0.0-20200901203048-c4f52b2c50aa 17 | golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a // indirect 18 | golang.org/x/text v0.3.2 // indirect 19 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 20 | gopkg.in/yaml.v2 v2.4.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Jeffail/gabs/v2 v2.0.0 h1:HDDyYkQSgnYNVuQzVc2Vy3Ezl5wkZ+HoJPQcZrZU/xw= 2 | github.com/Jeffail/gabs/v2 v2.0.0/go.mod h1:xCn81vdHKxFUuWWAaD5jCTQDNPBMh5pPs9IJ+NcziBI= 3 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 4 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 5 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 6 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 7 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 8 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 9 | github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= 10 | github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 11 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 12 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 13 | github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= 14 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 15 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 16 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 17 | github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= 18 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 19 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 20 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 21 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 22 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 23 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 24 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 25 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 26 | github.com/smartystreets/assertions v1.0.0 h1:UVQPSSmc3qtTi+zPPkCXvZX9VvW/xT/NsRvKfwY81a8= 27 | github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= 28 | github.com/smartystreets/goconvey v0.0.0-20190710185942-9d28bd7c0945 h1:N8Bg45zpk/UcpNGnfJt2y/3lRWASHNTUET8owPYCgYI= 29 | github.com/smartystreets/goconvey v0.0.0-20190710185942-9d28bd7c0945/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 30 | github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= 31 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 32 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 33 | golang.org/x/exp v0.0.0-20200901203048-c4f52b2c50aa h1:i1+omYRtqpxiCaQJB4MQhUToKvMPFqUUJKvRiRp0gtE= 34 | golang.org/x/exp/errors v0.0.0-20200901203048-c4f52b2c50aa h1:KuOC783xRi5lkhiU5v/+uXV++4UbZgc3o/STU05zqeg= 35 | golang.org/x/exp/errors v0.0.0-20200901203048-c4f52b2c50aa/go.mod h1:YgqsNsAu4fTvlab/7uiYK9LJrCIzKg/NiZUIH1/ayqo= 36 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 37 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 38 | golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7 h1:LepdCS8Gf/MVejFIt8lsiexZATdoGVyp5bcyS+rYoUI= 39 | golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 40 | golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a h1:N2T1jUrTQE9Re6TFF5PhvEHXHCguynGhKjWVsIUt5cY= 41 | golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 43 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 44 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 45 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 46 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 47 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 51 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 53 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 54 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 55 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 56 | -------------------------------------------------------------------------------- /goreleaser.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/goreleaser/goreleaser/blob/master/.goreleaser.yml 2 | builds: 3 | - main: ./cmd/main.go 4 | binary: zap 5 | goos: 6 | - darwin 7 | - linux 8 | goarch: 9 | - amd64 10 | - 386 11 | - arm64 12 | ignore: 13 | - goos: darwin 14 | goarch: 386 15 | ldflags: 16 | - -s -w 17 | env: 18 | - CGO_ENABLED=0 19 | checksum: 20 | name_template: '{{ .ProjectName }}_checksums.txt' 21 | archives: 22 | - id: cmd 23 | name_template: >- 24 | {{- .ProjectName }}_ 25 | {{- title .Os }}_ 26 | {{- if eq .Arch "amd64" }}64-bit 27 | {{- else if eq .Arch "386" }}32-bit 28 | {{- else }}{{ .Arch }}{{ end }} 29 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 30 | files: 31 | - c.yml 32 | - README.md 33 | - LICENSE 34 | -------------------------------------------------------------------------------- /zap_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/issmirnov/zap/2bc69b13082f64502e1cf77567e320982fb36422/zap_demo.gif --------------------------------------------------------------------------------