Commit 51c1a2c

mo khan <mo@mokhan.ca>
2025-04-07 20:37:26
chore: generate twirp rpc for golang
1 parent 4d27620
Changed files (4)
magefiles
pkg
rpc
gitlab.com
mokhax
spike
magefiles/magefile.go
@@ -86,6 +86,7 @@ func Protos() error {
 			"--go_opt=paths=source_relative",
 			"--go-grpc_out=pkg/rpc",
 			"--go-grpc_opt=paths=source_relative",
+			"--twirp_out=pkg/rpc",
 			"--ruby_out=lib/authx/rpc",
 			"--twirp_ruby_out=lib/authx/rpc",
 			file,
pkg/rpc/gitlab.com/mokhax/spike/pkg/rpc/ability.twirp.go
@@ -0,0 +1,1105 @@
+// Code generated by protoc-gen-twirp v8.1.3, DO NOT EDIT.
+// source: ability.proto
+
+package rpc
+
+import context "context"
+import fmt "fmt"
+import http "net/http"
+import io "io"
+import json "encoding/json"
+import strconv "strconv"
+import strings "strings"
+
+import protojson "google.golang.org/protobuf/encoding/protojson"
+import proto "google.golang.org/protobuf/proto"
+import twirp "github.com/twitchtv/twirp"
+import ctxsetters "github.com/twitchtv/twirp/ctxsetters"
+
+import bytes "bytes"
+import errors "errors"
+import path "path"
+import url "net/url"
+
+// Version compatibility assertion.
+// If the constant is not defined in the package, that likely means
+// the package needs to be updated to work with this generated code.
+// See https://twitchtv.github.io/twirp/docs/version_matrix.html
+const _ = twirp.TwirpPackageMinVersion_8_1_0
+
+// =================
+// Ability Interface
+// =================
+
+type Ability interface {
+	Allowed(context.Context, *AllowRequest) (*AllowReply, error)
+}
+
+// =======================
+// Ability Protobuf Client
+// =======================
+
+type abilityProtobufClient struct {
+	client      HTTPClient
+	urls        [1]string
+	interceptor twirp.Interceptor
+	opts        twirp.ClientOptions
+}
+
+// NewAbilityProtobufClient creates a Protobuf client that implements the Ability interface.
+// It communicates using Protobuf and can be configured with a custom HTTPClient.
+func NewAbilityProtobufClient(baseURL string, client HTTPClient, opts ...twirp.ClientOption) Ability {
+	if c, ok := client.(*http.Client); ok {
+		client = withoutRedirects(c)
+	}
+
+	clientOpts := twirp.ClientOptions{}
+	for _, o := range opts {
+		o(&clientOpts)
+	}
+
+	// Using ReadOpt allows backwards and forwards compatibility with new options in the future
+	literalURLs := false
+	_ = clientOpts.ReadOpt("literalURLs", &literalURLs)
+	var pathPrefix string
+	if ok := clientOpts.ReadOpt("pathPrefix", &pathPrefix); !ok {
+		pathPrefix = "/twirp" // default prefix
+	}
+
+	// Build method URLs: <baseURL>[<prefix>]/<package>.<Service>/<Method>
+	serviceURL := sanitizeBaseURL(baseURL)
+	serviceURL += baseServicePath(pathPrefix, "authx.rpc", "Ability")
+	urls := [1]string{
+		serviceURL + "Allowed",
+	}
+
+	return &abilityProtobufClient{
+		client:      client,
+		urls:        urls,
+		interceptor: twirp.ChainInterceptors(clientOpts.Interceptors...),
+		opts:        clientOpts,
+	}
+}
+
+func (c *abilityProtobufClient) Allowed(ctx context.Context, in *AllowRequest) (*AllowReply, error) {
+	ctx = ctxsetters.WithPackageName(ctx, "authx.rpc")
+	ctx = ctxsetters.WithServiceName(ctx, "Ability")
+	ctx = ctxsetters.WithMethodName(ctx, "Allowed")
+	caller := c.callAllowed
+	if c.interceptor != nil {
+		caller = func(ctx context.Context, req *AllowRequest) (*AllowReply, error) {
+			resp, err := c.interceptor(
+				func(ctx context.Context, req interface{}) (interface{}, error) {
+					typedReq, ok := req.(*AllowRequest)
+					if !ok {
+						return nil, twirp.InternalError("failed type assertion req.(*AllowRequest) when calling interceptor")
+					}
+					return c.callAllowed(ctx, typedReq)
+				},
+			)(ctx, req)
+			if resp != nil {
+				typedResp, ok := resp.(*AllowReply)
+				if !ok {
+					return nil, twirp.InternalError("failed type assertion resp.(*AllowReply) when calling interceptor")
+				}
+				return typedResp, err
+			}
+			return nil, err
+		}
+	}
+	return caller(ctx, in)
+}
+
+func (c *abilityProtobufClient) callAllowed(ctx context.Context, in *AllowRequest) (*AllowReply, error) {
+	out := new(AllowReply)
+	ctx, err := doProtobufRequest(ctx, c.client, c.opts.Hooks, c.urls[0], in, out)
+	if err != nil {
+		twerr, ok := err.(twirp.Error)
+		if !ok {
+			twerr = twirp.InternalErrorWith(err)
+		}
+		callClientError(ctx, c.opts.Hooks, twerr)
+		return nil, err
+	}
+
+	callClientResponseReceived(ctx, c.opts.Hooks)
+
+	return out, nil
+}
+
+// ===================
+// Ability JSON Client
+// ===================
+
+type abilityJSONClient struct {
+	client      HTTPClient
+	urls        [1]string
+	interceptor twirp.Interceptor
+	opts        twirp.ClientOptions
+}
+
+// NewAbilityJSONClient creates a JSON client that implements the Ability interface.
+// It communicates using JSON and can be configured with a custom HTTPClient.
+func NewAbilityJSONClient(baseURL string, client HTTPClient, opts ...twirp.ClientOption) Ability {
+	if c, ok := client.(*http.Client); ok {
+		client = withoutRedirects(c)
+	}
+
+	clientOpts := twirp.ClientOptions{}
+	for _, o := range opts {
+		o(&clientOpts)
+	}
+
+	// Using ReadOpt allows backwards and forwards compatibility with new options in the future
+	literalURLs := false
+	_ = clientOpts.ReadOpt("literalURLs", &literalURLs)
+	var pathPrefix string
+	if ok := clientOpts.ReadOpt("pathPrefix", &pathPrefix); !ok {
+		pathPrefix = "/twirp" // default prefix
+	}
+
+	// Build method URLs: <baseURL>[<prefix>]/<package>.<Service>/<Method>
+	serviceURL := sanitizeBaseURL(baseURL)
+	serviceURL += baseServicePath(pathPrefix, "authx.rpc", "Ability")
+	urls := [1]string{
+		serviceURL + "Allowed",
+	}
+
+	return &abilityJSONClient{
+		client:      client,
+		urls:        urls,
+		interceptor: twirp.ChainInterceptors(clientOpts.Interceptors...),
+		opts:        clientOpts,
+	}
+}
+
+func (c *abilityJSONClient) Allowed(ctx context.Context, in *AllowRequest) (*AllowReply, error) {
+	ctx = ctxsetters.WithPackageName(ctx, "authx.rpc")
+	ctx = ctxsetters.WithServiceName(ctx, "Ability")
+	ctx = ctxsetters.WithMethodName(ctx, "Allowed")
+	caller := c.callAllowed
+	if c.interceptor != nil {
+		caller = func(ctx context.Context, req *AllowRequest) (*AllowReply, error) {
+			resp, err := c.interceptor(
+				func(ctx context.Context, req interface{}) (interface{}, error) {
+					typedReq, ok := req.(*AllowRequest)
+					if !ok {
+						return nil, twirp.InternalError("failed type assertion req.(*AllowRequest) when calling interceptor")
+					}
+					return c.callAllowed(ctx, typedReq)
+				},
+			)(ctx, req)
+			if resp != nil {
+				typedResp, ok := resp.(*AllowReply)
+				if !ok {
+					return nil, twirp.InternalError("failed type assertion resp.(*AllowReply) when calling interceptor")
+				}
+				return typedResp, err
+			}
+			return nil, err
+		}
+	}
+	return caller(ctx, in)
+}
+
+func (c *abilityJSONClient) callAllowed(ctx context.Context, in *AllowRequest) (*AllowReply, error) {
+	out := new(AllowReply)
+	ctx, err := doJSONRequest(ctx, c.client, c.opts.Hooks, c.urls[0], in, out)
+	if err != nil {
+		twerr, ok := err.(twirp.Error)
+		if !ok {
+			twerr = twirp.InternalErrorWith(err)
+		}
+		callClientError(ctx, c.opts.Hooks, twerr)
+		return nil, err
+	}
+
+	callClientResponseReceived(ctx, c.opts.Hooks)
+
+	return out, nil
+}
+
+// ======================
+// Ability Server Handler
+// ======================
+
+type abilityServer struct {
+	Ability
+	interceptor      twirp.Interceptor
+	hooks            *twirp.ServerHooks
+	pathPrefix       string // prefix for routing
+	jsonSkipDefaults bool   // do not include unpopulated fields (default values) in the response
+	jsonCamelCase    bool   // JSON fields are serialized as lowerCamelCase rather than keeping the original proto names
+}
+
+// NewAbilityServer builds a TwirpServer that can be used as an http.Handler to handle
+// HTTP requests that are routed to the right method in the provided svc implementation.
+// The opts are twirp.ServerOption modifiers, for example twirp.WithServerHooks(hooks).
+func NewAbilityServer(svc Ability, opts ...interface{}) TwirpServer {
+	serverOpts := newServerOpts(opts)
+
+	// Using ReadOpt allows backwards and forwards compatibility with new options in the future
+	jsonSkipDefaults := false
+	_ = serverOpts.ReadOpt("jsonSkipDefaults", &jsonSkipDefaults)
+	jsonCamelCase := false
+	_ = serverOpts.ReadOpt("jsonCamelCase", &jsonCamelCase)
+	var pathPrefix string
+	if ok := serverOpts.ReadOpt("pathPrefix", &pathPrefix); !ok {
+		pathPrefix = "/twirp" // default prefix
+	}
+
+	return &abilityServer{
+		Ability:          svc,
+		hooks:            serverOpts.Hooks,
+		interceptor:      twirp.ChainInterceptors(serverOpts.Interceptors...),
+		pathPrefix:       pathPrefix,
+		jsonSkipDefaults: jsonSkipDefaults,
+		jsonCamelCase:    jsonCamelCase,
+	}
+}
+
+// writeError writes an HTTP response with a valid Twirp error format, and triggers hooks.
+// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err)
+func (s *abilityServer) writeError(ctx context.Context, resp http.ResponseWriter, err error) {
+	writeError(ctx, resp, err, s.hooks)
+}
+
+// handleRequestBodyError is used to handle error when the twirp server cannot read request
+func (s *abilityServer) handleRequestBodyError(ctx context.Context, resp http.ResponseWriter, msg string, err error) {
+	if context.Canceled == ctx.Err() {
+		s.writeError(ctx, resp, twirp.NewError(twirp.Canceled, "failed to read request: context canceled"))
+		return
+	}
+	if context.DeadlineExceeded == ctx.Err() {
+		s.writeError(ctx, resp, twirp.NewError(twirp.DeadlineExceeded, "failed to read request: deadline exceeded"))
+		return
+	}
+	s.writeError(ctx, resp, twirp.WrapError(malformedRequestError(msg), err))
+}
+
+// AbilityPathPrefix is a convenience constant that may identify URL paths.
+// Should be used with caution, it only matches routes generated by Twirp Go clients,
+// with the default "/twirp" prefix and default CamelCase service and method names.
+// More info: https://twitchtv.github.io/twirp/docs/routing.html
+const AbilityPathPrefix = "/twirp/authx.rpc.Ability/"
+
+func (s *abilityServer) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
+	ctx := req.Context()
+	ctx = ctxsetters.WithPackageName(ctx, "authx.rpc")
+	ctx = ctxsetters.WithServiceName(ctx, "Ability")
+	ctx = ctxsetters.WithResponseWriter(ctx, resp)
+
+	var err error
+	ctx, err = callRequestReceived(ctx, s.hooks)
+	if err != nil {
+		s.writeError(ctx, resp, err)
+		return
+	}
+
+	if req.Method != "POST" {
+		msg := fmt.Sprintf("unsupported method %q (only POST is allowed)", req.Method)
+		s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path))
+		return
+	}
+
+	// Verify path format: [<prefix>]/<package>.<Service>/<Method>
+	prefix, pkgService, method := parseTwirpPath(req.URL.Path)
+	if pkgService != "authx.rpc.Ability" {
+		msg := fmt.Sprintf("no handler for path %q", req.URL.Path)
+		s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path))
+		return
+	}
+	if prefix != s.pathPrefix {
+		msg := fmt.Sprintf("invalid path prefix %q, expected %q, on path %q", prefix, s.pathPrefix, req.URL.Path)
+		s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path))
+		return
+	}
+
+	switch method {
+	case "Allowed":
+		s.serveAllowed(ctx, resp, req)
+		return
+	default:
+		msg := fmt.Sprintf("no handler for path %q", req.URL.Path)
+		s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path))
+		return
+	}
+}
+
+func (s *abilityServer) serveAllowed(ctx context.Context, resp http.ResponseWriter, req *http.Request) {
+	header := req.Header.Get("Content-Type")
+	i := strings.Index(header, ";")
+	if i == -1 {
+		i = len(header)
+	}
+	switch strings.TrimSpace(strings.ToLower(header[:i])) {
+	case "application/json":
+		s.serveAllowedJSON(ctx, resp, req)
+	case "application/protobuf":
+		s.serveAllowedProtobuf(ctx, resp, req)
+	default:
+		msg := fmt.Sprintf("unexpected Content-Type: %q", req.Header.Get("Content-Type"))
+		twerr := badRouteError(msg, req.Method, req.URL.Path)
+		s.writeError(ctx, resp, twerr)
+	}
+}
+
+func (s *abilityServer) serveAllowedJSON(ctx context.Context, resp http.ResponseWriter, req *http.Request) {
+	var err error
+	ctx = ctxsetters.WithMethodName(ctx, "Allowed")
+	ctx, err = callRequestRouted(ctx, s.hooks)
+	if err != nil {
+		s.writeError(ctx, resp, err)
+		return
+	}
+
+	d := json.NewDecoder(req.Body)
+	rawReqBody := json.RawMessage{}
+	if err := d.Decode(&rawReqBody); err != nil {
+		s.handleRequestBodyError(ctx, resp, "the json request could not be decoded", err)
+		return
+	}
+	reqContent := new(AllowRequest)
+	unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true}
+	if err = unmarshaler.Unmarshal(rawReqBody, reqContent); err != nil {
+		s.handleRequestBodyError(ctx, resp, "the json request could not be decoded", err)
+		return
+	}
+
+	handler := s.Ability.Allowed
+	if s.interceptor != nil {
+		handler = func(ctx context.Context, req *AllowRequest) (*AllowReply, error) {
+			resp, err := s.interceptor(
+				func(ctx context.Context, req interface{}) (interface{}, error) {
+					typedReq, ok := req.(*AllowRequest)
+					if !ok {
+						return nil, twirp.InternalError("failed type assertion req.(*AllowRequest) when calling interceptor")
+					}
+					return s.Ability.Allowed(ctx, typedReq)
+				},
+			)(ctx, req)
+			if resp != nil {
+				typedResp, ok := resp.(*AllowReply)
+				if !ok {
+					return nil, twirp.InternalError("failed type assertion resp.(*AllowReply) when calling interceptor")
+				}
+				return typedResp, err
+			}
+			return nil, err
+		}
+	}
+
+	// Call service method
+	var respContent *AllowReply
+	func() {
+		defer ensurePanicResponses(ctx, resp, s.hooks)
+		respContent, err = handler(ctx, reqContent)
+	}()
+
+	if err != nil {
+		s.writeError(ctx, resp, err)
+		return
+	}
+	if respContent == nil {
+		s.writeError(ctx, resp, twirp.InternalError("received a nil *AllowReply and nil error while calling Allowed. nil responses are not supported"))
+		return
+	}
+
+	ctx = callResponsePrepared(ctx, s.hooks)
+
+	marshaler := &protojson.MarshalOptions{UseProtoNames: !s.jsonCamelCase, EmitUnpopulated: !s.jsonSkipDefaults}
+	respBytes, err := marshaler.Marshal(respContent)
+	if err != nil {
+		s.writeError(ctx, resp, wrapInternal(err, "failed to marshal json response"))
+		return
+	}
+
+	ctx = ctxsetters.WithStatusCode(ctx, http.StatusOK)
+	resp.Header().Set("Content-Type", "application/json")
+	resp.Header().Set("Content-Length", strconv.Itoa(len(respBytes)))
+	resp.WriteHeader(http.StatusOK)
+
+	if n, err := resp.Write(respBytes); err != nil {
+		msg := fmt.Sprintf("failed to write response, %d of %d bytes written: %s", n, len(respBytes), err.Error())
+		twerr := twirp.NewError(twirp.Unknown, msg)
+		ctx = callError(ctx, s.hooks, twerr)
+	}
+	callResponseSent(ctx, s.hooks)
+}
+
+func (s *abilityServer) serveAllowedProtobuf(ctx context.Context, resp http.ResponseWriter, req *http.Request) {
+	var err error
+	ctx = ctxsetters.WithMethodName(ctx, "Allowed")
+	ctx, err = callRequestRouted(ctx, s.hooks)
+	if err != nil {
+		s.writeError(ctx, resp, err)
+		return
+	}
+
+	buf, err := io.ReadAll(req.Body)
+	if err != nil {
+		s.handleRequestBodyError(ctx, resp, "failed to read request body", err)
+		return
+	}
+	reqContent := new(AllowRequest)
+	if err = proto.Unmarshal(buf, reqContent); err != nil {
+		s.writeError(ctx, resp, malformedRequestError("the protobuf request could not be decoded"))
+		return
+	}
+
+	handler := s.Ability.Allowed
+	if s.interceptor != nil {
+		handler = func(ctx context.Context, req *AllowRequest) (*AllowReply, error) {
+			resp, err := s.interceptor(
+				func(ctx context.Context, req interface{}) (interface{}, error) {
+					typedReq, ok := req.(*AllowRequest)
+					if !ok {
+						return nil, twirp.InternalError("failed type assertion req.(*AllowRequest) when calling interceptor")
+					}
+					return s.Ability.Allowed(ctx, typedReq)
+				},
+			)(ctx, req)
+			if resp != nil {
+				typedResp, ok := resp.(*AllowReply)
+				if !ok {
+					return nil, twirp.InternalError("failed type assertion resp.(*AllowReply) when calling interceptor")
+				}
+				return typedResp, err
+			}
+			return nil, err
+		}
+	}
+
+	// Call service method
+	var respContent *AllowReply
+	func() {
+		defer ensurePanicResponses(ctx, resp, s.hooks)
+		respContent, err = handler(ctx, reqContent)
+	}()
+
+	if err != nil {
+		s.writeError(ctx, resp, err)
+		return
+	}
+	if respContent == nil {
+		s.writeError(ctx, resp, twirp.InternalError("received a nil *AllowReply and nil error while calling Allowed. nil responses are not supported"))
+		return
+	}
+
+	ctx = callResponsePrepared(ctx, s.hooks)
+
+	respBytes, err := proto.Marshal(respContent)
+	if err != nil {
+		s.writeError(ctx, resp, wrapInternal(err, "failed to marshal proto response"))
+		return
+	}
+
+	ctx = ctxsetters.WithStatusCode(ctx, http.StatusOK)
+	resp.Header().Set("Content-Type", "application/protobuf")
+	resp.Header().Set("Content-Length", strconv.Itoa(len(respBytes)))
+	resp.WriteHeader(http.StatusOK)
+	if n, err := resp.Write(respBytes); err != nil {
+		msg := fmt.Sprintf("failed to write response, %d of %d bytes written: %s", n, len(respBytes), err.Error())
+		twerr := twirp.NewError(twirp.Unknown, msg)
+		ctx = callError(ctx, s.hooks, twerr)
+	}
+	callResponseSent(ctx, s.hooks)
+}
+
+func (s *abilityServer) ServiceDescriptor() ([]byte, int) {
+	return twirpFileDescriptor0, 0
+}
+
+func (s *abilityServer) ProtocGenTwirpVersion() string {
+	return "v8.1.3"
+}
+
+// PathPrefix returns the base service path, in the form: "/<prefix>/<package>.<Service>/"
+// that is everything in a Twirp route except for the <Method>. This can be used for routing,
+// for example to identify the requests that are targeted to this service in a mux.
+func (s *abilityServer) PathPrefix() string {
+	return baseServicePath(s.pathPrefix, "authx.rpc", "Ability")
+}
+
+// =====
+// Utils
+// =====
+
+// HTTPClient is the interface used by generated clients to send HTTP requests.
+// It is fulfilled by *(net/http).Client, which is sufficient for most users.
+// Users can provide their own implementation for special retry policies.
+//
+// HTTPClient implementations should not follow redirects. Redirects are
+// automatically disabled if *(net/http).Client is passed to client
+// constructors. See the withoutRedirects function in this file for more
+// details.
+type HTTPClient interface {
+	Do(req *http.Request) (*http.Response, error)
+}
+
+// TwirpServer is the interface generated server structs will support: they're
+// HTTP handlers with additional methods for accessing metadata about the
+// service. Those accessors are a low-level API for building reflection tools.
+// Most people can think of TwirpServers as just http.Handlers.
+type TwirpServer interface {
+	http.Handler
+
+	// ServiceDescriptor returns gzipped bytes describing the .proto file that
+	// this service was generated from. Once unzipped, the bytes can be
+	// unmarshalled as a
+	// google.golang.org/protobuf/types/descriptorpb.FileDescriptorProto.
+	//
+	// The returned integer is the index of this particular service within that
+	// FileDescriptorProto's 'Service' slice of ServiceDescriptorProtos. This is a
+	// low-level field, expected to be used for reflection.
+	ServiceDescriptor() ([]byte, int)
+
+	// ProtocGenTwirpVersion is the semantic version string of the version of
+	// twirp used to generate this file.
+	ProtocGenTwirpVersion() string
+
+	// PathPrefix returns the HTTP URL path prefix for all methods handled by this
+	// service. This can be used with an HTTP mux to route Twirp requests.
+	// The path prefix is in the form: "/<prefix>/<package>.<Service>/"
+	// that is, everything in a Twirp route except for the <Method> at the end.
+	PathPrefix() string
+}
+
+func newServerOpts(opts []interface{}) *twirp.ServerOptions {
+	serverOpts := &twirp.ServerOptions{}
+	for _, opt := range opts {
+		switch o := opt.(type) {
+		case twirp.ServerOption:
+			o(serverOpts)
+		case *twirp.ServerHooks: // backwards compatibility, allow to specify hooks as an argument
+			twirp.WithServerHooks(o)(serverOpts)
+		case nil: // backwards compatibility, allow nil value for the argument
+			continue
+		default:
+			panic(fmt.Sprintf("Invalid option type %T, please use a twirp.ServerOption", o))
+		}
+	}
+	return serverOpts
+}
+
+// WriteError writes an HTTP response with a valid Twirp error format (code, msg, meta).
+// Useful outside of the Twirp server (e.g. http middleware), but does not trigger hooks.
+// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err)
+func WriteError(resp http.ResponseWriter, err error) {
+	writeError(context.Background(), resp, err, nil)
+}
+
+// writeError writes Twirp errors in the response and triggers hooks.
+func writeError(ctx context.Context, resp http.ResponseWriter, err error, hooks *twirp.ServerHooks) {
+	// Convert to a twirp.Error. Non-twirp errors are converted to internal errors.
+	var twerr twirp.Error
+	if !errors.As(err, &twerr) {
+		twerr = twirp.InternalErrorWith(err)
+	}
+
+	statusCode := twirp.ServerHTTPStatusFromErrorCode(twerr.Code())
+	ctx = ctxsetters.WithStatusCode(ctx, statusCode)
+	ctx = callError(ctx, hooks, twerr)
+
+	respBody := marshalErrorToJSON(twerr)
+
+	resp.Header().Set("Content-Type", "application/json") // Error responses are always JSON
+	resp.Header().Set("Content-Length", strconv.Itoa(len(respBody)))
+	resp.WriteHeader(statusCode) // set HTTP status code and send response
+
+	_, writeErr := resp.Write(respBody)
+	if writeErr != nil {
+		// We have three options here. We could log the error, call the Error
+		// hook, or just silently ignore the error.
+		//
+		// Logging is unacceptable because we don't have a user-controlled
+		// logger; writing out to stderr without permission is too rude.
+		//
+		// Calling the Error hook would confuse users: it would mean the Error
+		// hook got called twice for one request, which is likely to lead to
+		// duplicated log messages and metrics, no matter how well we document
+		// the behavior.
+		//
+		// Silently ignoring the error is our least-bad option. It's highly
+		// likely that the connection is broken and the original 'err' says
+		// so anyway.
+		_ = writeErr
+	}
+
+	callResponseSent(ctx, hooks)
+}
+
+// sanitizeBaseURL parses the the baseURL, and adds the "http" scheme if needed.
+// If the URL is unparsable, the baseURL is returned unchanged.
+func sanitizeBaseURL(baseURL string) string {
+	u, err := url.Parse(baseURL)
+	if err != nil {
+		return baseURL // invalid URL will fail later when making requests
+	}
+	if u.Scheme == "" {
+		u.Scheme = "http"
+	}
+	return u.String()
+}
+
+// baseServicePath composes the path prefix for the service (without <Method>).
+// e.g.: baseServicePath("/twirp", "my.pkg", "MyService")
+//
+//	returns => "/twirp/my.pkg.MyService/"
+//
+// e.g.: baseServicePath("", "", "MyService")
+//
+//	returns => "/MyService/"
+func baseServicePath(prefix, pkg, service string) string {
+	fullServiceName := service
+	if pkg != "" {
+		fullServiceName = pkg + "." + service
+	}
+	return path.Join("/", prefix, fullServiceName) + "/"
+}
+
+// parseTwirpPath extracts path components form a valid Twirp route.
+// Expected format: "[<prefix>]/<package>.<Service>/<Method>"
+// e.g.: prefix, pkgService, method := parseTwirpPath("/twirp/pkg.Svc/MakeHat")
+func parseTwirpPath(path string) (string, string, string) {
+	parts := strings.Split(path, "/")
+	if len(parts) < 2 {
+		return "", "", ""
+	}
+	method := parts[len(parts)-1]
+	pkgService := parts[len(parts)-2]
+	prefix := strings.Join(parts[0:len(parts)-2], "/")
+	return prefix, pkgService, method
+}
+
+// getCustomHTTPReqHeaders retrieves a copy of any headers that are set in
+// a context through the twirp.WithHTTPRequestHeaders function.
+// If there are no headers set, or if they have the wrong type, nil is returned.
+func getCustomHTTPReqHeaders(ctx context.Context) http.Header {
+	header, ok := twirp.HTTPRequestHeaders(ctx)
+	if !ok || header == nil {
+		return nil
+	}
+	copied := make(http.Header)
+	for k, vv := range header {
+		if vv == nil {
+			copied[k] = nil
+			continue
+		}
+		copied[k] = make([]string, len(vv))
+		copy(copied[k], vv)
+	}
+	return copied
+}
+
+// newRequest makes an http.Request from a client, adding common headers.
+func newRequest(ctx context.Context, url string, reqBody io.Reader, contentType string) (*http.Request, error) {
+	req, err := http.NewRequest("POST", url, reqBody)
+	if err != nil {
+		return nil, err
+	}
+	req = req.WithContext(ctx)
+	if customHeader := getCustomHTTPReqHeaders(ctx); customHeader != nil {
+		req.Header = customHeader
+	}
+	req.Header.Set("Accept", contentType)
+	req.Header.Set("Content-Type", contentType)
+	req.Header.Set("Twirp-Version", "v8.1.3")
+	return req, nil
+}
+
+// JSON serialization for errors
+type twerrJSON struct {
+	Code string            `json:"code"`
+	Msg  string            `json:"msg"`
+	Meta map[string]string `json:"meta,omitempty"`
+}
+
+// marshalErrorToJSON returns JSON from a twirp.Error, that can be used as HTTP error response body.
+// If serialization fails, it will use a descriptive Internal error instead.
+func marshalErrorToJSON(twerr twirp.Error) []byte {
+	// make sure that msg is not too large
+	msg := twerr.Msg()
+	if len(msg) > 1e6 {
+		msg = msg[:1e6]
+	}
+
+	tj := twerrJSON{
+		Code: string(twerr.Code()),
+		Msg:  msg,
+		Meta: twerr.MetaMap(),
+	}
+
+	buf, err := json.Marshal(&tj)
+	if err != nil {
+		buf = []byte("{\"type\": \"" + twirp.Internal + "\", \"msg\": \"There was an error but it could not be serialized into JSON\"}") // fallback
+	}
+
+	return buf
+}
+
+// errorFromResponse builds a twirp.Error from a non-200 HTTP response.
+// If the response has a valid serialized Twirp error, then it's returned.
+// If not, the response status code is used to generate a similar twirp
+// error. See twirpErrorFromIntermediary for more info on intermediary errors.
+func errorFromResponse(resp *http.Response) twirp.Error {
+	statusCode := resp.StatusCode
+	statusText := http.StatusText(statusCode)
+
+	if isHTTPRedirect(statusCode) {
+		// Unexpected redirect: it must be an error from an intermediary.
+		// Twirp clients don't follow redirects automatically, Twirp only handles
+		// POST requests, redirects should only happen on GET and HEAD requests.
+		location := resp.Header.Get("Location")
+		msg := fmt.Sprintf("unexpected HTTP status code %d %q received, Location=%q", statusCode, statusText, location)
+		return twirpErrorFromIntermediary(statusCode, msg, location)
+	}
+
+	respBodyBytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return wrapInternal(err, "failed to read server error response body")
+	}
+
+	var tj twerrJSON
+	dec := json.NewDecoder(bytes.NewReader(respBodyBytes))
+	dec.DisallowUnknownFields()
+	if err := dec.Decode(&tj); err != nil || tj.Code == "" {
+		// Invalid JSON response; it must be an error from an intermediary.
+		msg := fmt.Sprintf("Error from intermediary with HTTP status code %d %q", statusCode, statusText)
+		return twirpErrorFromIntermediary(statusCode, msg, string(respBodyBytes))
+	}
+
+	errorCode := twirp.ErrorCode(tj.Code)
+	if !twirp.IsValidErrorCode(errorCode) {
+		msg := "invalid type returned from server error response: " + tj.Code
+		return twirp.InternalError(msg).WithMeta("body", string(respBodyBytes))
+	}
+
+	twerr := twirp.NewError(errorCode, tj.Msg)
+	for k, v := range tj.Meta {
+		twerr = twerr.WithMeta(k, v)
+	}
+	return twerr
+}
+
+// twirpErrorFromIntermediary maps HTTP errors from non-twirp sources to twirp errors.
+// The mapping is similar to gRPC: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md.
+// Returned twirp Errors have some additional metadata for inspection.
+func twirpErrorFromIntermediary(status int, msg string, bodyOrLocation string) twirp.Error {
+	var code twirp.ErrorCode
+	if isHTTPRedirect(status) { // 3xx
+		code = twirp.Internal
+	} else {
+		switch status {
+		case 400: // Bad Request
+			code = twirp.Internal
+		case 401: // Unauthorized
+			code = twirp.Unauthenticated
+		case 403: // Forbidden
+			code = twirp.PermissionDenied
+		case 404: // Not Found
+			code = twirp.BadRoute
+		case 429: // Too Many Requests
+			code = twirp.ResourceExhausted
+		case 502, 503, 504: // Bad Gateway, Service Unavailable, Gateway Timeout
+			code = twirp.Unavailable
+		default: // All other codes
+			code = twirp.Unknown
+		}
+	}
+
+	twerr := twirp.NewError(code, msg)
+	twerr = twerr.WithMeta("http_error_from_intermediary", "true") // to easily know if this error was from intermediary
+	twerr = twerr.WithMeta("status_code", strconv.Itoa(status))
+	if isHTTPRedirect(status) {
+		twerr = twerr.WithMeta("location", bodyOrLocation)
+	} else {
+		twerr = twerr.WithMeta("body", bodyOrLocation)
+	}
+	return twerr
+}
+
+func isHTTPRedirect(status int) bool {
+	return status >= 300 && status <= 399
+}
+
+// wrapInternal wraps an error with a prefix as an Internal error.
+// The original error cause is accessible by github.com/pkg/errors.Cause.
+func wrapInternal(err error, prefix string) twirp.Error {
+	return twirp.InternalErrorWith(&wrappedError{prefix: prefix, cause: err})
+}
+
+type wrappedError struct {
+	prefix string
+	cause  error
+}
+
+func (e *wrappedError) Error() string { return e.prefix + ": " + e.cause.Error() }
+func (e *wrappedError) Unwrap() error { return e.cause } // for go1.13 + errors.Is/As
+func (e *wrappedError) Cause() error  { return e.cause } // for github.com/pkg/errors
+
+// ensurePanicResponses makes sure that rpc methods causing a panic still result in a Twirp Internal
+// error response (status 500), and error hooks are properly called with the panic wrapped as an error.
+// The panic is re-raised so it can be handled normally with middleware.
+func ensurePanicResponses(ctx context.Context, resp http.ResponseWriter, hooks *twirp.ServerHooks) {
+	if r := recover(); r != nil {
+		// Wrap the panic as an error so it can be passed to error hooks.
+		// The original error is accessible from error hooks, but not visible in the response.
+		err := errFromPanic(r)
+		twerr := &internalWithCause{msg: "Internal service panic", cause: err}
+		// Actually write the error
+		writeError(ctx, resp, twerr, hooks)
+		// If possible, flush the error to the wire.
+		f, ok := resp.(http.Flusher)
+		if ok {
+			f.Flush()
+		}
+
+		panic(r)
+	}
+}
+
+// errFromPanic returns the typed error if the recovered panic is an error, otherwise formats as error.
+func errFromPanic(p interface{}) error {
+	if err, ok := p.(error); ok {
+		return err
+	}
+	return fmt.Errorf("panic: %v", p)
+}
+
+// internalWithCause is a Twirp Internal error wrapping an original error cause,
+// but the original error message is not exposed on Msg(). The original error
+// can be checked with go1.13+ errors.Is/As, and also by (github.com/pkg/errors).Unwrap
+type internalWithCause struct {
+	msg   string
+	cause error
+}
+
+func (e *internalWithCause) Unwrap() error                               { return e.cause } // for go1.13 + errors.Is/As
+func (e *internalWithCause) Cause() error                                { return e.cause } // for github.com/pkg/errors
+func (e *internalWithCause) Error() string                               { return e.msg + ": " + e.cause.Error() }
+func (e *internalWithCause) Code() twirp.ErrorCode                       { return twirp.Internal }
+func (e *internalWithCause) Msg() string                                 { return e.msg }
+func (e *internalWithCause) Meta(key string) string                      { return "" }
+func (e *internalWithCause) MetaMap() map[string]string                  { return nil }
+func (e *internalWithCause) WithMeta(key string, val string) twirp.Error { return e }
+
+// malformedRequestError is used when the twirp server cannot unmarshal a request
+func malformedRequestError(msg string) twirp.Error {
+	return twirp.NewError(twirp.Malformed, msg)
+}
+
+// badRouteError is used when the twirp server cannot route a request
+func badRouteError(msg string, method, url string) twirp.Error {
+	err := twirp.NewError(twirp.BadRoute, msg)
+	err = err.WithMeta("twirp_invalid_route", method+" "+url)
+	return err
+}
+
+// withoutRedirects makes sure that the POST request can not be redirected.
+// The standard library will, by default, redirect requests (including POSTs) if it gets a 302 or
+// 303 response, and also 301s in go1.8. It redirects by making a second request, changing the
+// method to GET and removing the body. This produces very confusing error messages, so instead we
+// set a redirect policy that always errors. This stops Go from executing the redirect.
+//
+// We have to be a little careful in case the user-provided http.Client has its own CheckRedirect
+// policy - if so, we'll run through that policy first.
+//
+// Because this requires modifying the http.Client, we make a new copy of the client and return it.
+func withoutRedirects(in *http.Client) *http.Client {
+	copy := *in
+	copy.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+		if in.CheckRedirect != nil {
+			// Run the input's redirect if it exists, in case it has side effects, but ignore any error it
+			// returns, since we want to use ErrUseLastResponse.
+			err := in.CheckRedirect(req, via)
+			_ = err // Silly, but this makes sure generated code passes errcheck -blank, which some people use.
+		}
+		return http.ErrUseLastResponse
+	}
+	return &copy
+}
+
+// doProtobufRequest makes a Protobuf request to the remote Twirp service.
+func doProtobufRequest(ctx context.Context, client HTTPClient, hooks *twirp.ClientHooks, url string, in, out proto.Message) (_ context.Context, err error) {
+	reqBodyBytes, err := proto.Marshal(in)
+	if err != nil {
+		return ctx, wrapInternal(err, "failed to marshal proto request")
+	}
+	reqBody := bytes.NewBuffer(reqBodyBytes)
+	if err = ctx.Err(); err != nil {
+		return ctx, wrapInternal(err, "aborted because context was done")
+	}
+
+	req, err := newRequest(ctx, url, reqBody, "application/protobuf")
+	if err != nil {
+		return ctx, wrapInternal(err, "could not build request")
+	}
+	ctx, err = callClientRequestPrepared(ctx, hooks, req)
+	if err != nil {
+		return ctx, err
+	}
+
+	req = req.WithContext(ctx)
+	resp, err := client.Do(req)
+	if err != nil {
+		return ctx, wrapInternal(err, "failed to do request")
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	if err = ctx.Err(); err != nil {
+		return ctx, wrapInternal(err, "aborted because context was done")
+	}
+
+	if resp.StatusCode != 200 {
+		return ctx, errorFromResponse(resp)
+	}
+
+	respBodyBytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return ctx, wrapInternal(err, "failed to read response body")
+	}
+	if err = ctx.Err(); err != nil {
+		return ctx, wrapInternal(err, "aborted because context was done")
+	}
+
+	if err = proto.Unmarshal(respBodyBytes, out); err != nil {
+		return ctx, wrapInternal(err, "failed to unmarshal proto response")
+	}
+	return ctx, nil
+}
+
+// doJSONRequest makes a JSON request to the remote Twirp service.
+func doJSONRequest(ctx context.Context, client HTTPClient, hooks *twirp.ClientHooks, url string, in, out proto.Message) (_ context.Context, err error) {
+	marshaler := &protojson.MarshalOptions{UseProtoNames: true}
+	reqBytes, err := marshaler.Marshal(in)
+	if err != nil {
+		return ctx, wrapInternal(err, "failed to marshal json request")
+	}
+	if err = ctx.Err(); err != nil {
+		return ctx, wrapInternal(err, "aborted because context was done")
+	}
+
+	req, err := newRequest(ctx, url, bytes.NewReader(reqBytes), "application/json")
+	if err != nil {
+		return ctx, wrapInternal(err, "could not build request")
+	}
+	ctx, err = callClientRequestPrepared(ctx, hooks, req)
+	if err != nil {
+		return ctx, err
+	}
+
+	req = req.WithContext(ctx)
+	resp, err := client.Do(req)
+	if err != nil {
+		return ctx, wrapInternal(err, "failed to do request")
+	}
+
+	defer func() {
+		cerr := resp.Body.Close()
+		if err == nil && cerr != nil {
+			err = wrapInternal(cerr, "failed to close response body")
+		}
+	}()
+
+	if err = ctx.Err(); err != nil {
+		return ctx, wrapInternal(err, "aborted because context was done")
+	}
+
+	if resp.StatusCode != 200 {
+		return ctx, errorFromResponse(resp)
+	}
+
+	d := json.NewDecoder(resp.Body)
+	rawRespBody := json.RawMessage{}
+	if err := d.Decode(&rawRespBody); err != nil {
+		return ctx, wrapInternal(err, "failed to unmarshal json response")
+	}
+	unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true}
+	if err = unmarshaler.Unmarshal(rawRespBody, out); err != nil {
+		return ctx, wrapInternal(err, "failed to unmarshal json response")
+	}
+	if err = ctx.Err(); err != nil {
+		return ctx, wrapInternal(err, "aborted because context was done")
+	}
+	return ctx, nil
+}
+
+// Call twirp.ServerHooks.RequestReceived if the hook is available
+func callRequestReceived(ctx context.Context, h *twirp.ServerHooks) (context.Context, error) {
+	if h == nil || h.RequestReceived == nil {
+		return ctx, nil
+	}
+	return h.RequestReceived(ctx)
+}
+
+// Call twirp.ServerHooks.RequestRouted if the hook is available
+func callRequestRouted(ctx context.Context, h *twirp.ServerHooks) (context.Context, error) {
+	if h == nil || h.RequestRouted == nil {
+		return ctx, nil
+	}
+	return h.RequestRouted(ctx)
+}
+
+// Call twirp.ServerHooks.ResponsePrepared if the hook is available
+func callResponsePrepared(ctx context.Context, h *twirp.ServerHooks) context.Context {
+	if h == nil || h.ResponsePrepared == nil {
+		return ctx
+	}
+	return h.ResponsePrepared(ctx)
+}
+
+// Call twirp.ServerHooks.ResponseSent if the hook is available
+func callResponseSent(ctx context.Context, h *twirp.ServerHooks) {
+	if h == nil || h.ResponseSent == nil {
+		return
+	}
+	h.ResponseSent(ctx)
+}
+
+// Call twirp.ServerHooks.Error if the hook is available
+func callError(ctx context.Context, h *twirp.ServerHooks, err twirp.Error) context.Context {
+	if h == nil || h.Error == nil {
+		return ctx
+	}
+	return h.Error(ctx, err)
+}
+
+func callClientResponseReceived(ctx context.Context, h *twirp.ClientHooks) {
+	if h == nil || h.ResponseReceived == nil {
+		return
+	}
+	h.ResponseReceived(ctx)
+}
+
+func callClientRequestPrepared(ctx context.Context, h *twirp.ClientHooks, req *http.Request) (context.Context, error) {
+	if h == nil || h.RequestPrepared == nil {
+		return ctx, nil
+	}
+	return h.RequestPrepared(ctx, req)
+}
+
+func callClientError(ctx context.Context, h *twirp.ClientHooks, err twirp.Error) {
+	if h == nil || h.Error == nil {
+		return
+	}
+	h.Error(ctx, err)
+}
+
+var twirpFileDescriptor0 = []byte{
+	// 216 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x64, 0x90, 0xbd, 0x4e, 0xc3, 0x30,
+	0x14, 0x46, 0x29, 0x48, 0x4d, 0x7b, 0x05, 0x8b, 0x25, 0xc0, 0xea, 0xc0, 0x4f, 0xc4, 0xc0, 0x64,
+	0x4b, 0x30, 0x32, 0x95, 0x81, 0x07, 0xc8, 0xc8, 0x66, 0xbb, 0x57, 0xad, 0x89, 0x83, 0x2f, 0xfe,
+	0x11, 0xcd, 0xdb, 0x23, 0x39, 0x21, 0x8a, 0xd4, 0xf1, 0xf8, 0xc8, 0xfa, 0x8e, 0x2e, 0x5c, 0x29,
+	0x6d, 0x9d, 0x4d, 0xbd, 0xa0, 0xe0, 0x93, 0x67, 0x6b, 0x95, 0xd3, 0xe1, 0x28, 0x02, 0x99, 0x7a,
+	0x07, 0x97, 0x5b, 0xe7, 0xfc, 0x6f, 0x83, 0x3f, 0x19, 0x63, 0x62, 0x1c, 0xaa, 0x98, 0xf5, 0x17,
+	0x9a, 0xc4, 0x17, 0x0f, 0x8b, 0xe7, 0x75, 0xf3, 0x8f, 0xec, 0x0e, 0x80, 0x30, 0x74, 0x36, 0x46,
+	0xeb, 0xbf, 0xf9, 0x79, 0x91, 0xb3, 0x17, 0xb6, 0x81, 0x55, 0xc0, 0xe8, 0x73, 0x30, 0xc8, 0x2f,
+	0x8a, 0x9d, 0xb8, 0x7e, 0x02, 0x18, 0x57, 0xc8, 0xf5, 0xec, 0x06, 0x96, 0x01, 0x63, 0x76, 0xc3,
+	0xc4, 0xaa, 0x19, 0xe9, 0xe5, 0x03, 0xaa, 0xed, 0xd0, 0xc9, 0xde, 0xa0, 0x2a, 0x1f, 0x70, 0xc7,
+	0x6e, 0xc5, 0x54, 0x2b, 0xe6, 0xa9, 0x9b, 0xeb, 0x53, 0x41, 0xae, 0xaf, 0xcf, 0xde, 0x1f, 0x3f,
+	0xef, 0xf7, 0x36, 0x39, 0xa5, 0x85, 0xf1, 0x9d, 0xec, 0x7c, 0x7b, 0x50, 0x47, 0x19, 0xc9, 0xb6,
+	0x28, 0xa9, 0xdd, 0xcb, 0x40, 0x46, 0x2f, 0xcb, 0x21, 0x5e, 0xff, 0x02, 0x00, 0x00, 0xff, 0xff,
+	0xe2, 0x96, 0x42, 0xb1, 0x19, 0x01, 0x00, 0x00,
+}
go.mod
@@ -206,6 +206,7 @@ require (
 	github.com/spf13/viper v1.19.0 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
 	github.com/thales-e-security/pool v0.0.2 // indirect
+	github.com/twitchtv/twirp v8.1.3+incompatible // indirect
 	github.com/urfave/cli v1.22.16 // indirect
 	github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
@@ -257,6 +258,7 @@ tool (
 	github.com/playwright-community/playwright-go/cmd/playwright
 	github.com/smallstep/certificates/cmd/step-ca
 	github.com/smallstep/cli/cmd/step
+	github.com/twitchtv/twirp/protoc-gen-twirp
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc
 	google.golang.org/protobuf/cmd/protoc-gen-go
 )
go.sum
@@ -555,6 +555,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
 github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg=
 github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU=
+github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
+github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
 github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
 github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=