Skip to content
quarry Artifact [16f3257588]
Login

Artifact [16f3257588]

Artifact 16f3257588a49eb87b0b44a705a6851c76ad7489ed16b36c9a04452b0ea050ce:


// router.go — the API consumer of the registry (0001 §9). Every registered
// op is mounted on a stdlib ServeMux; input crosses through the shared
// codecs and validator, output leaves as JSON. The generated OpenAPI
// document is served from the same registry at /api/v1/openapi.json.
package api

import (
	"encoding/json"
	"fmt"
	"mime"
	"net/http"
	"net/url"
	"reflect"
	"strings"

	"craftdesign.group/quarry/internal/ops"
)

// Router mounts every op in r plus the OpenAPI document. version flows
// into the spec's info block.
func Router(r *ops.Registry, version string) (http.Handler, error) {
	mux := http.NewServeMux()
	for _, d := range r.All() {
		mux.Handle(d.Method+" "+d.Pattern, opHandler(r, d))
	}

	spec, err := ops.Spec(r, "quarry", version)
	if err != nil {
		return nil, err
	}
	specJSON, err := json.MarshalIndent(spec, "", "  ")
	if err != nil {
		return nil, err
	}
	mux.HandleFunc("GET /api/v1/openapi.json", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write(specJSON)
	})
	return mux, nil
}

func opHandler(r *ops.Registry, d *ops.Descriptor) http.Handler {
	wild := ops.Wildcards(d.Pattern)
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		in, err := decodeIn(req, d, wild)
		if err != nil {
			writeError(w, http.StatusBadRequest, err)
			return
		}
		if err := ops.Validate(in); err != nil {
			writeError(w, http.StatusBadRequest, err)
			return
		}
		out, err := r.Invoke(req.Context(), d.Name, in)
		if err != nil {
			writeError(w, http.StatusInternalServerError, err)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(out)
	})
}

// decodeIn assembles the op's In struct from the request: query string
// (GET) or body (POST, form or JSON by content type), with path wildcards
// merged last so the routed path is always authoritative.
func decodeIn(req *http.Request, d *ops.Descriptor, wild []string) (any, error) {
	inPtr := d.NewIn()

	switch {
	case d.Method == http.MethodGet:
		if err := ops.DecodeValues(req.URL.Query(), inPtr); err != nil {
			return nil, err
		}
	case isJSON(req):
		if err := json.NewDecoder(req.Body).Decode(inPtr); err != nil {
			return nil, fmt.Errorf("body: %w", err)
		}
	default:
		if err := req.ParseForm(); err != nil {
			return nil, fmt.Errorf("form: %w", err)
		}
		if err := ops.DecodeValues(req.PostForm, inPtr); err != nil {
			return nil, err
		}
	}

	if len(wild) > 0 {
		pathVals := url.Values{}
		for _, name := range wild {
			pathVals.Set(name, req.PathValue(name))
		}
		if err := ops.DecodeValues(pathVals, inPtr); err != nil {
			return nil, err
		}
	}
	return deref(inPtr), nil
}

func deref(p any) any {
	return reflect.ValueOf(p).Elem().Interface()
}

func isJSON(req *http.Request) bool {
	ct, _, _ := mime.ParseMediaType(req.Header.Get("Content-Type"))
	return strings.HasSuffix(ct, "/json") || strings.HasSuffix(ct, "+json")
}

func writeError(w http.ResponseWriter, code int, err error) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}