Skip to content

balyakin/querygate

Repository files navigation

querygate

querygate is a small Go middleware and reverse proxy for teams that want to adopt the HTTP QUERY method without turning safe lookups into accidental POST APIs.

The short version: when a read request needs a body, GET stops being a good fit and POST tells too little of the truth. QUERY keeps the semantics readable: the request is safe, idempotent, body-aware, and cacheable when the response allows it.

querygate terminal screenshot

What It Does

querygate gives you two pieces that can be used together or separately:

Piece Use it when What it gives you
query package You own a Go net/http service QUERY validation, Accept-Query discovery, correct OPTIONS behavior, and content negotiation checks
querygate proxy You need to put something in front of an existing service Body-aware caching for QUERY, safe cache defaults, health checks, request IDs, and an optional QUERY to POST bridge

The project is intentionally boring in the places that matter. It uses the Go standard library, has bounded request and cache sizes, ships with timeouts, and refuses to cache responses that look private or ambiguous.

Why QUERY

Many APIs start with GET /search?q=.... Then filters get larger, clients need structured bodies, and the endpoint quietly becomes POST /search even though nothing is being created or changed.

That workaround costs you useful HTTP semantics:

Method Request body Intended meaning Cache story
GET Not a practical place for payloads Safe lookup through the URI Mature, URI-keyed
QUERY Yes Safe, idempotent lookup with request content Body-aware cache key
POST Yes Often means mutation, sometimes does not Usually skipped by shared caches

querygate exists for the middle case: large or structured reads that should still be treated as reads.

Quick Start

Run the example backend in one terminal:

go run ./examples/backend

Run the proxy in another:

go run ./cmd/querygate \
  --listen :8080 \
  --upstream http://127.0.0.1:9000

Send the same QUERY twice:

curl -i -X QUERY http://127.0.0.1:8080/search \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  --data '{"q":"latency budget","limit":3}'

The first response should include:

X-Querygate-Cache: MISS

The repeated response should include:

X-Querygate-Cache: HIT
Age: <seconds>

The exact Age value will depend on how quickly you repeat the request.

Installation

Install the CLI from the module path:

go install github.com/kaluga/querygate/cmd/querygate@latest

Or build the local checkout:

make build
./querygate --listen :8080 --upstream http://127.0.0.1:9000

The Docker image is intentionally plain:

make docker
docker run --rm -p 8080:8080 querygate:dev \
  --upstream http://host.docker.internal:9000

Middleware Example

Use the query package when your Go service can handle QUERY directly.

package main

import (
	"log"
	"net/http"

	"github.com/kaluga/querygate/query"
)

func main() {
	search := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "public, max-age=60")
		_, _ = w.Write([]byte(`{"results":[]}` + "\n"))
	})

	handler, err := query.NewHandler(search, query.Options{})
	if err != nil {
		log.Fatal(err)
	}

	log.Fatal(http.ListenAndServe(":9000", handler))
}

By default, the middleware accepts application/json, application/x-www-form-urlencoded, and text/plain request bodies, and advertises JSON responses through Accept-Query.

Proxy Behavior

For QUERY requests, the proxy builds a cache key from the method, URI, selected request headers, media type, content encoding, and normalized body. A repeated request with the same meaning can then be served from memory without touching the upstream.

The cache is conservative by design. It skips storage for responses with Set-Cookie, private, no-store, no-cache, Vary: *, unsupported Vary headers, oversized entries, missing cacheability signals, and credentialed requests unless the response is explicitly public.

Unsafe requests such as POST, PUT, PATCH, and DELETE pass through to the upstream. Successful unsafe requests invalidate cached entries for the same URI.

QUERY to POST Bridge

Some backends will not speak QUERY on day one. With translate_on_405 enabled, querygate retries a QUERY request as POST when the upstream returns 405 Method Not Allowed.

Translated responses are not cached by default. You can opt in with cache_translated: true, but do that only when the legacy POST handler is genuinely safe and its responses are written for shared caching.

CLI

querygate --listen :8080 --upstream http://127.0.0.1:9000
querygate --config config.example.json
querygate --config config.example.json --config-check
querygate probe http://127.0.0.1:8080/search
querygate --version

Reserved endpoints:

Endpoint Response
GET /healthz ok
GET /readyz ready

Configuration

Configuration is loaded in this order:

  1. Built-in defaults
  2. JSON config file
  3. QUERYGATE_ environment variables
  4. CLI flags for --listen and --upstream

See config.example.json for a complete file.

Field Default
listen :8080
upstream http://127.0.0.1:9000
cache_capacity 1024
ttl 60s
translate_on_405 true
cache_translated false
max_request_body_bytes 1048576
max_request_header_bytes 65536
max_cache_bytes 67108864
max_entry_bytes 5242880
read_header_timeout 5s
read_timeout 30s
write_timeout 60s
idle_timeout 120s
upstream_timeout 30s
shutdown_timeout 10s
vary_headers ["Accept", "Accept-Encoding"]
log_level info
log_format text

Environment variables use the same names in screaming snake case: QUERYGATE_TTL, QUERYGATE_UPSTREAM, QUERYGATE_VARY_HEADERS, and so on.

Operational Notes

querygate is a v1 adoption layer, not a full API gateway. It does not do TLS termination, authentication, rate limiting, persistent cache storage, distributed invalidation, tracing, or load balancing. Put it behind the infrastructure you already trust for those jobs.

What it does try to get right:

  • bounded request bodies, response entries, headers, and total cache size
  • sane server and upstream timeouts
  • request IDs on proxied responses
  • Age on cached responses
  • conditional responses for cached ETag and Last-Modified entries
  • explicit cache status through X-Querygate-Cache

Troubleshooting

Symptom Likely cause
400 Bad Request Non-empty QUERY body without Content-Type, malformed Content-Type, or malformed gzip body
406 Not Acceptable The Accept header does not match a configured response type
413 Request Entity Too Large The request body exceeded max_request_body_bytes
415 Unsupported Media Type Unsupported request media type or unsupported Content-Encoding
X-Querygate-Cache: SKIP The response was not safe or useful to store
Repeated MISS Check Cache-Control, credentials, Set-Cookie, Vary, body size, request headers, and response status

Development

make fmt
make test
make race
make vet

The module has no third-party Go dependencies. That is deliberate: the cache and proxy code should stay easy to read in a code review.

Roadmap

The near-term backlog is focused on production ergonomics without changing the core shape of the project:

  • singleflight for duplicate in-flight cache misses
  • Prometheus text metrics
  • richer probe output
  • fuzz tests for parsing and cache-key normalization
  • Redis or Memcached cache implementations
  • OpenTelemetry instrumentation
  • framework adapters once the net/http surface has settled

Security

Please report security issues privately. See SECURITY.md.

License

MIT. See LICENSE.

About

Body-aware Go middleware and reverse proxy for adopting the HTTP QUERY method with conservative caching, Accept-Query discovery, and safe QUERY-to-POST fallback.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

No contributors

Languages