first commit
parent
9911a3c7fb
commit
54b12659ba
@ -0,0 +1,14 @@
|
||||
FROM golang:1.15
|
||||
|
||||
WORKDIR /go/src/github.com/mccutchen/go-httpbin
|
||||
|
||||
# Manually implement the subset of `make deps` we need to build the image
|
||||
RUN cd /tmp && go get -u github.com/kevinburke/go-bindata/...
|
||||
|
||||
COPY . .
|
||||
RUN make build buildtests
|
||||
|
||||
FROM gcr.io/distroless/base
|
||||
COPY --from=0 /go/src/github.com/mccutchen/go-httpbin/dist/go-httpbin* /bin/
|
||||
EXPOSE 8080
|
||||
CMD ["/bin/go-httpbin"]
|
||||
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016 Will McCutchen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@ -0,0 +1,166 @@
|
||||
.PHONY: clean deploy deps gcloud-auth image imagepush lint run stagedeploy test testci testcover
|
||||
|
||||
# The version that will be used in docker tags (e.g. to push a
|
||||
# go-httpbin:latest image use `make imagepush VERSION=latest)`
|
||||
VERSION ?= $(shell git rev-parse --short HEAD)
|
||||
|
||||
# Override these values to deploy to a different Cloud Run project
|
||||
GCLOUD_PROJECT ?= httpbingo
|
||||
GCLOUD_ACCOUNT ?= mccutchen@gmail.com
|
||||
GCLOUD_REGION ?= us-central1
|
||||
|
||||
# The version tag for the Cloud Run deployment (override this to adjust
|
||||
# pre-production URLs)
|
||||
GCLOUD_TAG ?= "v-$(VERSION)"
|
||||
|
||||
# Run gcloud in a container to avoid needing to install the SDK locally
|
||||
GCLOUD_COMMAND ?= ./bin/gcloud
|
||||
|
||||
# We push docker images to both docker hub and gcr.io
|
||||
DOCKER_TAG_DOCKERHUB ?= mccutchen/go-httpbin:$(VERSION)
|
||||
DOCKER_TAG_GCLOUD ?= gcr.io/$(GCLOUD_PROJECT)/go-httpbin:$(VERSION)
|
||||
|
||||
# Built binaries will be placed here
|
||||
DIST_PATH ?= dist
|
||||
|
||||
# Default flags used by the test, testci, testcover targets
|
||||
COVERAGE_PATH ?= coverage.txt
|
||||
COVERAGE_ARGS ?= -covermode=atomic -coverprofile=$(COVERAGE_PATH)
|
||||
TEST_ARGS ?= -race
|
||||
|
||||
# Tool dependencies
|
||||
TOOL_BIN_DIR ?= $(shell go env GOPATH)/bin
|
||||
TOOL_GOBINDATA := $(TOOL_BIN_DIR)/go-bindata
|
||||
TOOL_GOLINT := $(TOOL_BIN_DIR)/golint
|
||||
TOOL_STATICCHECK := $(TOOL_BIN_DIR)/staticcheck
|
||||
|
||||
GO_SOURCES = $(wildcard **/*.go)
|
||||
|
||||
GENERATED_ASSETS_PATH := httpbin/assets/assets.go
|
||||
|
||||
# =============================================================================
|
||||
# build
|
||||
# =============================================================================
|
||||
build: $(DIST_PATH)/go-httpbin
|
||||
|
||||
$(DIST_PATH)/go-httpbin: assets $(GO_SOURCES)
|
||||
mkdir -p $(DIST_PATH)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(DIST_PATH)/go-httpbin ./cmd/go-httpbin
|
||||
|
||||
assets: $(GENERATED_ASSETS_PATH)
|
||||
|
||||
buildtests:
|
||||
CGO_ENABLED=0 go test -ldflags="-s -w" -v -c -o $(DIST_PATH)/go-httpbin.test ./httpbin
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST_PATH) $(COVERAGE_PATH)
|
||||
|
||||
$(GENERATED_ASSETS_PATH): $(TOOL_GOBINDATA) static/*
|
||||
$(TOOL_GOBINDATA) -o $(GENERATED_ASSETS_PATH) -pkg=assets -prefix=static -modtime=1601471052 static
|
||||
# reformat generated code
|
||||
gofmt -s -w $(GENERATED_ASSETS_PATH)
|
||||
# dumb hack to make generate code lint correctly
|
||||
sed -i.bak 's/Html/HTML/g' $(GENERATED_ASSETS_PATH)
|
||||
sed -i.bak 's/Xml/XML/g' $(GENERATED_ASSETS_PATH)
|
||||
rm $(GENERATED_ASSETS_PATH).bak
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# test & lint
|
||||
# =============================================================================
|
||||
test:
|
||||
go test $(TEST_ARGS) ./...
|
||||
|
||||
|
||||
# Test command to run for continuous integration, which includes code coverage
|
||||
# based on codecov.io's documentation:
|
||||
# https://github.com/codecov/example-go/blob/b85638743b972bd0bd2af63421fe513c6f968930/README.md
|
||||
testci: build
|
||||
go test $(TEST_ARGS) $(COVERAGE_ARGS) ./...
|
||||
git diff --exit-code
|
||||
|
||||
testcover: testci
|
||||
go tool cover -html=$(COVERAGE_PATH)
|
||||
|
||||
lint: $(TOOL_GOLINT) $(TOOL_STATICCHECK)
|
||||
test -z "$$(gofmt -d -s -e .)" || (echo "Error: gofmt failed"; gofmt -d -s -e . ; exit 1)
|
||||
go vet ./...
|
||||
$(TOOL_GOLINT) -set_exit_status ./...
|
||||
$(TOOL_STATICCHECK) ./...
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# run locally
|
||||
# =============================================================================
|
||||
run: build
|
||||
$(DIST_PATH)/go-httpbin
|
||||
|
||||
watch: $(TOOL_REFLEX)
|
||||
reflex -s -r '\.(go|html)$$' make run
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# deploy to fly.io
|
||||
# =============================================================================
|
||||
deploy:
|
||||
flyctl deploy --strategy=rolling
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# deploy to google cloud run
|
||||
# =============================================================================
|
||||
deploy-cloud-run: gcloud-auth imagepush
|
||||
$(GCLOUD_COMMAND) beta run deploy \
|
||||
$(GCLOUD_PROJECT) \
|
||||
--image=$(DOCKER_TAG_GCLOUD) \
|
||||
--revision-suffix=$(VERSION) \
|
||||
--tag=$(GCLOUD_TAG) \
|
||||
--project=$(GCLOUD_PROJECT) \
|
||||
--region=$(GCLOUD_REGION) \
|
||||
--allow-unauthenticated \
|
||||
--platform=managed
|
||||
$(GCLOUD_COMMAND) run services update-traffic --to-latest
|
||||
|
||||
stagedeploy-cloud-run: gcloud-auth imagepush
|
||||
$(GCLOUD_COMMAND) beta run deploy \
|
||||
$(GCLOUD_PROJECT) \
|
||||
--image=$(DOCKER_TAG_GCLOUD) \
|
||||
--revision-suffix=$(VERSION) \
|
||||
--tag=$(GCLOUD_TAG) \
|
||||
--project=$(GCLOUD_PROJECT) \
|
||||
--region=$(GCLOUD_REGION) \
|
||||
--allow-unauthenticated \
|
||||
--platform=managed \
|
||||
--no-traffic
|
||||
|
||||
gcloud-auth:
|
||||
@$(GCLOUD_COMMAND) auth list | grep '^\*' | grep -q $(GCLOUD_ACCOUNT) || $(GCLOUD_COMMAND) auth login $(GCLOUD_ACCOUNT)
|
||||
@$(GCLOUD_COMMAND) auth print-access-token | docker login -u oauth2accesstoken --password-stdin https://gcr.io
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# docker images
|
||||
# =============================================================================
|
||||
image:
|
||||
docker build -t $(DOCKER_TAG_DOCKERHUB) .
|
||||
|
||||
imagepush: image
|
||||
docker push $(DOCKER_TAG_DOCKERHUB)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# dependencies
|
||||
#
|
||||
# Deps are installed outside of working dir to avoid polluting go modules
|
||||
# =============================================================================
|
||||
$(TOOL_GOBINDATA):
|
||||
cd /tmp && go get -u github.com/kevinburke/go-bindata/...
|
||||
|
||||
$(TOOL_GOLINT):
|
||||
cd /tmp && go get -u golang.org/x/lint/golint
|
||||
|
||||
$(TOOL_REFLEX):
|
||||
cd /tmp && go get -u github.com/cespare/reflex
|
||||
|
||||
$(TOOL_STATICCHECK):
|
||||
cd /tmp && go get -u honnef.co/go/tools/cmd/staticcheck
|
||||
@ -1,3 +1,139 @@
|
||||
# go-httpbin-master
|
||||
# go-httpbin
|
||||
|
||||
go-httpbin-master
|
||||
A reasonably complete and well-tested golang port of [Kenneth Reitz][kr]'s
|
||||
[httpbin][httpbin-org] service, with zero dependencies outside the go stdlib.
|
||||
|
||||
[](https://godoc.org/github.com/mccutchen/go-httpbin)
|
||||
[](http://travis-ci.org/mccutchen/go-httpbin)
|
||||
[](https://codecov.io/gh/mccutchen/go-httpbin)
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Run as a standalone binary, configured by command line flags or environment
|
||||
variables:
|
||||
|
||||
```
|
||||
$ go-httpbin -help
|
||||
Usage of go-httpbin:
|
||||
-host string
|
||||
Host to listen on (default "0.0.0.0")
|
||||
-port int
|
||||
Port to listen on (default 8080)
|
||||
-https-cert-file string
|
||||
HTTPS certificate file
|
||||
-https-key-file string
|
||||
HTTPS private key file
|
||||
-max-body-size int
|
||||
Maximum size of request or response, in bytes (default 1048576)
|
||||
-max-duration duration
|
||||
Maximum duration a response may take (default 10s)
|
||||
|
||||
Examples:
|
||||
# Run http server
|
||||
$ go-httpbin -host 127.0.0.1 -port 8081
|
||||
|
||||
# Run https server
|
||||
|
||||
# Generate .crt and .key files
|
||||
$ openssl genrsa -out server.key 2048
|
||||
$ openssl ecparam -genkey -name secp384r1 -out server.key
|
||||
$ openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650
|
||||
|
||||
$ go-httpbin -host 127.0.0.1 -port 8081 -https-cert-file ./server.crt -https-key-file ./server.key
|
||||
```
|
||||
|
||||
Docker images are published to [Docker Hub][docker-hub]:
|
||||
|
||||
```
|
||||
# Run http server
|
||||
$ docker run -P mccutchen/go-httpbin
|
||||
|
||||
# Run https server
|
||||
$ docker run -e HTTPS_CERT_FILE='/tmp/server.crt' -e HTTPS_KEY_FILE='/tmp/server.key' -p 8080:8080 -v /tmp:/tmp mccutchen/go-httpbin
|
||||
```
|
||||
|
||||
The `github.com/mccutchen/go-httpbin/httpbin` package can also be used as a
|
||||
library for testing an applications interactions with an upstream HTTP service,
|
||||
like so:
|
||||
|
||||
```go
|
||||
package httpbin_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mccutchen/go-httpbin/httpbin"
|
||||
)
|
||||
|
||||
func TestSlowResponse(t *testing.T) {
|
||||
svc := httpbin.New()
|
||||
srv := httptest.NewServer(svc.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
client := http.Client{
|
||||
Timeout: time.Duration(1 * time.Second),
|
||||
}
|
||||
_, err := client.Get(srv.URL + "/delay/10")
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
go get github.com/mccutchen/go-httpbin/cmd/go-httpbin
|
||||
```
|
||||
|
||||
|
||||
## Motivation & prior art
|
||||
|
||||
I've been a longtime user of [Kenneith Reitz][kr]'s original
|
||||
[httpbin.org][httpbin-org], and wanted to write a golang port for fun and to
|
||||
see how far I could get using only the stdlib.
|
||||
|
||||
When I started this project, there were a handful of existing and incomplete
|
||||
golang ports, with the most promising being [ahmetb/go-httpbin][ahmet]. This
|
||||
project showed me how useful it might be to have an `httpbin` _library_
|
||||
available for testing golang applications.
|
||||
|
||||
### Known differences from other httpbin versions
|
||||
|
||||
Compared to [the original][httpbin-org]:
|
||||
- No `/brotli` endpoint (due to lack of support in Go's stdlib)
|
||||
- The `?show_env=1` query param is ignored (i.e. no special handling of
|
||||
runtime environment headers)
|
||||
- Response values which may be encoded as either a string or a list of strings
|
||||
will always be encoded as a list of strings (e.g. request headers, query
|
||||
params, form values)
|
||||
|
||||
Compared to [ahmetb/go-httpbin][ahmet]:
|
||||
- No dependencies on 3rd party packages
|
||||
- More complete implementation of endpoints
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# local development
|
||||
make
|
||||
make test
|
||||
make testcover
|
||||
make run
|
||||
|
||||
# building & pushing docker images
|
||||
make image
|
||||
make imagepush
|
||||
```
|
||||
|
||||
[kr]: https://github.com/kennethreitz
|
||||
[httpbin-org]: https://httpbin.org/
|
||||
[httpbin-repo]: https://github.com/kennethreitz/httpbin
|
||||
[ahmet]: https://github.com/ahmetb/go-httpbin
|
||||
[docker-hub]: https://hub.docker.com/r/mccutchen/go-httpbin/
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
---
|
||||
runtime: go113
|
||||
|
||||
main: ./cmd/go_httpbin
|
||||
|
||||
handlers:
|
||||
# Always redirect index requests to https
|
||||
- url: /
|
||||
script: auto
|
||||
secure: always
|
||||
redirect_http_response_code: 301
|
||||
|
||||
# Allow requests for any other resources via either http or https
|
||||
- url: /.+
|
||||
script: auto
|
||||
secure: optional
|
||||
@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# A wrapper that executes the gcloud CLI in a docker container, to avoid
|
||||
# requiring a local installation.
|
||||
#
|
||||
# Adapted from this helpful blog post:
|
||||
# https://blog.scottlowe.org/2018/09/13/running-gcloud-cli-in-a-docker-container/
|
||||
|
||||
GCLOUD_SDK_TAG="312.0.0"
|
||||
|
||||
exec docker run \
|
||||
--rm -it \
|
||||
--workdir /code \
|
||||
-v $PWD:/code \
|
||||
-v $HOME/.config/gcloud:/root/.config/gcloud \
|
||||
google/cloud-sdk:$GCLOUD_SDK_TAG \
|
||||
gcloud $*
|
||||
@ -0,0 +1,38 @@
|
||||
# What is going on here?
|
||||
|
||||
## TL;DR
|
||||
|
||||
* `cmd/maincmd` package exposes all of this app's command line functionality in a `Main()`
|
||||
|
||||
* `cmd/go-httpbin` and `cmd/go_httpbin` build identical binaries using the
|
||||
`maincmd` package for backwards compatibility reasons explained below
|
||||
|
||||
## Why tho
|
||||
|
||||
Originally, this project exposed only one command:
|
||||
|
||||
cmd/go-httpbin/main.go
|
||||
|
||||
But the dash in that path was incompatible with Google App Engine's naming
|
||||
restrictions, so in [moving httpbingo.org onto Google App Engine][pr17], that
|
||||
path was (carelessly) renamed to
|
||||
|
||||
cmd/go_httpbin/main.go
|
||||
|
||||
_That_ change had a number of unintended consequences:
|
||||
|
||||
* It broke existing workflows built around `go get github.com/mccutchen/go-httpbin/cmd/go-httpbin`,
|
||||
as suggested in the README
|
||||
|
||||
* It broke the Makefile, which was still looking for `cmd/go-httpbin`
|
||||
|
||||
* It broke the absolute aesthetic truth that CLI binaries should use dashes
|
||||
instead of underscores for word separators
|
||||
|
||||
So, to restore the former behavior while maintaining support for deploying to
|
||||
App Engine, the actual main functionality was extracted into the `cmd/maincmd`
|
||||
package here and shared between the other two.
|
||||
|
||||
(This is pretty dumb, I know, but it seems to work.)
|
||||
|
||||
[pr17]: https://github.com/mccutchen/go-httpbin/pull/17
|
||||
@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "github.com/mccutchen/go-httpbin/cmd/maincmd"
|
||||
|
||||
func main() {
|
||||
maincmd.Main()
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "github.com/mccutchen/go-httpbin/cmd/maincmd"
|
||||
|
||||
func main() {
|
||||
maincmd.Main()
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
package maincmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mccutchen/go-httpbin/httpbin"
|
||||
)
|
||||
|
||||
const defaultHost = "0.0.0.0"
|
||||
const defaultPort = 8080
|
||||
|
||||
var (
|
||||
host string
|
||||
port int
|
||||
maxBodySize int64
|
||||
maxDuration time.Duration
|
||||
httpsCertFile string
|
||||
httpsKeyFile string
|
||||
)
|
||||
|
||||
// Main implements the go-httpbin CLI's main() function in a reusable way
|
||||
func Main() {
|
||||
flag.StringVar(&host, "host", defaultHost, "Host to listen on")
|
||||
flag.IntVar(&port, "port", defaultPort, "Port to listen on")
|
||||
flag.StringVar(&httpsCertFile, "https-cert-file", "", "HTTPS Server certificate file")
|
||||
flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file")
|
||||
flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
|
||||
flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
|
||||
flag.Parse()
|
||||
|
||||
// Command line flags take precedence over environment vars, so we only
|
||||
// check for environment vars if we have default values for our command
|
||||
// line flags.
|
||||
var err error
|
||||
if maxBodySize == httpbin.DefaultMaxBodySize && os.Getenv("MAX_BODY_SIZE") != "" {
|
||||
maxBodySize, err = strconv.ParseInt(os.Getenv("MAX_BODY_SIZE"), 10, 64)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_BODY_SIZE: %s\n\n", os.Getenv("MAX_BODY_SIZE"), err)
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if maxDuration == httpbin.DefaultMaxDuration && os.Getenv("MAX_DURATION") != "" {
|
||||
maxDuration, err = time.ParseDuration(os.Getenv("MAX_DURATION"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_DURATION: %s\n\n", os.Getenv("MAX_DURATION"), err)
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if host == defaultHost && os.Getenv("HOST") != "" {
|
||||
host = os.Getenv("HOST")
|
||||
}
|
||||
if port == defaultPort && os.Getenv("PORT") != "" {
|
||||
port, err = strconv.Atoi(os.Getenv("PORT"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var PORT: %s\n\n", os.Getenv("PORT"), err)
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if httpsCertFile == "" && os.Getenv("HTTPS_CERT_FILE") != "" {
|
||||
httpsCertFile = os.Getenv("HTTPS_CERT_FILE")
|
||||
}
|
||||
if httpsKeyFile == "" && os.Getenv("HTTPS_KEY_FILE") != "" {
|
||||
httpsKeyFile = os.Getenv("HTTPS_KEY_FILE")
|
||||
}
|
||||
|
||||
var serveTLS bool
|
||||
if httpsCertFile != "" || httpsKeyFile != "" {
|
||||
serveTLS = true
|
||||
if httpsCertFile == "" || httpsKeyFile == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: https cert and key must both be provided\n\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
logger := log.New(os.Stderr, "", 0)
|
||||
|
||||
// A hacky log helper function to ensure that shutdown messages are
|
||||
// formatted the same as other messages. See StdLogObserver in
|
||||
// httpbin/middleware.go for the format we're matching here.
|
||||
serverLog := func(msg string, args ...interface{}) {
|
||||
const (
|
||||
logFmt = "time=%q msg=%q"
|
||||
dateFmt = "2006-01-02T15:04:05.9999"
|
||||
)
|
||||
logger.Printf(logFmt, time.Now().Format(dateFmt), fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
h := httpbin.New(
|
||||
httpbin.WithMaxBodySize(maxBodySize),
|
||||
httpbin.WithMaxDuration(maxDuration),
|
||||
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
|
||||
)
|
||||
|
||||
listenAddr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
server := &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: h.Handler(),
|
||||
}
|
||||
|
||||
// shutdownCh triggers graceful shutdown on SIGINT or SIGTERM
|
||||
shutdownCh := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// exitCh will be closed when it is safe to exit, after graceful shutdown
|
||||
exitCh := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
sig := <-shutdownCh
|
||||
serverLog("shutdown started by signal: %s", sig)
|
||||
|
||||
shutdownTimeout := maxDuration + 1*time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
server.SetKeepAlivesEnabled(false)
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
serverLog("shutdown error: %s", err)
|
||||
}
|
||||
|
||||
close(exitCh)
|
||||
}()
|
||||
|
||||
var listenErr error
|
||||
if serveTLS {
|
||||
serverLog("go-httpbin listening on https://%s", listenAddr)
|
||||
listenErr = server.ListenAndServeTLS(httpsCertFile, httpsKeyFile)
|
||||
} else {
|
||||
serverLog("go-httpbin listening on http://%s", listenAddr)
|
||||
listenErr = server.ListenAndServe()
|
||||
}
|
||||
if listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
logger.Fatalf("failed to listen: %s", listenErr)
|
||||
}
|
||||
|
||||
<-exitCh
|
||||
serverLog("shutdown finished")
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
app = "httpbingo"
|
||||
|
||||
[[services]]
|
||||
internal_port = 8080
|
||||
protocol = "tcp"
|
||||
|
||||
[services.concurrency]
|
||||
hard_limit = 25
|
||||
soft_limit = 20
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["http"]
|
||||
port = "80"
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["tls", "http"]
|
||||
port = "443"
|
||||
|
||||
[[services.tcp_checks]]
|
||||
interval = 10000
|
||||
timeout = 2000
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,221 @@
|
||||
// Package digest provides a limited implementation of HTTP Digest
|
||||
// Authentication, as defined in RFC 2617.
|
||||
//
|
||||
// Only the "auth" QOP directive is handled at this time, and while support for
|
||||
// the SHA-256 algorithm is implemented here it does not actually work in
|
||||
// either Chrome or Firefox.
|
||||
//
|
||||
// For more info, see:
|
||||
// https://tools.ietf.org/html/rfc2617
|
||||
// https://en.wikipedia.org/wiki/Digest_access_authentication
|
||||
package digest
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// digestAlgorithm is an algorithm used to hash digest payloads
|
||||
type digestAlgorithm int
|
||||
|
||||
// Digest algorithms supported by this package
|
||||
const (
|
||||
MD5 digestAlgorithm = iota
|
||||
SHA256
|
||||
)
|
||||
|
||||
func (a digestAlgorithm) String() string {
|
||||
switch a {
|
||||
case MD5:
|
||||
return "MD5"
|
||||
case SHA256:
|
||||
return "SHA-256"
|
||||
}
|
||||
return "UNKNOWN"
|
||||
}
|
||||
|
||||
// Check returns a bool indicating whether the request is correctly
|
||||
// authenticated for the given username and password.
|
||||
func Check(req *http.Request, username, password string) bool {
|
||||
auth := parseAuthorizationHeader(req.Header.Get("Authorization"))
|
||||
if auth == nil || auth.username != username {
|
||||
return false
|
||||
}
|
||||
expectedResponse := response(auth, password, req.Method, req.RequestURI)
|
||||
return compare(auth.response, expectedResponse)
|
||||
}
|
||||
|
||||
// Challenge returns a WWW-Authenticate header value for the given realm and
|
||||
// algorithm. If an invalid realm or an unsupported algorithm is given
|
||||
func Challenge(realm string, algorithm digestAlgorithm) string {
|
||||
entropy := make([]byte, 32)
|
||||
rand.Read(entropy)
|
||||
|
||||
opaqueVal := entropy[:16]
|
||||
nonceVal := fmt.Sprintf("%s:%x", time.Now(), entropy[16:31])
|
||||
|
||||
// we use MD5 to hash nonces regardless of hash used for authentication
|
||||
opaque := hash(opaqueVal, MD5)
|
||||
nonce := hash([]byte(nonceVal), MD5)
|
||||
|
||||
return fmt.Sprintf("Digest qop=auth, realm=%#v, algorithm=%s, nonce=%s, opaque=%s", sanitizeRealm(realm), algorithm, nonce, opaque)
|
||||
}
|
||||
|
||||
// sanitizeRealm tries to ensure that a given realm does not include any
|
||||
// characters that will trip up our extremely simplistic header parser.
|
||||
func sanitizeRealm(realm string) string {
|
||||
realm = strings.Replace(realm, `"`, "", -1)
|
||||
realm = strings.Replace(realm, ",", "", -1)
|
||||
return realm
|
||||
}
|
||||
|
||||
// authorization is the result of parsing an Authorization header
|
||||
type authorization struct {
|
||||
algorithm digestAlgorithm
|
||||
cnonce string
|
||||
nc string
|
||||
nonce string
|
||||
opaque string
|
||||
qop string
|
||||
realm string
|
||||
response string
|
||||
uri string
|
||||
username string
|
||||
}
|
||||
|
||||
// parseAuthorizationHeader parses an Authorization header into an
|
||||
// Authorization struct, given a an authorization header like:
|
||||
//
|
||||
// Authorization: Digest username="Mufasa",
|
||||
// realm="testrealm@host.com",
|
||||
// nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
|
||||
// uri="/dir/index.html",
|
||||
// qop=auth,
|
||||
// nc=00000001,
|
||||
// cnonce="0a4f113b",
|
||||
// response="6629fae49393a05397450978507c4ef1",
|
||||
// opaque="5ccc069c403ebaf9f0171e9517f40e41"
|
||||
//
|
||||
// If the given value does not contain a Digest authorization header, or is in
|
||||
// some other way malformed, nil is returned.
|
||||
//
|
||||
// Example from Wikipedia: https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation
|
||||
func parseAuthorizationHeader(value string) *authorization {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(value, " ", 2)
|
||||
if parts[0] != "Digest" || len(parts) != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
authInfo := parts[1]
|
||||
auth := parseDictHeader(authInfo)
|
||||
|
||||
algo := MD5
|
||||
if strings.ToLower(auth["algorithm"]) == "sha-256" {
|
||||
algo = SHA256
|
||||
}
|
||||
|
||||
return &authorization{
|
||||
algorithm: algo,
|
||||
cnonce: auth["cnonce"],
|
||||
nc: auth["nc"],
|
||||
nonce: auth["nonce"],
|
||||
opaque: auth["opaque"],
|
||||
qop: auth["qop"],
|
||||
realm: auth["realm"],
|
||||
response: auth["response"],
|
||||
uri: auth["uri"],
|
||||
username: auth["username"],
|
||||
}
|
||||
}
|
||||
|
||||
// parseDictHeader is a simplistic, buggy, and incomplete implementation of
|
||||
// parsing key-value pairs from a header value into a map.
|
||||
func parseDictHeader(value string) map[string]string {
|
||||
pairs := strings.Split(value, ",")
|
||||
res := make(map[string]string, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
|
||||
key := strings.TrimSpace(parts[0])
|
||||
if len(key) == 0 {
|
||||
continue
|
||||
}
|
||||
val := ""
|
||||
if len(parts) > 1 {
|
||||
val = strings.TrimSpace(parts[1])
|
||||
if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
}
|
||||
res[key] = val
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// hash generates the hex digest of the given data using the given hashing
|
||||
// algorithm, which must be one of MD5 or SHA256.
|
||||
func hash(data []byte, algorithm digestAlgorithm) string {
|
||||
switch algorithm {
|
||||
case SHA256:
|
||||
return fmt.Sprintf("%x", sha256.Sum256(data))
|
||||
default:
|
||||
return fmt.Sprintf("%x", md5.Sum(data))
|
||||
}
|
||||
}
|
||||
|
||||
// makeHA1 returns the HA1 hash, where
|
||||
//
|
||||
// HA1 = H(A1) = H(username:realm:password)
|
||||
//
|
||||
// and H is one of MD5 or SHA256.
|
||||
func makeHA1(realm, username, password string, algorithm digestAlgorithm) string {
|
||||
A1 := fmt.Sprintf("%s:%s:%s", username, realm, password)
|
||||
return hash([]byte(A1), algorithm)
|
||||
}
|
||||
|
||||
// makeHA2 returns the HA2 hash, where
|
||||
//
|
||||
// HA2 = H(A2) = H(method:digestURI)
|
||||
//
|
||||
// and H is one of MD5 or SHA256.
|
||||
func makeHA2(auth *authorization, method, uri string) string {
|
||||
A2 := fmt.Sprintf("%s:%s", method, uri)
|
||||
return hash([]byte(A2), auth.algorithm)
|
||||
}
|
||||
|
||||
// Response calculates the correct digest auth response. If the qop directive's
|
||||
// value is "auth" or "auth-int" , then compute the response as
|
||||
//
|
||||
// RESPONSE = H(HA1:nonce:nonceCount:clientNonce:qop:HA2)
|
||||
//
|
||||
// and if the qop directive is unspecified, then compute the response as
|
||||
//
|
||||
// RESPONSE = H(HA1:nonce:HA2)
|
||||
//
|
||||
// where H is one of MD5 or SHA256.
|
||||
func response(auth *authorization, password, method, uri string) string {
|
||||
ha1 := makeHA1(auth.realm, auth.username, password, auth.algorithm)
|
||||
ha2 := makeHA2(auth, method, uri)
|
||||
|
||||
var r string
|
||||
if auth.qop == "auth" || auth.qop == "auth-int" {
|
||||
r = fmt.Sprintf("%s:%s:%s:%s:%s:%s", ha1, auth.nonce, auth.nc, auth.cnonce, auth.qop, ha2)
|
||||
} else {
|
||||
r = fmt.Sprintf("%s:%s:%s", ha1, auth.nonce, ha2)
|
||||
}
|
||||
return hash([]byte(r), auth.algorithm)
|
||||
}
|
||||
|
||||
// compare is a constant-time string comparison
|
||||
func compare(x, y string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(x), []byte(y)) == 1
|
||||
}
|
||||
@ -0,0 +1,267 @@
|
||||
package digest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Well-formed examples from Wikipedia:
|
||||
// https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation
|
||||
const (
|
||||
exampleUsername = "Mufasa"
|
||||
examplePassword = "Circle Of Life"
|
||||
|
||||
exampleAuthorization string = `Digest username="Mufasa",
|
||||
realm="testrealm@host.com",
|
||||
nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
|
||||
uri="/dir/index.html",
|
||||
qop=auth,
|
||||
nc=00000001,
|
||||
cnonce="0a4f113b",
|
||||
response="6629fae49393a05397450978507c4ef1",
|
||||
opaque="5ccc069c403ebaf9f0171e9517f40e41"`
|
||||
)
|
||||
|
||||
func assertStringEquals(t *testing.T, expected, got string) {
|
||||
if expected != got {
|
||||
t.Errorf("Expected %#v, got %#v", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func buildRequest(method, uri, authHeader string) *http.Request {
|
||||
req, _ := http.NewRequest(method, uri, nil)
|
||||
req.RequestURI = uri
|
||||
if authHeader != "" {
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestCheck(t *testing.T) {
|
||||
t.Run("missing authorization", func(t *testing.T) {
|
||||
req := buildRequest("GET", "/dir/index.html", "")
|
||||
if Check(req, exampleUsername, examplePassword) != false {
|
||||
t.Error("Missing Authorization header should fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong username", func(t *testing.T) {
|
||||
req := buildRequest("GET", "/dir/index.html", exampleAuthorization)
|
||||
if Check(req, "Simba", examplePassword) != false {
|
||||
t.Error("Incorrect username should fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong password", func(t *testing.T) {
|
||||
req := buildRequest("GET", "/dir/index.html", exampleAuthorization)
|
||||
if Check(req, examplePassword, "foobar") != false {
|
||||
t.Error("Incorrect password should fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
req := buildRequest("GET", "/dir/index.html", exampleAuthorization)
|
||||
if Check(req, exampleUsername, examplePassword) != true {
|
||||
t.Error("Correct credentials should pass")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChallenge(t *testing.T) {
|
||||
var tests = []struct {
|
||||
realm string
|
||||
expectedRealm string
|
||||
algorithm digestAlgorithm
|
||||
expectedAlgorithm string
|
||||
}{
|
||||
{"realm", "realm", MD5, "MD5"},
|
||||
{"realm", "realm", SHA256, "SHA-256"},
|
||||
{"realm with spaces", "realm with spaces", SHA256, "SHA-256"},
|
||||
{`realm "with" "quotes"`, "realm with quotes", MD5, "MD5"},
|
||||
{`spaces, "quotes," and commas`, "spaces quotes and commas", MD5, "MD5"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
challenge := Challenge(test.realm, test.algorithm)
|
||||
result := parseDictHeader(challenge)
|
||||
assertStringEquals(t, test.expectedRealm, result["realm"])
|
||||
assertStringEquals(t, test.expectedAlgorithm, result["algorithm"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponse(t *testing.T) {
|
||||
auth := parseAuthorizationHeader(exampleAuthorization)
|
||||
expected := auth.response
|
||||
got := response(auth, examplePassword, "GET", "/dir/index.html")
|
||||
assertStringEquals(t, expected, got)
|
||||
}
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
var tests = []struct {
|
||||
algorithm digestAlgorithm
|
||||
data []byte
|
||||
expected string
|
||||
}{
|
||||
{SHA256, []byte("hello, world!\n"), "4dca0fd5f424a31b03ab807cbae77eb32bf2d089eed1cee154b3afed458de0dc"},
|
||||
{MD5, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"},
|
||||
|
||||
// Any unhandled hash results in MD5 being used
|
||||
{digestAlgorithm(10), []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("hash/%v", test.algorithm), func(t *testing.T) {
|
||||
result := hash(test.data, test.algorithm)
|
||||
assertStringEquals(t, test.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompare(t *testing.T) {
|
||||
if compare("foo", "bar") != false {
|
||||
t.Error("Expected foo != bar")
|
||||
}
|
||||
|
||||
if compare("foo", "foo") != true {
|
||||
t.Error("Expected foo == foo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDictHeader(t *testing.T) {
|
||||
var tests = []struct {
|
||||
input string
|
||||
expected map[string]string
|
||||
}{
|
||||
{"foo=bar", map[string]string{"foo": "bar"}},
|
||||
|
||||
// keys without values get the empty string
|
||||
{"foo", map[string]string{"foo": ""}},
|
||||
{"foo=bar, baz", map[string]string{"foo": "bar", "baz": ""}},
|
||||
|
||||
// no spaces required
|
||||
{"foo=bar,baz=quux", map[string]string{"foo": "bar", "baz": "quux"}},
|
||||
|
||||
// spaces are stripped
|
||||
{"foo=bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}},
|
||||
{"foo= bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}},
|
||||
{"foo=bar, baz = quux", map[string]string{"foo": "bar", "baz": "quux"}},
|
||||
{" foo =bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}},
|
||||
{"foo=bar,baz = quux ", map[string]string{"foo": "bar", "baz": "quux"}},
|
||||
|
||||
// quotes around values are stripped
|
||||
{`foo="bar two three four", baz=quux`, map[string]string{"foo": "bar two three four", "baz": "quux"}},
|
||||
{`foo=bar, baz=""`, map[string]string{"foo": "bar", "baz": ""}},
|
||||
|
||||
// quotes around keys are not stripped
|
||||
{`"foo"="bar", "baz two"=quux`, map[string]string{`"foo"`: "bar", `"baz two"`: "quux"}},
|
||||
|
||||
// spaces within quotes around values are preserved
|
||||
{`foo=bar, baz=" quux "`, map[string]string{"foo": "bar", "baz": " quux "}},
|
||||
|
||||
// commas values are NOT handled correctly
|
||||
{`foo="one, two, three", baz=quux`, map[string]string{"foo": `"one`, "two": "", `three"`: "", "baz": "quux"}},
|
||||
{",,,", make(map[string]string)},
|
||||
|
||||
// trailing comma is okay
|
||||
{"foo=bar,", map[string]string{"foo": "bar"}},
|
||||
{"foo=bar, ", map[string]string{"foo": "bar"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
results := parseDictHeader(test.input)
|
||||
if !reflect.DeepEqual(test.expected, results) {
|
||||
t.Errorf("expected %#v, got %#v", test.expected, results)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthorizationHeader(t *testing.T) {
|
||||
var tests = []struct {
|
||||
input string
|
||||
expected *authorization
|
||||
}{
|
||||
{"", nil},
|
||||
{"Digest", nil},
|
||||
{"Basic QWxhZGRpbjpPcGVuU2VzYW1l", nil},
|
||||
|
||||
// case sensitive on Digest
|
||||
{"digest username=u, realm=r, nonce=n", nil},
|
||||
|
||||
// incomplete headers are fine
|
||||
{"Digest username=u, realm=r, nonce=n", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
realm: "r",
|
||||
nonce: "n",
|
||||
}},
|
||||
|
||||
// algorithm can be either MD5 or SHA-256, with MD5 as default
|
||||
{"Digest username=u", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
}},
|
||||
{"Digest algorithm=MD5, username=u", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
}},
|
||||
{"Digest algorithm=md5, username=u", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
}},
|
||||
{"Digest algorithm=SHA-256, username=u", &authorization{
|
||||
algorithm: SHA256,
|
||||
username: "u",
|
||||
}},
|
||||
{"Digest algorithm=foo, username=u", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
}},
|
||||
{"Digest algorithm=SHA-512, username=u", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
}},
|
||||
// algorithm not case sensitive
|
||||
{"Digest algorithm=sha-256, username=u", &authorization{
|
||||
algorithm: SHA256,
|
||||
username: "u",
|
||||
}},
|
||||
// but dash is required in SHA-256 is not recognized
|
||||
{"Digest algorithm=SHA256, username=u", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
}},
|
||||
// session variants not recognized
|
||||
{"Digest algorithm=SHA-256-sess, username=u", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
}},
|
||||
{"Digest algorithm=MD5-sess, username=u", &authorization{
|
||||
algorithm: MD5,
|
||||
username: "u",
|
||||
}},
|
||||
|
||||
{exampleAuthorization, &authorization{
|
||||
algorithm: MD5,
|
||||
cnonce: "0a4f113b",
|
||||
nc: "00000001",
|
||||
nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093",
|
||||
opaque: "5ccc069c403ebaf9f0171e9517f40e41",
|
||||
qop: "auth",
|
||||
realm: "testrealm@host.com",
|
||||
response: "6629fae49393a05397450978507c4ef1",
|
||||
uri: "/dir/index.html",
|
||||
username: exampleUsername,
|
||||
}},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
|
||||
got := parseAuthorizationHeader(test.input)
|
||||
if !reflect.DeepEqual(test.expected, got) {
|
||||
t.Errorf("expected %#v, got %#v", test.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,983 @@
|
||||
package httpbin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mccutchen/go-httpbin/httpbin/assets"
|
||||
"github.com/mccutchen/go-httpbin/httpbin/digest"
|
||||
)
|
||||
|
||||
var acceptedMediaTypes = []string{
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/",
|
||||
}
|
||||
|
||||
func notImplementedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Index renders an HTML index page
|
||||
func (h *HTTPBin) Index(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' camo.githubusercontent.com")
|
||||
writeHTML(w, assets.MustAsset("index.html"), http.StatusOK)
|
||||
}
|
||||
|
||||
// FormsPost renders an HTML form that submits a request to the /post endpoint
|
||||
func (h *HTTPBin) FormsPost(w http.ResponseWriter, r *http.Request) {
|
||||
writeHTML(w, assets.MustAsset("forms-post.html"), http.StatusOK)
|
||||
}
|
||||
|
||||
// UTF8 renders an HTML encoding stress test
|
||||
func (h *HTTPBin) UTF8(w http.ResponseWriter, r *http.Request) {
|
||||
writeHTML(w, assets.MustAsset("utf8.html"), http.StatusOK)
|
||||
}
|
||||
|
||||
// Get handles HTTP GET requests
|
||||
func (h *HTTPBin) Get(w http.ResponseWriter, r *http.Request) {
|
||||
resp := &getResponse{
|
||||
Args: r.URL.Query(),
|
||||
Headers: getRequestHeaders(r),
|
||||
Origin: getOrigin(r),
|
||||
URL: getURL(r).String(),
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
writeJSON(w, body, http.StatusOK)
|
||||
}
|
||||
|
||||
// RequestWithBody handles POST, PUT, and PATCH requests
|
||||
func (h *HTTPBin) RequestWithBody(w http.ResponseWriter, r *http.Request) {
|
||||
resp := &bodyResponse{
|
||||
Args: r.URL.Query(),
|
||||
Headers: getRequestHeaders(r),
|
||||
Origin: getOrigin(r),
|
||||
URL: getURL(r).String(),
|
||||
}
|
||||
|
||||
err := parseBody(w, r, resp)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("error parsing request body: %s", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(resp)
|
||||
writeJSON(w, body, http.StatusOK)
|
||||
}
|
||||
|
||||
// Gzip returns a gzipped response
|
||||
func (h *HTTPBin) Gzip(w http.ResponseWriter, r *http.Request) {
|
||||
resp := &gzipResponse{
|
||||
Headers: getRequestHeaders(r),
|
||||
Origin: getOrigin(r),
|
||||
Gzipped: true,
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
gzw := gzip.NewWriter(buf)
|
||||
gzw.Write(body)
|
||||
gzw.Close()
|
||||
|
||||
gzBody := buf.Bytes()
|
||||
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
writeJSON(w, gzBody, http.StatusOK)
|
||||
}
|
||||
|
||||
// Deflate returns a gzipped response
|
||||
func (h *HTTPBin) Deflate(w http.ResponseWriter, r *http.Request) {
|
||||
resp := &deflateResponse{
|
||||
Headers: getRequestHeaders(r),
|
||||
Origin: getOrigin(r),
|
||||
Deflated: true,
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w2 := zlib.NewWriter(buf)
|
||||
w2.Write(body)
|
||||
w2.Close()
|
||||
|
||||
compressedBody := buf.Bytes()
|
||||
|
||||
w.Header().Set("Content-Encoding", "deflate")
|
||||
writeJSON(w, compressedBody, http.StatusOK)
|
||||
}
|
||||
|
||||
// IP echoes the IP address of the incoming request
|
||||
func (h *HTTPBin) IP(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := json.Marshal(&ipResponse{
|
||||
Origin: getOrigin(r),
|
||||
})
|
||||
writeJSON(w, body, http.StatusOK)
|
||||
}
|
||||
|
||||
// UserAgent echoes the incoming User-Agent header
|
||||
func (h *HTTPBin) UserAgent(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := json.Marshal(&userAgentResponse{
|
||||
UserAgent: r.Header.Get("User-Agent"),
|
||||
})
|
||||
writeJSON(w, body, http.StatusOK)
|
||||
}
|
||||
|
||||
// Headers echoes the incoming request headers
|
||||
func (h *HTTPBin) Headers(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := json.Marshal(&headersResponse{
|
||||
Headers: getRequestHeaders(r),
|
||||
})
|
||||
writeJSON(w, body, http.StatusOK)
|
||||
}
|
||||
|
||||
// Status responds with the specified status code. TODO: support random choice
|
||||
// from multiple, optionally weighted status codes.
|
||||
func (h *HTTPBin) Status(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
code, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type statusCase struct {
|
||||
headers map[string]string
|
||||
body []byte
|
||||
}
|
||||
|
||||
redirectHeaders := &statusCase{
|
||||
headers: map[string]string{
|
||||
"Location": "/redirect/1",
|
||||
},
|
||||
}
|
||||
notAcceptableBody, _ := json.Marshal(map[string]interface{}{
|
||||
"message": "Client did not request a supported media type",
|
||||
"accept": acceptedMediaTypes,
|
||||
})
|
||||
|
||||
http300body := []byte(`<!doctype html>
|
||||
<head>
|
||||
<title>Multiple Choices</title>
|
||||
</head>
|
||||
<body>
|
||||
<ul>
|
||||
<li><a href="/image/jpeg">/image/jpeg</a></li>
|
||||
<li><a href="/image/png">/image/png</a></li>
|
||||
<li><a href="/image/svg">/image/svg</a></li>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
http308body := []byte(`<!doctype html>
|
||||
<head>
|
||||
<title>Permanent Redirect</title>
|
||||
</head>
|
||||
<body>Permanently redirected to <a href="/image/jpeg">/image/jpeg</a>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
specialCases := map[int]*statusCase{
|
||||
300: {
|
||||
body: http300body,
|
||||
headers: map[string]string{
|
||||
"Location": "/image/jpeg",
|
||||
},
|
||||
},
|
||||
301: redirectHeaders,
|
||||
302: redirectHeaders,
|
||||
303: redirectHeaders,
|
||||
305: redirectHeaders,
|
||||
307: redirectHeaders,
|
||||
308: {
|
||||
body: http308body,
|
||||
headers: map[string]string{
|
||||
"Location": "/image/jpeg",
|
||||
},
|
||||
},
|
||||
401: {
|
||||
headers: map[string]string{
|
||||
"WWW-Authenticate": `Basic realm="Fake Realm"`,
|
||||
},
|
||||
},
|
||||
402: {
|
||||
body: []byte("Fuck you, pay me!"),
|
||||
headers: map[string]string{
|
||||
"X-More-Info": "http://vimeo.com/22053820",
|
||||
},
|
||||
},
|
||||
406: {
|
||||
body: notAcceptableBody,
|
||||
headers: map[string]string{
|
||||
"Content-Type": jsonContentType,
|
||||
},
|
||||
},
|
||||
407: {
|
||||
headers: map[string]string{
|
||||
"Proxy-Authenticate": `Basic realm="Fake Realm"`,
|
||||
},
|
||||
},
|
||||
418: {
|
||||
body: []byte("I'm a teapot!"),
|
||||
headers: map[string]string{
|
||||
"X-More-Info": "http://tools.ietf.org/html/rfc2324",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if specialCase, ok := specialCases[code]; ok {
|
||||
if specialCase.headers != nil {
|
||||
for key, val := range specialCase.headers {
|
||||
w.Header().Set(key, val)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
if specialCase.body != nil {
|
||||
w.Write(specialCase.body)
|
||||
}
|
||||
} else {
|
||||
w.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
|
||||
// ResponseHeaders responds with a map of header values
|
||||
func (h *HTTPBin) ResponseHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
args := r.URL.Query()
|
||||
for k, vs := range args {
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(args)
|
||||
if contentType := w.Header().Get("Content-Type"); contentType == "" {
|
||||
w.Header().Set("Content-Type", jsonContentType)
|
||||
}
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
func redirectLocation(r *http.Request, relative bool, n int) string {
|
||||
var location string
|
||||
var path string
|
||||
|
||||
if n < 1 {
|
||||
path = "/get"
|
||||
} else if relative {
|
||||
path = fmt.Sprintf("/relative-redirect/%d", n)
|
||||
} else {
|
||||
path = fmt.Sprintf("/absolute-redirect/%d", n)
|
||||
}
|
||||
|
||||
if relative {
|
||||
location = path
|
||||
} else {
|
||||
u := getURL(r)
|
||||
u.Path = path
|
||||
u.RawQuery = ""
|
||||
location = u.String()
|
||||
}
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
func doRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
n, err := strconv.Atoi(parts[2])
|
||||
if err != nil || n < 1 {
|
||||
http.Error(w, "Invalid redirect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Location", redirectLocation(r, relative, n-1))
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
// Redirect responds with 302 redirect a given number of times. Defaults to a
|
||||
// relative redirect, but an ?absolute=true query param will trigger an
|
||||
// absolute redirect.
|
||||
func (h *HTTPBin) Redirect(w http.ResponseWriter, r *http.Request) {
|
||||
params := r.URL.Query()
|
||||
relative := strings.ToLower(params.Get("absolute")) != "true"
|
||||
doRedirect(w, r, relative)
|
||||
}
|
||||
|
||||
// RelativeRedirect responds with an HTTP 302 redirect a given number of times
|
||||
func (h *HTTPBin) RelativeRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
doRedirect(w, r, true)
|
||||
}
|
||||
|
||||
// AbsoluteRedirect responds with an HTTP 302 redirect a given number of times
|
||||
func (h *HTTPBin) AbsoluteRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
doRedirect(w, r, false)
|
||||
}
|
||||
|
||||
// RedirectTo responds with a redirect to a specific URL with an optional
|
||||
// status code, which defaults to 302
|
||||
func (h *HTTPBin) RedirectTo(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
url := q.Get("url")
|
||||
if url == "" {
|
||||
http.Error(w, "Missing URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
statusCode := http.StatusFound
|
||||
rawStatusCode := q.Get("status_code")
|
||||
if rawStatusCode != "" {
|
||||
statusCode, err = strconv.Atoi(q.Get("status_code"))
|
||||
if err != nil || statusCode < 300 || statusCode > 399 {
|
||||
http.Error(w, "Invalid status code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Location", url)
|
||||
w.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
// Cookies responds with the cookies in the incoming request
|
||||
func (h *HTTPBin) Cookies(w http.ResponseWriter, r *http.Request) {
|
||||
resp := cookiesResponse{}
|
||||
for _, c := range r.Cookies() {
|
||||
resp[c.Name] = c.Value
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
writeJSON(w, body, http.StatusOK)
|
||||
}
|
||||
|
||||
// SetCookies sets cookies as specified in query params and redirects to
|
||||
// Cookies endpoint
|
||||
func (h *HTTPBin) SetCookies(w http.ResponseWriter, r *http.Request) {
|
||||
params := r.URL.Query()
|
||||
for k := range params {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: k,
|
||||
Value: params.Get(k),
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Location", "/cookies")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
// DeleteCookies deletes cookies specified in query params and redirects to
|
||||
// Cookies endpoint
|
||||
func (h *HTTPBin) DeleteCookies(w http.ResponseWriter, r *http.Request) {
|
||||
params := r.URL.Query()
|
||||
for k := range params {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: k,
|
||||
Value: params.Get(k),
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
Expires: time.Now().Add(-1 * 24 * 365 * time.Hour),
|
||||
})
|
||||
}
|
||||
w.Header().Set("Location", "/cookies")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
// BasicAuth requires basic authentication
|
||||
func (h *HTTPBin) BasicAuth(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 4 {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
expectedUser := parts[2]
|
||||
expectedPass := parts[3]
|
||||
|
||||
givenUser, givenPass, _ := r.BasicAuth()
|
||||
|
||||
status := http.StatusOK
|
||||
authorized := givenUser == expectedUser && givenPass == expectedPass
|
||||
if !authorized {
|
||||
status = http.StatusUnauthorized
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Fake Realm"`)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(&authResponse{
|
||||
Authorized: authorized,
|
||||
User: givenUser,
|
||||
})
|
||||
writeJSON(w, body, status)
|
||||
}
|
||||
|
||||
// HiddenBasicAuth requires HTTP Basic authentication but returns a status of
|
||||
// 404 if the request is unauthorized
|
||||
func (h *HTTPBin) HiddenBasicAuth(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 4 {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
expectedUser := parts[2]
|
||||
expectedPass := parts[3]
|
||||
|
||||
givenUser, givenPass, _ := r.BasicAuth()
|
||||
|
||||
authorized := givenUser == expectedUser && givenPass == expectedPass
|
||||
if !authorized {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(&authResponse{
|
||||
Authorized: authorized,
|
||||
User: givenUser,
|
||||
})
|
||||
writeJSON(w, body, http.StatusOK)
|
||||
}
|
||||
|
||||
// Stream responds with max(n, 100) lines of JSON-encoded request data.
|
||||
func (h *HTTPBin) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
n, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid integer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if n > 100 {
|
||||
n = 100
|
||||
} else if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
|
||||
resp := &streamResponse{
|
||||
Args: r.URL.Query(),
|
||||
Headers: getRequestHeaders(r),
|
||||
Origin: getOrigin(r),
|
||||
URL: getURL(r).String(),
|
||||
}
|
||||
|
||||
f := w.(http.Flusher)
|
||||
for i := 0; i < n; i++ {
|
||||
resp.ID = i
|
||||
line, _ := json.Marshal(resp)
|
||||
w.Write(line)
|
||||
w.Write([]byte("\n"))
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Delay waits for a given amount of time before responding, where the time may
|
||||
// be specified as a golang-style duration or seconds in floating point.
|
||||
func (h *HTTPBin) Delay(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
delay, err := parseBoundedDuration(parts[2], 0, h.MaxDuration)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid duration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
w.WriteHeader(499) // "Client Closed Request" https://httpstatuses.com/499
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
h.RequestWithBody(w, r)
|
||||
}
|
||||
|
||||
// Drip returns data over a duration after an optional initial delay, then
|
||||
// (optionally) returns with the given status code.
|
||||
func (h *HTTPBin) Drip(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
var (
|
||||
duration = h.DefaultParams.DripDuration
|
||||
delay = h.DefaultParams.DripDelay
|
||||
numBytes = h.DefaultParams.DripNumBytes
|
||||
code = http.StatusOK
|
||||
|
||||
err error
|
||||
)
|
||||
|
||||
if userDuration := q.Get("duration"); userDuration != "" {
|
||||
duration, err = parseBoundedDuration(userDuration, 0, h.MaxDuration)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid duration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if userDelay := q.Get("delay"); userDelay != "" {
|
||||
delay, err = parseBoundedDuration(userDelay, 0, h.MaxDuration)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid delay", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if userNumBytes := q.Get("numbytes"); userNumBytes != "" {
|
||||
numBytes, err = strconv.ParseInt(userNumBytes, 10, 64)
|
||||
if err != nil || numBytes <= 0 || numBytes > h.MaxBodySize {
|
||||
http.Error(w, "Invalid numbytes", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if userCode := q.Get("code"); userCode != "" {
|
||||
code, err = strconv.Atoi(userCode)
|
||||
if err != nil || code < 100 || code >= 600 {
|
||||
http.Error(w, "Invalid code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if duration+delay > h.MaxDuration {
|
||||
http.Error(w, "Too much time", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pause := duration / time.Duration(numBytes)
|
||||
flusher := w.(http.Flusher)
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", numBytes))
|
||||
w.WriteHeader(code)
|
||||
flusher.Flush()
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
|
||||
for i := int64(0); i < numBytes; i++ {
|
||||
w.Write([]byte("*"))
|
||||
flusher.Flush()
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(pause):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Range returns up to N bytes, with support for HTTP Range requests.
|
||||
//
|
||||
// This departs from httpbin by not supporting the chunk_size or duration
|
||||
// parameters.
|
||||
func (h *HTTPBin) Range(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
numBytes, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("ETag", fmt.Sprintf("range%d", numBytes))
|
||||
w.Header().Add("Accept-Ranges", "bytes")
|
||||
|
||||
if numBytes <= 0 || numBytes > h.MaxBodySize {
|
||||
http.Error(w, "Invalid number of bytes", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
content := newSyntheticByteStream(numBytes, func(offset int64) byte {
|
||||
return byte(97 + (offset % 26))
|
||||
})
|
||||
var modtime time.Time
|
||||
http.ServeContent(w, r, "", modtime, content)
|
||||
}
|
||||
|
||||
// HTML renders a basic HTML page
|
||||
func (h *HTTPBin) HTML(w http.ResponseWriter, r *http.Request) {
|
||||
writeHTML(w, assets.MustAsset("moby.html"), http.StatusOK)
|
||||
}
|
||||
|
||||
// Robots renders a basic robots.txt file
|
||||
func (h *HTTPBin) Robots(w http.ResponseWriter, r *http.Request) {
|
||||
robotsTxt := []byte(`User-agent: *
|
||||
Disallow: /deny
|
||||
`)
|
||||
writeResponse(w, http.StatusOK, "text/plain", robotsTxt)
|
||||
}
|
||||
|
||||
// Deny renders a basic page that robots should never access
|
||||
func (h *HTTPBin) Deny(w http.ResponseWriter, r *http.Request) {
|
||||
writeResponse(w, http.StatusOK, "text/plain", []byte(`YOU SHOULDN'T BE HERE`))
|
||||
}
|
||||
|
||||
// Cache returns a 304 if an If-Modified-Since or an If-None-Match header is
|
||||
// present, otherwise returns the same response as Get.
|
||||
func (h *HTTPBin) Cache(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-Modified-Since") != "" || r.Header.Get("If-None-Match") != "" {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
lastModified := time.Now().Format(time.RFC1123)
|
||||
w.Header().Add("Last-Modified", lastModified)
|
||||
w.Header().Add("ETag", sha1hash(lastModified))
|
||||
h.Get(w, r)
|
||||
}
|
||||
|
||||
// CacheControl sets a Cache-Control header for N seconds for /cache/N requests
|
||||
func (h *HTTPBin) CacheControl(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
seconds, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds))
|
||||
h.Get(w, r)
|
||||
}
|
||||
|
||||
// ETag assumes the resource has the given etag and response to If-None-Match
|
||||
// and If-Match headers appropriately.
|
||||
func (h *HTTPBin) ETag(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
etag := parts[2]
|
||||
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, etag))
|
||||
|
||||
// TODO: This mostly duplicates the work of Get() above, should this be
|
||||
// pulled into a little helper?
|
||||
resp := &getResponse{
|
||||
Args: r.URL.Query(),
|
||||
Headers: getRequestHeaders(r),
|
||||
Origin: getOrigin(r),
|
||||
URL: getURL(r).String(),
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
|
||||
// Let http.ServeContent deal with If-None-Match and If-Match headers:
|
||||
// https://golang.org/pkg/net/http/#ServeContent
|
||||
http.ServeContent(w, r, "response.json", time.Now(), bytes.NewReader(body))
|
||||
}
|
||||
|
||||
// Bytes returns N random bytes generated with an optional seed
|
||||
func (h *HTTPBin) Bytes(w http.ResponseWriter, r *http.Request) {
|
||||
handleBytes(w, r, false)
|
||||
}
|
||||
|
||||
// StreamBytes streams N random bytes generated with an optional seed in chunks
|
||||
// of a given size.
|
||||
func (h *HTTPBin) StreamBytes(w http.ResponseWriter, r *http.Request) {
|
||||
handleBytes(w, r, true)
|
||||
}
|
||||
|
||||
// handleBytes consolidates the logic for validating input params of the Bytes
|
||||
// and StreamBytes endpoints and knows how to write the response in chunks if
|
||||
// streaming is true.
|
||||
func handleBytes(w http.ResponseWriter, r *http.Request, streaming bool) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
numBytes, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if numBytes < 1 {
|
||||
numBytes = 1
|
||||
} else if numBytes > 100*1024 {
|
||||
numBytes = 100 * 1024
|
||||
}
|
||||
|
||||
var chunkSize int
|
||||
var write func([]byte)
|
||||
|
||||
if streaming {
|
||||
if r.URL.Query().Get("chunk_size") != "" {
|
||||
chunkSize, err = strconv.Atoi(r.URL.Query().Get("chunk_size"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
chunkSize = 10 * 1024
|
||||
}
|
||||
|
||||
write = func() func(chunk []byte) {
|
||||
f := w.(http.Flusher)
|
||||
return func(chunk []byte) {
|
||||
w.Write(chunk)
|
||||
f.Flush()
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
chunkSize = numBytes
|
||||
write = func(chunk []byte) {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(chunk)))
|
||||
w.Write(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
var seed int64
|
||||
rawSeed := r.URL.Query().Get("seed")
|
||||
if rawSeed != "" {
|
||||
seed, err = strconv.ParseInt(rawSeed, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid seed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
seed = time.Now().Unix()
|
||||
}
|
||||
|
||||
src := rand.NewSource(seed)
|
||||
rng := rand.New(src)
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
var chunk []byte
|
||||
for i := 0; i < numBytes; i++ {
|
||||
chunk = append(chunk, byte(rng.Intn(256)))
|
||||
if len(chunk) == chunkSize {
|
||||
write(chunk)
|
||||
chunk = nil
|
||||
}
|
||||
}
|
||||
if len(chunk) > 0 {
|
||||
write(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// Links redirects to the first page in a series of N links
|
||||
func (h *HTTPBin) Links(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(parts[2])
|
||||
if err != nil || n < 0 || n > 256 {
|
||||
http.Error(w, "Invalid link count", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Are we handling /links/<n>/<offset>? If so, render an HTML page
|
||||
if len(parts) == 4 {
|
||||
offset, err := strconv.Atoi(parts[3])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid offset", http.StatusBadRequest)
|
||||
}
|
||||
doLinksPage(w, r, n, offset)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, redirect from /links/<n> to /links/<n>/0
|
||||
r.URL.Path = r.URL.Path + "/0"
|
||||
w.Header().Set("Location", r.URL.String())
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
// doLinksPage renders a page with a series of N links
|
||||
func doLinksPage(w http.ResponseWriter, r *http.Request, n int, offset int) {
|
||||
w.Header().Add("Content-Type", htmlContentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
w.Write([]byte("<html><head><title>Links</title></head><body>"))
|
||||
for i := 0; i < n; i++ {
|
||||
if i == offset {
|
||||
fmt.Fprintf(w, "%d ", i)
|
||||
} else {
|
||||
fmt.Fprintf(w, `<a href="/links/%d/%d">%d</a> `, n, i, i)
|
||||
}
|
||||
}
|
||||
w.Write([]byte("</body></html>"))
|
||||
}
|
||||
|
||||
// ImageAccept responds with an appropriate image based on the Accept header
|
||||
func (h *HTTPBin) ImageAccept(w http.ResponseWriter, r *http.Request) {
|
||||
accept := r.Header.Get("Accept")
|
||||
if accept == "" || strings.Contains(accept, "image/png") || strings.Contains(accept, "image/*") {
|
||||
doImage(w, "png")
|
||||
} else if strings.Contains(accept, "image/webp") {
|
||||
doImage(w, "webp")
|
||||
} else if strings.Contains(accept, "image/svg+xml") {
|
||||
doImage(w, "svg")
|
||||
} else if strings.Contains(accept, "image/jpeg") {
|
||||
doImage(w, "jpeg")
|
||||
} else {
|
||||
http.Error(w, "Unsupported media type", http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
// Image responds with an image of a specific kind, from /image/<kind>
|
||||
func (h *HTTPBin) Image(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) != 3 {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
doImage(w, parts[2])
|
||||
}
|
||||
|
||||
// doImage responds with a specific kind of image, if there is an image asset
|
||||
// of the given kind.
|
||||
func doImage(w http.ResponseWriter, kind string) {
|
||||
img, err := assets.Asset("image." + kind)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
}
|
||||
contentType := "image/" + kind
|
||||
if kind == "svg" {
|
||||
contentType = "image/svg+xml"
|
||||
}
|
||||
writeResponse(w, http.StatusOK, contentType, img)
|
||||
}
|
||||
|
||||
// XML responds with an XML document
|
||||
func (h *HTTPBin) XML(w http.ResponseWriter, r *http.Request) {
|
||||
writeResponse(w, http.StatusOK, "application/xml", assets.MustAsset("sample.xml"))
|
||||
}
|
||||
|
||||
// DigestAuth handles a simple implementation of HTTP Digest Authentication,
|
||||
// which supports the "auth" QOP and the MD5 and SHA-256 crypto algorithms.
|
||||
//
|
||||
// /digest-auth/<qop>/<user>/<passwd>
|
||||
// /digest-auth/<qop>/<user>/<passwd>/<algorithm>
|
||||
func (h *HTTPBin) DigestAuth(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
count := len(parts)
|
||||
|
||||
if count != 5 && count != 6 {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
qop := strings.ToLower(parts[2])
|
||||
user := parts[3]
|
||||
password := parts[4]
|
||||
|
||||
algoName := "MD5"
|
||||
if count == 6 {
|
||||
algoName = strings.ToUpper(parts[5])
|
||||
}
|
||||
|
||||
if qop != "auth" {
|
||||
http.Error(w, "Invalid QOP directive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if algoName != "MD5" && algoName != "SHA-256" {
|
||||
http.Error(w, "Invalid algorithm", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
algorithm := digest.MD5
|
||||
if algoName == "SHA-256" {
|
||||
algorithm = digest.SHA256
|
||||
}
|
||||
|
||||
if !digest.Check(r, user, password) {
|
||||
w.Header().Set("WWW-Authenticate", digest.Challenge("go-httpbin", algorithm))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
resp, _ := json.Marshal(&authResponse{
|
||||
Authorized: true,
|
||||
User: user,
|
||||
})
|
||||
writeJSON(w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// UUID - responds with a generated UUID
|
||||
func (h *HTTPBin) UUID(w http.ResponseWriter, r *http.Request) {
|
||||
resp, _ := json.Marshal(&uuidResponse{
|
||||
UUID: uuidv4(),
|
||||
})
|
||||
writeJSON(w, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// Base64 - encodes/decodes input data
|
||||
func (h *HTTPBin) Base64(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := newBase64Helper(r.URL.Path)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("%s", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var result []byte
|
||||
var base64Error error
|
||||
|
||||
if b.operation == "decode" {
|
||||
result, base64Error = b.Decode()
|
||||
} else {
|
||||
result, base64Error = b.Encode()
|
||||
}
|
||||
|
||||
if base64Error != nil {
|
||||
http.Error(w, fmt.Sprintf("%s failed: %s", b.operation, base64Error), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeResponse(w, http.StatusOK, "text/html", result)
|
||||
}
|
||||
|
||||
// JSON - returns a sample json
|
||||
func (h *HTTPBin) JSON(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, assets.MustAsset("sample.json"), http.StatusOK)
|
||||
}
|
||||
|
||||
// Bearer - Prompts the user for authorization using bearer authentication.
|
||||
func (h *HTTPBin) Bearer(w http.ResponseWriter, r *http.Request) {
|
||||
reqToken := r.Header.Get("Authorization")
|
||||
tokenFields := strings.Fields(reqToken)
|
||||
if len(tokenFields) != 2 || tokenFields[0] != "Bearer" {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(&bearerResponse{
|
||||
Authenticated: true,
|
||||
Token: tokenFields[1],
|
||||
})
|
||||
writeJSON(w, body, http.StatusOK)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,306 @@
|
||||
package httpbin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Base64MaxLen - Maximum input length for Base64 functions
|
||||
const Base64MaxLen = 2000
|
||||
|
||||
// requestHeaders takes in incoming request and returns an http.Header map
|
||||
// suitable for inclusion in our response data structures.
|
||||
//
|
||||
// This is necessary to ensure that the incoming Host header is included,
|
||||
// because golang only exposes that header on the http.Request struct itself.
|
||||
func getRequestHeaders(r *http.Request) http.Header {
|
||||
h := r.Header
|
||||
h.Set("Host", r.Host)
|
||||
return h
|
||||
}
|
||||
|
||||
func getOrigin(r *http.Request) string {
|
||||
origin := r.Header.Get("X-Forwarded-For")
|
||||
if origin == "" {
|
||||
origin = r.RemoteAddr
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
func getURL(r *http.Request) *url.URL {
|
||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||
if scheme == "" {
|
||||
scheme = r.Header.Get("X-Forwarded-Protocol")
|
||||
}
|
||||
if scheme == "" && r.Header.Get("X-Forwarded-Ssl") == "on" {
|
||||
scheme = "https"
|
||||
}
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
host := r.URL.Host
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
|
||||
return &url.URL{
|
||||
Scheme: scheme,
|
||||
Opaque: r.URL.Opaque,
|
||||
User: r.URL.User,
|
||||
Host: host,
|
||||
Path: r.URL.Path,
|
||||
RawPath: r.URL.RawPath,
|
||||
ForceQuery: r.URL.ForceQuery,
|
||||
RawQuery: r.URL.RawQuery,
|
||||
Fragment: r.URL.Fragment,
|
||||
}
|
||||
}
|
||||
|
||||
func writeResponse(w http.ResponseWriter, status int, contentType string, body []byte) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
w.WriteHeader(status)
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, body []byte, status int) {
|
||||
writeResponse(w, status, jsonContentType, body)
|
||||
}
|
||||
|
||||
func writeHTML(w http.ResponseWriter, body []byte, status int) {
|
||||
writeResponse(w, status, htmlContentType, body)
|
||||
}
|
||||
|
||||
// parseBody handles parsing a request body into our standard API response,
|
||||
// taking care to only consume the request body once based on the Content-Type
|
||||
// of the request. The given bodyResponse will be modified.
|
||||
//
|
||||
// Note: this function expects callers to limit the the maximum size of the
|
||||
// request body. See, e.g., the limitRequestSize middleware.
|
||||
func parseBody(w http.ResponseWriter, r *http.Request, resp *bodyResponse) error {
|
||||
if r.Body == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always set resp.Data to the incoming request body, in case we don't know
|
||||
// how to handle the content type
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
r.Body.Close()
|
||||
return err
|
||||
}
|
||||
resp.Data = string(body)
|
||||
|
||||
// After reading the body to populate resp.Data, we need to re-wrap it in
|
||||
// an io.Reader for further processing below
|
||||
r.Body.Close()
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
ct := r.Header.Get("Content-Type")
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"):
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Form = r.PostForm
|
||||
case strings.HasPrefix(ct, "multipart/form-data"):
|
||||
// The memory limit here only restricts how many parts will be kept in
|
||||
// memory before overflowing to disk:
|
||||
// https://golang.org/pkg/net/http/#Request.ParseMultipartForm
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Form = r.PostForm
|
||||
case strings.HasPrefix(ct, "application/json"):
|
||||
err := json.NewDecoder(r.Body).Decode(&resp.JSON)
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDuration takes a user's input as a string and attempts to convert it
|
||||
// into a time.Duration. If not given as a go-style duration string, the input
|
||||
// is assumed to be seconds as a float.
|
||||
func parseDuration(input string) (time.Duration, error) {
|
||||
d, err := time.ParseDuration(input)
|
||||
if err != nil {
|
||||
n, err := strconv.ParseFloat(input, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d = time.Duration(n*1000) * time.Millisecond
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// parseBoundedDuration parses a time.Duration from user input and ensures that
|
||||
// it is within a given maximum and minimum time
|
||||
func parseBoundedDuration(input string, min, max time.Duration) (time.Duration, error) {
|
||||
d, err := parseDuration(input)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if d > max {
|
||||
err = fmt.Errorf("duration %s longer than %s", d, max)
|
||||
} else if d < min {
|
||||
err = fmt.Errorf("duration %s shorter than %s", d, min)
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
|
||||
// syntheticByteStream implements the ReadSeeker interface to allow reading
|
||||
// arbitrary subsets of bytes up to a maximum size given a function for
|
||||
// generating the byte at a given offset.
|
||||
type syntheticByteStream struct {
|
||||
mu sync.Mutex
|
||||
|
||||
size int64
|
||||
offset int64
|
||||
factory func(int64) byte
|
||||
}
|
||||
|
||||
// newSyntheticByteStream returns a new stream of bytes of a specific size,
|
||||
// given a factory function for generating the byte at a given offset.
|
||||
func newSyntheticByteStream(size int64, factory func(int64) byte) io.ReadSeeker {
|
||||
return &syntheticByteStream{
|
||||
size: size,
|
||||
factory: factory,
|
||||
}
|
||||
}
|
||||
|
||||
// Read implements the Reader interface for syntheticByteStream
|
||||
func (s *syntheticByteStream) Read(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
start := s.offset
|
||||
end := start + int64(len(p))
|
||||
var err error
|
||||
if end >= s.size {
|
||||
err = io.EOF
|
||||
end = s.size
|
||||
}
|
||||
|
||||
for idx := start; idx < end; idx++ {
|
||||
p[idx-start] = s.factory(idx)
|
||||
}
|
||||
|
||||
s.offset = end
|
||||
|
||||
return int(end - start), err
|
||||
}
|
||||
|
||||
// Seek implements the Seeker interface for syntheticByteStream
|
||||
func (s *syntheticByteStream) Seek(offset int64, whence int) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
s.offset = offset
|
||||
case io.SeekCurrent:
|
||||
s.offset += offset
|
||||
case io.SeekEnd:
|
||||
s.offset = s.size - offset
|
||||
default:
|
||||
return 0, errors.New("Seek: invalid whence")
|
||||
}
|
||||
|
||||
if s.offset < 0 {
|
||||
return 0, errors.New("Seek: invalid offset")
|
||||
}
|
||||
|
||||
return s.offset, nil
|
||||
}
|
||||
|
||||
func sha1hash(input string) string {
|
||||
h := sha1.New()
|
||||
return fmt.Sprintf("%x", h.Sum([]byte(input)))
|
||||
}
|
||||
|
||||
func uuidv4() string {
|
||||
buff := make([]byte, 16)
|
||||
_, err := rand.Read(buff[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buff[6] = (buff[6] & 0x0f) | 0x40 // Version 4
|
||||
buff[8] = (buff[8] & 0x3f) | 0x80 // Variant 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", buff[0:4], buff[4:6], buff[6:8], buff[8:10], buff[10:])
|
||||
}
|
||||
|
||||
// base64Helper - describes the base64 operation (encode|decode) and input data
|
||||
type base64Helper struct {
|
||||
operation string
|
||||
data string
|
||||
}
|
||||
|
||||
// newbase64Helper - create a new base64Helper struct
|
||||
// Supports the following URL paths
|
||||
// - /base64/input_str
|
||||
// - /base64/encode/input_str
|
||||
// - /base64/decode/input_str
|
||||
func newBase64Helper(path string) (*base64Helper, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
return nil, errors.New("invalid URL")
|
||||
}
|
||||
|
||||
var b base64Helper
|
||||
|
||||
// Validation for - /base64/input_str
|
||||
if len(parts) == 3 {
|
||||
b.operation = "decode"
|
||||
b.data = parts[2]
|
||||
} else {
|
||||
// Validation for
|
||||
// - /base64/encode/input_str
|
||||
// - /base64/encode/input_str
|
||||
b.operation = parts[2]
|
||||
if b.operation != "encode" && b.operation != "decode" {
|
||||
return nil, fmt.Errorf("invalid operation: %s", b.operation)
|
||||
}
|
||||
b.data = parts[3]
|
||||
}
|
||||
if len(b.data) == 0 {
|
||||
return nil, errors.New("no input data")
|
||||
}
|
||||
if len(b.data) >= Base64MaxLen {
|
||||
return nil, fmt.Errorf("input length - %d, Cannot handle input >= %d", len(b.data), Base64MaxLen)
|
||||
}
|
||||
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// Encode - encode data as base64
|
||||
func (b *base64Helper) Encode() ([]byte, error) {
|
||||
buff := make([]byte, base64.StdEncoding.EncodedLen(len(b.data)))
|
||||
base64.StdEncoding.Encode(buff, []byte(b.data))
|
||||
return buff, nil
|
||||
}
|
||||
|
||||
// Decode - decode data from base64
|
||||
func (b *base64Helper) Decode() ([]byte, error) {
|
||||
buff := make([]byte, base64.StdEncoding.DecodedLen(len(b.data)))
|
||||
_, err := base64.StdEncoding.Decode(buff, []byte(b.data))
|
||||
return buff, err
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
package httpbin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func assertNil(t *testing.T, v interface{}) {
|
||||
if v != nil {
|
||||
t.Errorf("expected nil, got %#v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func assertIntEqual(t *testing.T, a, b int) {
|
||||
if a != b {
|
||||
t.Errorf("expected %v == %v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBytesEqual(t *testing.T, a, b []byte) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("expected %v == %v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func assertError(t *testing.T, got, expected error) {
|
||||
if got != expected {
|
||||
t.Errorf("expected error %v, got %v", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
var okTests = []struct {
|
||||
input string
|
||||
expected time.Duration
|
||||
}{
|
||||
// go-style durations
|
||||
{"1s", time.Second},
|
||||
{"500ms", 500 * time.Millisecond},
|
||||
{"1.5h", 90 * time.Minute},
|
||||
{"-10m", -10 * time.Minute},
|
||||
|
||||
// or floating point seconds
|
||||
{"1", time.Second},
|
||||
{"0.25", 250 * time.Millisecond},
|
||||
{"-25", -25 * time.Second},
|
||||
{"-2.5", -2500 * time.Millisecond},
|
||||
}
|
||||
for _, test := range okTests {
|
||||
t.Run(fmt.Sprintf("ok/%s", test.input), func(t *testing.T) {
|
||||
result, err := parseDuration(test.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error parsing duration %v: %s", test.input, err)
|
||||
}
|
||||
if result != test.expected {
|
||||
t.Fatalf("expected %s, got %s", test.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var badTests = []struct {
|
||||
input string
|
||||
}{
|
||||
{"foo"},
|
||||
{"100foo"},
|
||||
{"1/1"},
|
||||
{"1.5.foo"},
|
||||
{"0xFF"},
|
||||
}
|
||||
for _, test := range badTests {
|
||||
t.Run(fmt.Sprintf("bad/%s", test.input), func(t *testing.T) {
|
||||
_, err := parseDuration(test.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error parsing %v", test.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyntheticByteStream(t *testing.T) {
|
||||
factory := func(offset int64) byte {
|
||||
return byte(offset)
|
||||
}
|
||||
|
||||
t.Run("read", func(t *testing.T) {
|
||||
s := newSyntheticByteStream(10, factory)
|
||||
|
||||
// read first half
|
||||
p := make([]byte, 5)
|
||||
count, err := s.Read(p)
|
||||
assertNil(t, err)
|
||||
assertIntEqual(t, count, 5)
|
||||
assertBytesEqual(t, p, []byte{0, 1, 2, 3, 4})
|
||||
|
||||
// read second half
|
||||
p = make([]byte, 5)
|
||||
count, err = s.Read(p)
|
||||
assertError(t, err, io.EOF)
|
||||
assertIntEqual(t, count, 5)
|
||||
assertBytesEqual(t, p, []byte{5, 6, 7, 8, 9})
|
||||
|
||||
// can't read any more
|
||||
p = make([]byte, 5)
|
||||
count, err = s.Read(p)
|
||||
assertError(t, err, io.EOF)
|
||||
assertIntEqual(t, count, 0)
|
||||
assertBytesEqual(t, p, []byte{0, 0, 0, 0, 0})
|
||||
})
|
||||
|
||||
t.Run("read into too-large buffer", func(t *testing.T) {
|
||||
s := newSyntheticByteStream(5, factory)
|
||||
p := make([]byte, 10)
|
||||
count, err := s.Read(p)
|
||||
assertError(t, err, io.EOF)
|
||||
assertIntEqual(t, count, 5)
|
||||
assertBytesEqual(t, p, []byte{0, 1, 2, 3, 4, 0, 0, 0, 0, 0})
|
||||
})
|
||||
|
||||
t.Run("seek", func(t *testing.T) {
|
||||
s := newSyntheticByteStream(100, factory)
|
||||
|
||||
p := make([]byte, 5)
|
||||
s.Seek(10, io.SeekStart)
|
||||
count, err := s.Read(p)
|
||||
assertNil(t, err)
|
||||
assertIntEqual(t, count, 5)
|
||||
assertBytesEqual(t, p, []byte{10, 11, 12, 13, 14})
|
||||
|
||||
s.Seek(10, io.SeekCurrent)
|
||||
count, err = s.Read(p)
|
||||
assertNil(t, err)
|
||||
assertIntEqual(t, count, 5)
|
||||
assertBytesEqual(t, p, []byte{25, 26, 27, 28, 29})
|
||||
|
||||
s.Seek(10, io.SeekEnd)
|
||||
count, err = s.Read(p)
|
||||
assertNil(t, err)
|
||||
assertIntEqual(t, count, 5)
|
||||
assertBytesEqual(t, p, []byte{90, 91, 92, 93, 94})
|
||||
|
||||
// invalid whence
|
||||
_, err = s.Seek(10, 666)
|
||||
if err.Error() != "Seek: invalid whence" {
|
||||
t.Errorf("Expected \"Seek: invalid whence\", got %#v", err.Error())
|
||||
}
|
||||
|
||||
// invalid offset
|
||||
_, err = s.Seek(-10, io.SeekStart)
|
||||
if err.Error() != "Seek: invalid offset" {
|
||||
t.Errorf("Expected \"Seek: invalid offset\", got %#v", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,258 @@
|
||||
package httpbin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Default configuration values
|
||||
const (
|
||||
DefaultMaxBodySize int64 = 1024 * 1024
|
||||
DefaultMaxDuration = 10 * time.Second
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; encoding=utf-8"
|
||||
const htmlContentType = "text/html; charset=utf-8"
|
||||
|
||||
type headersResponse struct {
|
||||
Headers http.Header `json:"headers"`
|
||||
}
|
||||
|
||||
type ipResponse struct {
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
type userAgentResponse struct {
|
||||
UserAgent string `json:"user-agent"`
|
||||
}
|
||||
|
||||
type getResponse struct {
|
||||
Args url.Values `json:"args"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Origin string `json:"origin"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// A generic response for any incoming request that might contain a body
|
||||
type bodyResponse struct {
|
||||
Args url.Values `json:"args"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Origin string `json:"origin"`
|
||||
URL string `json:"url"`
|
||||
|
||||
Data string `json:"data"`
|
||||
Files map[string][]string `json:"files"`
|
||||
Form map[string][]string `json:"form"`
|
||||
JSON interface{} `json:"json"`
|
||||
}
|
||||
|
||||
type cookiesResponse map[string]string
|
||||
|
||||
type authResponse struct {
|
||||
Authorized bool `json:"authorized"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
|
||||
type gzipResponse struct {
|
||||
Headers http.Header `json:"headers"`
|
||||
Origin string `json:"origin"`
|
||||
Gzipped bool `json:"gzipped"`
|
||||
}
|
||||
|
||||
type deflateResponse struct {
|
||||
Headers http.Header `json:"headers"`
|
||||
Origin string `json:"origin"`
|
||||
Deflated bool `json:"deflated"`
|
||||
}
|
||||
|
||||
// An actual stream response body will be made up of one or more of these
|
||||
// structs, encoded as JSON and separated by newlines
|
||||
type streamResponse struct {
|
||||
ID int `json:"id"`
|
||||
Args url.Values `json:"args"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Origin string `json:"origin"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type uuidResponse struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
type bearerResponse struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// HTTPBin contains the business logic
|
||||
type HTTPBin struct {
|
||||
// Max size of an incoming request generated response body, in bytes
|
||||
MaxBodySize int64
|
||||
|
||||
// Max duration of a request, for those requests that allow user control
|
||||
// over timing (e.g. /delay)
|
||||
MaxDuration time.Duration
|
||||
|
||||
// Observer called with the result of each handled request
|
||||
Observer Observer
|
||||
|
||||
// Default parameter values
|
||||
DefaultParams DefaultParams
|
||||
}
|
||||
|
||||
// DefaultParams defines default parameter values
|
||||
type DefaultParams struct {
|
||||
DripDuration time.Duration
|
||||
DripDelay time.Duration
|
||||
DripNumBytes int64
|
||||
}
|
||||
|
||||
// DefaultDefaultParams defines the DefaultParams that are used by default. In
|
||||
// general, these should match the original httpbin.org's defaults.
|
||||
var DefaultDefaultParams = DefaultParams{
|
||||
DripDuration: 2 * time.Second,
|
||||
DripDelay: 2 * time.Second,
|
||||
DripNumBytes: 10,
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that exposes all HTTPBin endpoints
|
||||
func (h *HTTPBin) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/", methods(h.Index, "GET"))
|
||||
mux.HandleFunc("/forms/post", methods(h.FormsPost, "GET"))
|
||||
mux.HandleFunc("/encoding/utf8", methods(h.UTF8, "GET"))
|
||||
|
||||
mux.HandleFunc("/get", methods(h.Get, "GET"))
|
||||
mux.HandleFunc("/post", methods(h.RequestWithBody, "POST"))
|
||||
mux.HandleFunc("/put", methods(h.RequestWithBody, "PUT"))
|
||||
mux.HandleFunc("/patch", methods(h.RequestWithBody, "PATCH"))
|
||||
mux.HandleFunc("/delete", methods(h.RequestWithBody, "DELETE"))
|
||||
|
||||
mux.HandleFunc("/ip", h.IP)
|
||||
mux.HandleFunc("/user-agent", h.UserAgent)
|
||||
mux.HandleFunc("/headers", h.Headers)
|
||||
mux.HandleFunc("/response-headers", h.ResponseHeaders)
|
||||
|
||||
mux.HandleFunc("/status/", h.Status)
|
||||
|
||||
mux.HandleFunc("/redirect/", h.Redirect)
|
||||
mux.HandleFunc("/relative-redirect/", h.RelativeRedirect)
|
||||
mux.HandleFunc("/absolute-redirect/", h.AbsoluteRedirect)
|
||||
mux.HandleFunc("/redirect-to", h.RedirectTo)
|
||||
|
||||
mux.HandleFunc("/cookies", h.Cookies)
|
||||
mux.HandleFunc("/cookies/set", h.SetCookies)
|
||||
mux.HandleFunc("/cookies/delete", h.DeleteCookies)
|
||||
|
||||
mux.HandleFunc("/basic-auth/", h.BasicAuth)
|
||||
mux.HandleFunc("/hidden-basic-auth/", h.HiddenBasicAuth)
|
||||
mux.HandleFunc("/digest-auth/", h.DigestAuth)
|
||||
mux.HandleFunc("/bearer", h.Bearer)
|
||||
|
||||
mux.HandleFunc("/deflate", h.Deflate)
|
||||
mux.HandleFunc("/gzip", h.Gzip)
|
||||
|
||||
mux.HandleFunc("/stream/", h.Stream)
|
||||
mux.HandleFunc("/delay/", h.Delay)
|
||||
mux.HandleFunc("/drip", h.Drip)
|
||||
|
||||
mux.HandleFunc("/range/", h.Range)
|
||||
mux.HandleFunc("/bytes/", h.Bytes)
|
||||
mux.HandleFunc("/stream-bytes/", h.StreamBytes)
|
||||
|
||||
mux.HandleFunc("/html", h.HTML)
|
||||
mux.HandleFunc("/robots.txt", h.Robots)
|
||||
mux.HandleFunc("/deny", h.Deny)
|
||||
|
||||
mux.HandleFunc("/cache", h.Cache)
|
||||
mux.HandleFunc("/cache/", h.CacheControl)
|
||||
mux.HandleFunc("/etag/", h.ETag)
|
||||
|
||||
mux.HandleFunc("/links/", h.Links)
|
||||
|
||||
mux.HandleFunc("/image", h.ImageAccept)
|
||||
mux.HandleFunc("/image/", h.Image)
|
||||
mux.HandleFunc("/xml", h.XML)
|
||||
mux.HandleFunc("/json", h.JSON)
|
||||
|
||||
mux.HandleFunc("/uuid", h.UUID)
|
||||
mux.HandleFunc("/base64/", h.Base64)
|
||||
|
||||
// existing httpbin endpoints that we do not support
|
||||
mux.HandleFunc("/brotli", notImplementedHandler)
|
||||
|
||||
// Make sure our ServeMux doesn't "helpfully" redirect these invalid
|
||||
// endpoints by adding a trailing slash. See the ServeMux docs for more
|
||||
// info: https://golang.org/pkg/net/http/#ServeMux
|
||||
mux.HandleFunc("/absolute-redirect", http.NotFound)
|
||||
mux.HandleFunc("/basic-auth", http.NotFound)
|
||||
mux.HandleFunc("/delay", http.NotFound)
|
||||
mux.HandleFunc("/digest-auth", http.NotFound)
|
||||
mux.HandleFunc("/hidden-basic-auth", http.NotFound)
|
||||
mux.HandleFunc("/redirect", http.NotFound)
|
||||
mux.HandleFunc("/relative-redirect", http.NotFound)
|
||||
mux.HandleFunc("/status", http.NotFound)
|
||||
mux.HandleFunc("/stream", http.NotFound)
|
||||
mux.HandleFunc("/bytes", http.NotFound)
|
||||
mux.HandleFunc("/stream-bytes", http.NotFound)
|
||||
mux.HandleFunc("/links", http.NotFound)
|
||||
|
||||
// Apply global middleware
|
||||
var handler http.Handler
|
||||
handler = mux
|
||||
handler = limitRequestSize(h.MaxBodySize, handler)
|
||||
handler = preflight(handler)
|
||||
handler = autohead(handler)
|
||||
if h.Observer != nil {
|
||||
handler = observe(h.Observer, handler)
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// New creates a new HTTPBin instance
|
||||
func New(opts ...OptionFunc) *HTTPBin {
|
||||
h := &HTTPBin{
|
||||
MaxBodySize: DefaultMaxBodySize,
|
||||
MaxDuration: DefaultMaxDuration,
|
||||
DefaultParams: DefaultDefaultParams,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// OptionFunc uses the "functional options" pattern to customize an HTTPBin
|
||||
// instance
|
||||
type OptionFunc func(*HTTPBin)
|
||||
|
||||
// WithDefaultParams sets the default params handlers will use
|
||||
func WithDefaultParams(defaultParams DefaultParams) OptionFunc {
|
||||
return func(h *HTTPBin) {
|
||||
h.DefaultParams = defaultParams
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxBodySize sets the maximum amount of memory
|
||||
func WithMaxBodySize(m int64) OptionFunc {
|
||||
return func(h *HTTPBin) {
|
||||
h.MaxBodySize = m
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxDuration sets the maximum amount of time httpbin may take to respond
|
||||
func WithMaxDuration(d time.Duration) OptionFunc {
|
||||
return func(h *HTTPBin) {
|
||||
h.MaxDuration = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithObserver sets the request observer callback
|
||||
func WithObserver(o Observer) OptionFunc {
|
||||
return func(h *HTTPBin) {
|
||||
h.Observer = o
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package httpbin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
h := New()
|
||||
if h.MaxBodySize != DefaultMaxBodySize {
|
||||
t.Fatalf("expected default MaxBodySize == %d, got %#v", DefaultMaxBodySize, h.MaxBodySize)
|
||||
}
|
||||
if h.MaxDuration != DefaultMaxDuration {
|
||||
t.Fatalf("expected default MaxDuration == %s, got %#v", DefaultMaxDuration, h.MaxDuration)
|
||||
}
|
||||
if h.Observer != nil {
|
||||
t.Fatalf("expected default Observer == nil, got %#v", h.Observer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOptions(t *testing.T) {
|
||||
maxDuration := 1 * time.Second
|
||||
maxBodySize := int64(1024)
|
||||
observer := func(_ Result) {}
|
||||
|
||||
h := New(
|
||||
WithMaxBodySize(maxBodySize),
|
||||
WithMaxDuration(maxDuration),
|
||||
WithObserver(observer),
|
||||
)
|
||||
|
||||
if h.MaxBodySize != maxBodySize {
|
||||
t.Fatalf("expected MaxBodySize == %d, got %#v", maxBodySize, h.MaxBodySize)
|
||||
}
|
||||
if h.MaxDuration != maxDuration {
|
||||
t.Fatalf("expected MaxDuration == %s, got %#v", maxDuration, h.MaxDuration)
|
||||
}
|
||||
if h.Observer == nil {
|
||||
t.Fatalf("expected non-nil Observer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewObserver(t *testing.T) {
|
||||
expectedStatus := http.StatusTeapot
|
||||
|
||||
observed := false
|
||||
observer := func(r Result) {
|
||||
observed = true
|
||||
if r.Status != expectedStatus {
|
||||
t.Fatalf("expected result status = %d, got %d", expectedStatus, r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
h := New(WithObserver(observer))
|
||||
|
||||
r, _ := http.NewRequest("GET", fmt.Sprintf("/status/%d", expectedStatus), nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.Handler().ServeHTTP(w, r)
|
||||
|
||||
if observed == false {
|
||||
t.Fatalf("observer never called")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
package httpbin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func preflight(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = "*"
|
||||
}
|
||||
respHeader := w.Header()
|
||||
respHeader.Set("Access-Control-Allow-Origin", origin)
|
||||
respHeader.Set("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS")
|
||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||
if r.Header.Get("Access-Control-Request-Headers") != "" {
|
||||
w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers"))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
return
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func methods(h http.HandlerFunc, methods ...string) http.HandlerFunc {
|
||||
methodMap := make(map[string]struct{}, len(methods))
|
||||
for _, m := range methods {
|
||||
methodMap[m] = struct{}{}
|
||||
// GET implies support for HEAD
|
||||
if m == "GET" {
|
||||
methodMap["HEAD"] = struct{}{}
|
||||
}
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := methodMap[r.Method]; !ok {
|
||||
http.Error(w, fmt.Sprintf("method %s not allowed", r.Method), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func limitRequestSize(maxSize int64, h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// headResponseWriter implements http.ResponseWriter in order to discard the
|
||||
// body of the response
|
||||
type headResponseWriter struct {
|
||||
*metaResponseWriter
|
||||
}
|
||||
|
||||
func (hw *headResponseWriter) Write(b []byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// autohead automatically discards the body of responses to HEAD requests
|
||||
func autohead(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "HEAD" {
|
||||
w = &headResponseWriter{&metaResponseWriter{w: w}}
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// metaResponseWriter implements http.ResponseWriter and http.Flusher in order
|
||||
// to record a response's status code and body size for logging purposes.
|
||||
type metaResponseWriter struct {
|
||||
w http.ResponseWriter
|
||||
status int
|
||||
size int64
|
||||
}
|
||||
|
||||
func (mw *metaResponseWriter) Write(b []byte) (int, error) {
|
||||
size, err := mw.w.Write(b)
|
||||
mw.size += int64(size)
|
||||
return size, err
|
||||
}
|
||||
|
||||
func (mw *metaResponseWriter) WriteHeader(s int) {
|
||||
mw.w.WriteHeader(s)
|
||||
mw.status = s
|
||||
}
|
||||
|
||||
func (mw *metaResponseWriter) Flush() {
|
||||
f := mw.w.(http.Flusher)
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
func (mw *metaResponseWriter) Header() http.Header {
|
||||
return mw.w.Header()
|
||||
}
|
||||
|
||||
func (mw *metaResponseWriter) Status() int {
|
||||
if mw.status == 0 {
|
||||
return http.StatusOK
|
||||
}
|
||||
return mw.status
|
||||
}
|
||||
|
||||
func (mw *metaResponseWriter) Size() int64 {
|
||||
return mw.size
|
||||
}
|
||||
|
||||
func observe(o Observer, h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mw := &metaResponseWriter{w: w}
|
||||
t := time.Now()
|
||||
h.ServeHTTP(mw, r)
|
||||
o(Result{
|
||||
Status: mw.Status(),
|
||||
Method: r.Method,
|
||||
URI: r.URL.RequestURI(),
|
||||
Size: mw.Size(),
|
||||
Duration: time.Since(t),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Result is the result of handling a request, used for instrumentation
|
||||
type Result struct {
|
||||
Status int
|
||||
Method string
|
||||
URI string
|
||||
Size int64
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Observer is a function that will be called with the details of a handled
|
||||
// request, which can be used for logging, instrumentation, etc
|
||||
type Observer func(result Result)
|
||||
|
||||
// StdLogObserver creates an Observer that will log each request in structured
|
||||
// format using the given stdlib logger
|
||||
func StdLogObserver(l *log.Logger) Observer {
|
||||
const (
|
||||
logFmt = "time=%q status=%d method=%q uri=%q size_bytes=%d duration_ms=%0.02f"
|
||||
dateFmt = "2006-01-02T15:04:05.9999"
|
||||
)
|
||||
return func(result Result) {
|
||||
l.Printf(
|
||||
logFmt,
|
||||
time.Now().Format(dateFmt),
|
||||
result.Status,
|
||||
result.Method,
|
||||
result.URI,
|
||||
result.Size,
|
||||
result.Duration.Seconds()*1e3, // https://github.com/golang/go/issues/5491#issuecomment-66079585
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Example form from HTML5 spec http://www.w3.org/TR/html5/forms.html#writing-a-form's-user-interface -->
|
||||
<form method="post" action="/post">
|
||||
<p><label>Customer name: <input name="custname"></label></p>
|
||||
<p><label>Telephone: <input type=tel name="custtel"></label></p>
|
||||
<p><label>E-mail address: <input type=email name="custemail"></label></p>
|
||||
<fieldset>
|
||||
<legend> Pizza Size </legend>
|
||||
<p><label> <input type=radio name=size value="small"> Small </label></p>
|
||||
<p><label> <input type=radio name=size value="medium"> Medium </label></p>
|
||||
<p><label> <input type=radio name=size value="large"> Large </label></p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend> Pizza Toppings </legend>
|
||||
<p><label> <input type=checkbox name="topping" value="bacon"> Bacon </label></p>
|
||||
<p><label> <input type=checkbox name="topping" value="cheese"> Extra Cheese </label></p>
|
||||
<p><label> <input type=checkbox name="topping" value="onion"> Onion </label></p>
|
||||
<p><label> <input type=checkbox name="topping" value="mushroom"> Mushroom </label></p>
|
||||
</fieldset>
|
||||
<p><label>Preferred delivery time: <input type=time min="11:00" max="21:00" step="900" name="delivery"></label></p>
|
||||
<p><label>Delivery instructions: <textarea name="comments"></textarea></label></p>
|
||||
<p><button>Submit order</button></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@ -0,0 +1,259 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 100 100">
|
||||
|
||||
<title>SVG Logo</title>
|
||||
|
||||
<a xlink:href="http://www.w3.org/Graphics/SVG/" target="_parent"
|
||||
xlink:title="W3C SVG Working Group home page">
|
||||
|
||||
<rect
|
||||
id="background"
|
||||
fill="#FF9900"
|
||||
width="100"
|
||||
height="100"
|
||||
rx="4"
|
||||
ry="4"/>
|
||||
|
||||
<rect
|
||||
id="top-left"
|
||||
fill="#FFB13B"
|
||||
width="50"
|
||||
height="50"
|
||||
rx="4"
|
||||
ry="4"/>
|
||||
|
||||
<rect
|
||||
id="bottom-right"
|
||||
x="50"
|
||||
y="50"
|
||||
fill="#DE8500"
|
||||
width="50"
|
||||
height="50"
|
||||
rx="4"
|
||||
ry="4"/>
|
||||
|
||||
<g id="circles" fill="#FF9900">
|
||||
<circle
|
||||
id="n"
|
||||
cx="50"
|
||||
cy="18.4"
|
||||
r="18.4"/>
|
||||
|
||||
<circle
|
||||
id="ne"
|
||||
cx="72.4"
|
||||
cy="27.6"
|
||||
r="18.4"/>
|
||||
|
||||
<circle
|
||||
id="e"
|
||||
cx="81.6"
|
||||
cy="50"
|
||||
r="18.4"/>
|
||||
|
||||
<circle
|
||||
id="se"
|
||||
cx="72.4"
|
||||
cy="72.4"
|
||||
r="18.4"/>
|
||||
|
||||
<circle
|
||||
id="s"
|
||||
cx="50"
|
||||
cy="81.6"
|
||||
r="18.4"/>
|
||||
|
||||
<circle
|
||||
id="sw"
|
||||
cx="27.6"
|
||||
cy="72.4"
|
||||
r="18.4"/>
|
||||
|
||||
<circle
|
||||
id="w"
|
||||
cx="18.4"
|
||||
cy="50"
|
||||
r="18.4"/>
|
||||
|
||||
<circle
|
||||
id="nw"
|
||||
cx="27.6"
|
||||
cy="27.6"
|
||||
r="18.4"/>
|
||||
</g>
|
||||
|
||||
<g id="stars">
|
||||
<path
|
||||
id="black-star"
|
||||
d="M 63.086, 18.385
|
||||
c 0.000, -7.227 -5.859,-13.086 -13.100,-13.086
|
||||
c -7.235, 0.000 -13.096, 5.859 -13.096, 13.086
|
||||
c -5.100, -5.110 -13.395, -5.110 -18.497, 0.000
|
||||
c -5.119, 5.120 -5.119, 13.408 0.000, 18.524
|
||||
c -7.234, 0.000 -13.103, 5.859 -13.103, 13.085
|
||||
c 0.000, 7.230 5.870, 13.098 13.103, 13.098
|
||||
c -5.119, 5.110 -5.119, 13.395 0.000, 18.515
|
||||
c 5.102, 5.104 13.397, 5.104 18.497, 0.000
|
||||
c 0.000, 7.228 5.860, 13.083 13.096, 13.083
|
||||
c 7.240, 0.000 13.100, -5.855 13.100,-13.083
|
||||
c 5.118, 5.104 13.416, 5.104 18.513, 0.000
|
||||
c 5.101, -5.120 5.101,-13.410 0.000,-18.515
|
||||
c 7.216, 0.000 13.081, -5.869 13.081,-13.098
|
||||
c 0.000, -7.227 -5.865,-13.085 -13.081,-13.085
|
||||
c 5.101, -5.119 5.101,-13.406 0.000,-18.524
|
||||
C 76.502, 13.275 68.206, 13.275 63.086, 18.385 z"/>
|
||||
|
||||
<path
|
||||
id="white-star"
|
||||
fill="#FFFFFF"
|
||||
d="M 55.003, 23.405
|
||||
v 14.488
|
||||
L 65.260, 27.640
|
||||
c 0.000, -1.812 0.691,-3.618 2.066, -5.005
|
||||
c 2.780, -2.771 7.275,-2.771 10.024, 0.000
|
||||
c 2.771, 2.766 2.771, 7.255 0.000, 10.027
|
||||
c -1.377, 1.375 -3.195, 2.072 -5.015, 2.072
|
||||
L 62.101, 44.982
|
||||
H 76.590
|
||||
c 1.290, -1.280 3.054,-2.076 5.011, -2.076
|
||||
c 3.900, 0.000 7.078, 3.179 7.078, 7.087
|
||||
c 0.000, 3.906 -3.178, 7.088 -7.078, 7.088
|
||||
c -1.957, 0.000 -3.721,-0.798 -5.011, -2.072
|
||||
H 62.100
|
||||
l 10.229, 10.244
|
||||
c 1.824, 0.000 3.642, 0.694 5.015, 2.086
|
||||
c 2.774, 2.759 2.774, 7.250 0.000, 10.010
|
||||
c -2.750, 2.774 -7.239, 2.774 -10.025, 0.000
|
||||
c -1.372, -1.372 -2.064,-3.192 -2.064, -5.003
|
||||
L 55.000, 62.094
|
||||
v 14.499
|
||||
c 1.271, 1.276 2.084, 3.054 2.084, 5.013
|
||||
c 0.000, 3.906 -3.177, 7.077 -7.098, 7.077
|
||||
c -3.919, 0.000 -7.094,-3.167 -7.094, -7.077
|
||||
c 0.000, -1.959 0.811,-3.732 2.081, -5.013
|
||||
V 62.094
|
||||
L 34.738, 72.346
|
||||
c 0.000, 1.812 -0.705, 3.627 -2.084, 5.003
|
||||
c -2.769, 2.772 -7.251, 2.772 -10.024, 0.000
|
||||
c -2.775, -2.764 -2.775,-7.253 0.000,-10.012
|
||||
c 1.377, -1.390 3.214,-2.086 5.012, -2.086
|
||||
l 10.257,-10.242
|
||||
H 23.414
|
||||
c -1.289, 1.276 -3.072, 2.072 -5.015, 2.072
|
||||
c -3.917, 0.000 -7.096,-3.180 -7.096, -7.088
|
||||
s 3.177, -7.087 7.096,-7.087
|
||||
c 1.940, 0.000 3.725, 0.796 5.015, 2.076
|
||||
h 14.488
|
||||
L 27.646, 34.736
|
||||
c -1.797, 0.000 -3.632,-0.697 -5.012, -2.071
|
||||
c -2.775, -2.772 -2.775,-7.260 0.000,-10.027
|
||||
c 2.773, -2.771 7.256,-2.771 10.027, 0.000
|
||||
c 1.375, 1.386 2.083, 3.195 2.083, 5.005
|
||||
l 10.235, 10.252
|
||||
V 23.407
|
||||
c -1.270, -1.287 -2.082,-3.053 -2.082, -5.023
|
||||
c 0.000, -3.908 3.175,-7.079 7.096, -7.079
|
||||
c 3.919, 0.000 7.097, 3.168 7.097, 7.079
|
||||
C 57.088, 20.356 56.274,22.119 55.003, 23.405 z"/>
|
||||
</g>
|
||||
|
||||
<g id="svg-textbox">
|
||||
<path
|
||||
id="text-backdrop"
|
||||
fill="black"
|
||||
d="M 5.30,50.00
|
||||
H 94.68
|
||||
V 90.00
|
||||
Q 94.68,95.00 89.68,95.00
|
||||
H 10.30
|
||||
Q 5.30,95.00 5.30,90.00 Z"/>
|
||||
|
||||
<path
|
||||
id="shine"
|
||||
fill="#3F3F3F"
|
||||
d="M 14.657,54.211
|
||||
h 71.394
|
||||
c 2.908, 0.000 5.312, 2.385 5.312, 5.315
|
||||
v 17.910
|
||||
c -27.584,-3.403 -54.926,-8.125 -82.011,-7.683
|
||||
V 59.526
|
||||
C 9.353,56.596 11.743,54.211 14.657,54.211
|
||||
L 14.657,54.211 z"/>
|
||||
|
||||
<g id="svg-text">
|
||||
<title>SVG</title>
|
||||
<path
|
||||
id="S"
|
||||
fill="#FFFFFF"
|
||||
stroke="#000000"
|
||||
stroke-width="0.5035"
|
||||
d="M 18.312,72.927
|
||||
c -2.103,-2.107 -3.407, -5.028 -3.407, -8.253
|
||||
c 0.000,-6.445 5.223,-11.672 11.666,-11.672
|
||||
c 6.446, 0.000 11.667, 5.225 11.667, 11.672
|
||||
h -6.832
|
||||
c 0.000,-2.674 -2.168, -4.837 -4.835, -4.837
|
||||
c -2.663, 0.000 -4.838, 2.163 -4.838, 4.837
|
||||
c 0.000, 1.338 0.549, 2.536 1.415, 3.420
|
||||
l 0.000, 0.000
|
||||
c 0.883, 0.874 2.101, 1.405 3.423, 1.405
|
||||
v 0.012
|
||||
c 3.232, 0.000 6.145, 1.309 8.243, 3.416
|
||||
l 0.000, 0.000
|
||||
c 2.118, 2.111 3.424, 5.034 3.424, 8.248
|
||||
c 0.000, 6.454 -5.221, 11.680 -11.667, 11.680
|
||||
c -6.442, 0.000 -11.666, -5.222 -11.666,-11.680
|
||||
h 6.828
|
||||
c 0.000, 2.679 2.175, 4.835 4.838, 4.835
|
||||
c 2.667, 0.000 4.835, -2.156 4.835, -4.835
|
||||
c 0.000,-1.329 -0.545, -2.527 -1.429, -3.407
|
||||
l 0.000, 0.000
|
||||
c -0.864,-0.880 -2.082, -1.418 -3.406, -1.418
|
||||
l 0.000, 0.000
|
||||
C 23.341,76.350 20.429, 75.036 18.312, 72.927
|
||||
L 18.312,72.927
|
||||
L 18.312,72.927 z"/>
|
||||
<polygon
|
||||
id="V"
|
||||
fill="#FFFFFF"
|
||||
stroke="#000000"
|
||||
stroke-width="0.5035"
|
||||
points="61.588,53.005
|
||||
53.344,92.854
|
||||
46.494,92.854
|
||||
38.236,53.005
|
||||
45.082,53.005
|
||||
49.920,76.342
|
||||
54.755,53.005"/>
|
||||
|
||||
<path
|
||||
id="G"
|
||||
fill="#FFFFFF"
|
||||
stroke="#000000"
|
||||
stroke-width="0.5035"
|
||||
d="M 73.255,69.513
|
||||
h 11.683
|
||||
v 11.664
|
||||
l 0.000, 0.000
|
||||
c 0.000, 6.452 -5.226,11.678 -11.669, 11.678
|
||||
c -6.441, 0.000 -11.666,-5.226 -11.666,-11.678
|
||||
l 0.000, 0.000
|
||||
V 64.676
|
||||
h -0.017
|
||||
C 61.586,58.229 66.827,53.000 73.253, 53.000
|
||||
c 6.459, 0.000 11.683, 5.225 11.683, 11.676
|
||||
h -6.849
|
||||
c 0.000,-2.674 -2.152,-4.837 -4.834, -4.837
|
||||
c -2.647, 0.000 -4.820, 2.163 -4.820, 4.837
|
||||
v 16.501
|
||||
l 0.000, 0.000
|
||||
c 0.000, 2.675 2.173, 4.837 4.820, 4.837
|
||||
c 2.682, 0.000 4.834,-2.162 4.834, -4.827
|
||||
v -0.012
|
||||
v -4.827
|
||||
h -4.834
|
||||
L 73.255,69.513
|
||||
L 73.255,69.513 z"/>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv='content-type' value='text/html;charset=utf8'>
|
||||
<meta name='generator' value='Ronn/v0.7.3 (http://github.com/rtomayko/ronn/tree/0.7.3)'>
|
||||
<title>go-httpbin(1): HTTP Client Testing Service</title>
|
||||
<style type='text/css' media='all'>
|
||||
/* style: man */
|
||||
body#manpage {margin:0;background:#fff;}
|
||||
.mp {max-width:100ex;padding:0 9ex 1ex 4ex}
|
||||
.mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}
|
||||
.mp h2 {margin:10px 0 0 0}
|
||||
.mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}
|
||||
.mp h3 {margin:0 0 0 4ex}
|
||||
.mp dt {margin:0;clear:left}
|
||||
.mp dt.flush {float:left;width:8ex}
|
||||
.mp dd {margin:0 0 0 9ex}
|
||||
.mp h1,.mp h2,.mp h3,.mp h4 {clear:left}
|
||||
.mp pre {margin-bottom:20px}
|
||||
.mp pre+h2,.mp pre+h3 {margin-top:22px}
|
||||
.mp h2+pre,.mp h3+pre {margin-top:5px}
|
||||
.mp img {display:block;margin:auto}
|
||||
.mp h1.man-title {display:none}
|
||||
.mp,.mp code,.mp pre,.mp tt,.mp kbd,.mp samp,.mp h3,.mp h4 {font-family:monospace;font-size:14px;line-height:1.42857142857143}
|
||||
.mp h2 {font-size:16px;line-height:1.25}
|
||||
.mp h1 {font-size:20px;line-height:2}
|
||||
.mp,.mp code,.mp pre,.mp pre code,.mp tt,.mp kbd,.mp samp {color:#131211}
|
||||
.mp h1,.mp h2,.mp h3,.mp h4 {color:#030201}
|
||||
.mp u {text-decoration:underline}
|
||||
.mp code,.mp strong,.mp b {font-weight:bold;color:#131211}
|
||||
.mp em,.mp var {font-style:italic;color:#232221;text-decoration:none}
|
||||
.mp a,.mp a:link,.mp a:hover,.mp a code,.mp a pre,.mp a tt,.mp a kbd,.mp a samp {color:#0000ff}
|
||||
.mp b.man-ref {font-weight:normal;color:#434241}
|
||||
.mp pre {padding:0 4ex}
|
||||
.mp pre code {font-weight:normal;color:#434241}
|
||||
.mp h2+pre,h3+pre {padding-left:0}
|
||||
ol.man-decor,ol.man-decor li {margin:3px 0 10px 0;padding:0;float:left;width:33%;list-style-type:none;text-transform:uppercase;color:#999;letter-spacing:1px}
|
||||
ol.man-decor {width:100%}
|
||||
ol.man-decor li.tl {text-align:left}
|
||||
ol.man-decor li.tc {text-align:center;letter-spacing:4px}
|
||||
ol.man-decor li.tr {text-align:right;float:right}
|
||||
</style>
|
||||
<style type='text/css' media='all'>
|
||||
/* style: 80c */
|
||||
.mp {max-width:86ex}
|
||||
ul {list-style: None; margin-left: 1em!important}
|
||||
.man-navigation {left:101ex}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body id='manpage'>
|
||||
|
||||
|
||||
<div class='mp'>
|
||||
<h1>go-httpbin(1)</h1>
|
||||
<p>A golang port of the venerable <a href="https://httpbin.org/">httpbin.org</a> HTTP request & response testing service.</p>
|
||||
|
||||
<h2 id="ENDPOINTS">ENDPOINTS</h2>
|
||||
|
||||
<ul>
|
||||
<li><a href="/"><code>/</code></a> This page.</li>
|
||||
<li><a href="/absolute-redirect/6"><code>/absolute-redirect/:n</code></a> 302 Absolute redirects <em>n</em> times.</li>
|
||||
<li><a href="/base64/aHR0cGJpbmdvLm9yZw=="><code>/base64/:value</code></a> Decodes a Base64 encoded string.</li>
|
||||
<li><a href="/base64/decode/aHR0cGJpbmdvLm9yZw=="><code>/base64/decode/:value</code></a> Explicit URL for decoding a Base64 encoded string.</li>
|
||||
<li><a href="/base64/encode/httpbingo.org"><code>/base64/encode/:value</code></a> Encodes a string into Base64.</li>
|
||||
<li><a href="/basic-auth/user/passwd"><code>/basic-auth/:user/:passwd</code></a> Challenges HTTPBasic Auth.</li>
|
||||
<li><a href="/bearer"><code>/bearer</code></a> Checks Bearer token header - returns 401 if not set.</li>
|
||||
<li><a href="/brotli"><code><del>/brotli</del></code></a> Returns brotli-encoded data.</del> <i>Not implemented!</i></li>
|
||||
<li><a href="/bytes/1024"><code>/bytes/:n</code></a> Generates <em>n</em> random bytes of binary data, accepts optional <em>seed</em> integer parameter.</li>
|
||||
<li><a href="/cache"><code>/cache</code></a> Returns 200 unless an If-Modified-Since or If-None-Match header is provided, when it returns a 304.</li>
|
||||
<li><a href="/cache/60"><code>/cache/:n</code></a> Sets a Cache-Control header for <em>n</em> seconds.</li>
|
||||
<li><a href="/cookies"><code>/cookies</code></a> Returns cookie data.</li>
|
||||
<li><a href="/cookies/delete?k1=&k2="><code>/cookies/delete?name</code></a> Deletes one or more simple cookies.</li>
|
||||
<li><a href="/cookies/set?k1=v1&k2=v2"><code>/cookies/set?name=value</code></a> Sets one or more simple cookies.</li>
|
||||
<li><a href="/deflate"><code>/deflate</code></a> Returns deflate-encoded data.</li>
|
||||
<li><a href="/delay/3"><code>/delay/:n</code></a> Delays responding for <em>min(n, 10)</em> seconds.</li>
|
||||
<li><a href="/deny"><code>/deny</code></a> Denied by robots.txt file.</li>
|
||||
<li><a href="/digest-auth/auth/user/passwd/MD5"><code>/digest-auth/:qop/:user/:passwd/:algorithm</code></a> Challenges HTTP Digest Auth.</li>
|
||||
<li><a href="/digest-auth/auth/user/passwd/MD5"><code>/digest-auth/:qop/:user/:passwd</code></a> Challenges HTTP Digest Auth.</li>
|
||||
<li><a href="/drip?code=200&numbytes=5&duration=5"><code>/drip?numbytes=n&duration=s&delay=s&code=code</code></a> Drips data over a duration after an optional initial delay, then (optionally) returns with the given status code.</li>
|
||||
<li><a href="/encoding/utf8"><code>/encoding/utf8</code></a> Returns page containing UTF-8 data.</li>
|
||||
<li><a href="/etag/etag"><code>/etag/:etag</code></a> Assumes the resource has the given etag and responds to If-None-Match header with a 200 or 304 and If-Match with a 200 or 412 as appropriate.</li>
|
||||
<li><a href="/forms/post"><code>/forms/post</code></a> HTML form that submits to <em>/post</em></li>
|
||||
<li><a href="/get"><code>/get</code></a> Returns GET data.</li>
|
||||
<li><a href="/gzip"><code>/gzip</code></a> Returns gzip-encoded data.</li>
|
||||
<li><a href="/headers"><code>/headers</code></a> Returns header dict.</li>
|
||||
<li><a href="/hidden-basic-auth/user/passwd"><code>/hidden-basic-auth/:user/:passwd</code></a> 404'd BasicAuth.</li>
|
||||
<li><a href="/html"><code>/html</code></a> Renders an HTML Page.</li>
|
||||
<li><a href="/image"><code>/image</code></a> Returns page containing an image based on sent Accept header.</li>
|
||||
<li><a href="/image/jpeg"><code>/image/jpeg</code></a> Returns a JPEG image.</li>
|
||||
<li><a href="/image/png"><code>/image/png</code></a> Returns a PNG image.</li>
|
||||
<li><a href="/image/svg"><code>/image/svg</code></a> Returns a SVG image.</li>
|
||||
<li><a href="/image/webp"><code>/image/webp</code></a> Returns a WEBP image.</li>
|
||||
<li><a href="/ip"><code>/ip</code></a> Returns Origin IP.</li>
|
||||
<li><a href="/json"><code>/json</code></a> Returns JSON.</li>
|
||||
<li><a href="/links/10"><code>/links/:n</code></a> Returns page containing <em>n</em> HTML links.</li>
|
||||
<li><a href="/range/1024"><code>/range/1024?duration=s&chunk_size=code</code></a> Streams <em>n</em> bytes, and allows specifying a <em>Range</em> header to select a subset of the data. Accepts a <em>chunk_size</em> and request <em>duration</em> parameter.</li>
|
||||
<li><a href="/redirect-to?status_code=307&url=http%3A%2F%2Fexample.com%2F"><code>/redirect-to?url=foo&status_code=307</code></a> 307 Redirects to the <em>foo</em> URL.</li>
|
||||
<li><a href="/redirect-to?url=http%3A%2F%2Fexample.com%2F"><code>/redirect-to?url=foo</code></a> 302 Redirects to the <em>foo</em> URL.</li>
|
||||
<li><a href="/redirect/6"><code>/redirect/:n</code></a> 302 Redirects <em>n</em> times.</li>
|
||||
<li><a href="/relative-redirect/6"><code>/relative-redirect/:n</code></a> 302 Relative redirects <em>n</em> times.</li>
|
||||
<li><a href="/response-headers?Server=httpbin&Content-Type=text%2Fplain%3B+charset%3DUTF-8"><code>/response-headers?key=val</code></a> Returns given response headers.</li>
|
||||
<li><a href="/robots.txt"><code>/robots.txt</code></a> Returns some robots.txt rules.</li>
|
||||
<li><a href="/status/418"><code>/status/:code</code></a> Returns given HTTP Status code.</li>
|
||||
<li><a href="/stream-bytes/1024"><code>/stream-bytes/:n</code></a> Streams <em>n</em> random bytes of binary data, accepts optional <em>seed</em> and <em>chunk_size</em> integer parameters.</li>
|
||||
<li><a href="/stream/20"><code>/stream/:n</code></a> Streams <em>min(n, 100)</em> lines.</li>
|
||||
<li><a href="/user-agent"><code>/user-agent</code></a> Returns user-agent.</li>
|
||||
<li><a href="/uuid"><code>/uuid</code></a> Generates a <a href="https://en.wikipedia.org/wiki/Universally_unique_identifier">UUIDv4</a> value.</li>
|
||||
<li><a href="/xml"><code>/xml</code></a> Returns some XML</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="DESCRIPTION">DESCRIPTION</h2>
|
||||
|
||||
<p>Testing an HTTP Library can become difficult sometimes. <a href="http://requestb.in">RequestBin</a> is fantastic for testing POST requests, but doesn't let you control the response. This exists to cover all kinds of HTTP scenarios. Additional endpoints are being considered.</p>
|
||||
|
||||
<p>All endpoint responses are JSON-encoded.</p>
|
||||
|
||||
<h2 id="EXAMPLES">EXAMPLES</h2>
|
||||
|
||||
<h3 id="-curl-http-httpbin-org-ip">$ curl https://httpbingo.org/ip</h3>
|
||||
|
||||
<pre><code>{"origin": "24.127.96.129"}
|
||||
</code></pre>
|
||||
|
||||
<h3 id="-curl-http-httpbin-org-user-agent">$ curl https://httpbingo.org/user-agent</h3>
|
||||
|
||||
<pre><code>{"user-agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3"}
|
||||
</code></pre>
|
||||
|
||||
<h3 id="-curl-http-httpbin-org-get">$ curl https://httpbingo.org/get</h3>
|
||||
|
||||
<pre><code>{
|
||||
"args": {},
|
||||
"headers": {
|
||||
"Accept": "*/*",
|
||||
"Connection": "close",
|
||||
"Content-Length": "",
|
||||
"Content-Type": "",
|
||||
"Host": "httpbin.org",
|
||||
"User-Agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3"
|
||||
},
|
||||
"origin": "24.127.96.129",
|
||||
"url": "https://httpbingo.org/get"
|
||||
}
|
||||
</code></pre>
|
||||
|
||||
<h3 id="-curl-I-http-httpbin-org-status-418">$ curl -I https://httpbingo.org/status/418</h3>
|
||||
|
||||
<pre><code>HTTP/1.1 418 I'M A TEAPOT
|
||||
Server: nginx/0.7.67
|
||||
Date: Mon, 13 Jun 2011 04:25:38 GMT
|
||||
Connection: close
|
||||
x-more-info: http://tools.ietf.org/html/rfc2324
|
||||
Content-Length: 135
|
||||
</code></pre>
|
||||
|
||||
<h3 id="-curl-https-httpbin-org-get-show_env-1">$ curl https://httpbingo.org/get?show_env=1</h3>
|
||||
|
||||
<pre><code>{
|
||||
"headers": {
|
||||
"Content-Length": "",
|
||||
"Accept-Language": "en-US,en;q=0.8",
|
||||
"Accept-Encoding": "gzip,deflate,sdch",
|
||||
"X-Forwarded-Port": "443",
|
||||
"X-Forwarded-For": "109.60.101.240",
|
||||
"Host": "httpbin.org",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux i686) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.83 Safari/535.11",
|
||||
"X-Request-Start": "1350053933441",
|
||||
"Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.3",
|
||||
"Connection": "keep-alive",
|
||||
"X-Forwarded-Proto": "https",
|
||||
"Cookie": "_gauges_unique_day=1; _gauges_unique_month=1; _gauges_unique_year=1; _gauges_unique=1; _gauges_unique_hour=1",
|
||||
"Content-Type": ""
|
||||
},
|
||||
"args": {
|
||||
"show_env": "1"
|
||||
},
|
||||
"origin": "109.60.101.240",
|
||||
"url": "https://httpbingo.org/get?show_env=1"
|
||||
}
|
||||
</code></pre>
|
||||
|
||||
|
||||
<h2 id="AUTHOR">AUTHOR</h2>
|
||||
|
||||
<p>Ported to Go by <a href="https://github.com/mccutchen">Will McCutchen</a>.</p>
|
||||
<p>From <a href="https://httpbin.org/">the original</a> <a href="http://kennethreitz.com/">Kenneth Reitz</a> project.</p>
|
||||
|
||||
<h2 id="SEE-ALSO">SEE ALSO</h2>
|
||||
|
||||
<p><a href="https://httpbin.org/">httpbin.org</a> — the original httpbin</p>
|
||||
|
||||
</div>
|
||||
|
||||
<a href="https://github.com/mccutchen/go-httpbin"><img style="position: absolute; top: 0; right: 0; border: 0;" src="https://camo.githubusercontent.com/38ef81f8aca64bb9a64448d0d70f1308ef5341ab/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6461726b626c75655f3132313632312e706e67" alt="Fork me on GitHub" data-canonical-src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png"></a>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Herman Melville - Moby-Dick</h1>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
Availing himself of the mild, summer-cool weather that now reigned in these latitudes, and in preparation for the peculiarly active pursuits shortly to be anticipated, Perth, the begrimed, blistered old blacksmith, had not removed his portable forge to the hold again, after concluding his contributory work for Ahab's leg, but still retained it on deck, fast lashed to ringbolts by the foremast; being now almost incessantly invoked by the headsmen, and harpooneers, and bowsmen to do some little job for them; altering, or repairing, or new shaping their various weapons and boat furniture. Often he would be surrounded by an eager circle, all waiting to be served; holding boat-spades, pike-heads, harpoons, and lances, and jealously watching his every sooty movement, as he toiled. Nevertheless, this old man's was a patient hammer wielded by a patient arm. No murmur, no impatience, no petulance did come from him. Silent, slow, and solemn; bowing over still further his chronically broken back, he toiled away, as if toil were life itself, and the heavy beating of his hammer the heavy beating of his heart. And so it was.—Most miserable! A peculiar walk in this old man, a certain slight but painful appearing yawing in his gait, had at an early period of the voyage excited the curiosity of the mariners. And to the importunity of their persisted questionings he had finally given in; and so it came to pass that every one now knew the shameful story of his wretched fate. Belated, and not innocently, one bitter winter's midnight, on the road running between two country towns, the blacksmith half-stupidly felt the deadly numbness stealing over him, and sought refuge in a leaning, dilapidated barn. The issue was, the loss of the extremities of both feet. Out of this revelation, part by part, at last came out the four acts of the gladness, and the one long, and as yet uncatastrophied fifth act of the grief of his life's drama. He was an old man, who, at the age of nearly sixty, had postponedly encountered that thing in sorrow's technicals called ruin. He had been an artisan of famed excellence, and with plenty to do; owned a house and garden; embraced a youthful, daughter-like, loving wife, and three blithe, ruddy children; every Sunday went to a cheerful-looking church, planted in a grove. But one night, under cover of darkness, and further concealed in a most cunning disguisement, a desperate burglar slid into his happy home, and robbed them all of everything. And darker yet to tell, the blacksmith himself did ignorantly conduct this burglar into his family's heart. It was the Bottle Conjuror! Upon the opening of that fatal cork, forth flew the fiend, and shrivelled up his home. Now, for prudent, most wise, and economic reasons, the blacksmith's shop was in the basement of his dwelling, but with a separate entrance to it; so that always had the young and loving healthy wife listened with no unhappy nervousness, but with vigorous pleasure, to the stout ringing of her young-armed old husband's hammer; whose reverberations, muffled by passing through the floors and walls, came up to her, not unsweetly, in her nursery; and so, to stout Labor's iron lullaby, the blacksmith's infants were rocked to slumber. Oh, woe on woe! Oh, Death, why canst thou not sometimes be timely? Hadst thou taken this old blacksmith to thyself ere his full ruin came upon him, then had the young widow had a delicious grief, and her orphans a truly venerable, legendary sire to dream of in their after years; and all of them a care-killing competency.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,21 @@
|
||||
{
|
||||
"slideshow": {
|
||||
"author": "Yours Truly",
|
||||
"date": "date of publication",
|
||||
"slides": [
|
||||
{
|
||||
"title": "Wake up to WonderWidgets!",
|
||||
"type": "all"
|
||||
},
|
||||
{
|
||||
"items": [
|
||||
"Why <em>WonderWidgets</em> are great",
|
||||
"Who <em>buys</em> WonderWidgets"
|
||||
],
|
||||
"title": "Overview",
|
||||
"type": "all"
|
||||
}
|
||||
],
|
||||
"title": "Sample Slide Show"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<?xml version='1.0' encoding='us-ascii'?>
|
||||
|
||||
<!-- A SAMPLE set of slides -->
|
||||
|
||||
<slideshow
|
||||
title="Sample Slide Show"
|
||||
date="Date of publication"
|
||||
author="Yours Truly"
|
||||
>
|
||||
|
||||
<!-- TITLE SLIDE -->
|
||||
<slide type="all">
|
||||
<title>Wake up to WonderWidgets!</title>
|
||||
</slide>
|
||||
|
||||
<!-- OVERVIEW -->
|
||||
<slide type="all">
|
||||
<title>Overview</title>
|
||||
<item>Why <em>WonderWidgets</em> are great</item>
|
||||
<item/>
|
||||
<item>Who <em>buys</em> WonderWidgets</item>
|
||||
</slide>
|
||||
|
||||
</slideshow>
|
||||
Loading…
Reference in New Issue