util/httpio: prototype design for handling I/O in HTTP
Signed-off-by: Joe Tsai <joetsai@digital-static.net>
This commit is contained in:
parent
b89c113365
commit
2e20bd2ffe
|
@ -0,0 +1,81 @@
|
|||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package httphdr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: Must authorization parameters be valid UTF-8?
|
||||
|
||||
// AuthScheme is an authorization scheme per RFC 7235.
|
||||
// Per section 2.1, the "Authorization" header is formatted as:
|
||||
//
|
||||
// Authorization: <auth-scheme> <auth-parameter>
|
||||
//
|
||||
// A scheme implementation must self-report the <auth-scheme> name and
|
||||
// provide the ability to marshal and unmarshal the <auth-parameter>.
|
||||
//
|
||||
// For concrete implementations, see [Basic] and [Bearer].
|
||||
type AuthScheme interface {
|
||||
// AuthScheme is the authorization scheme name.
|
||||
// It must be valid according to RFC 7230, section 3.2.6.
|
||||
AuthScheme() string
|
||||
|
||||
// MarshalAuth marshals the authorization parameter for the scheme.
|
||||
MarshalAuth() (string, error)
|
||||
|
||||
// UnmarshalAuth unmarshals the authorization parameter for the scheme.
|
||||
UnmarshalAuth(string) error
|
||||
}
|
||||
|
||||
// BasicAuth is the Basic authorization scheme as defined in RFC 2617.
|
||||
type BasicAuth struct {
|
||||
Username string // must not contain ':' per section 2
|
||||
Password string
|
||||
}
|
||||
|
||||
func (BasicAuth) AuthScheme() string { return "Basic" }
|
||||
|
||||
func (a BasicAuth) MarshalAuth() (string, error) {
|
||||
if strings.IndexByte(a.Username, ':') >= 0 {
|
||||
return "", fmt.Errorf("invalid username: contains a colon")
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(a.Username + ":" + a.Password)), nil
|
||||
}
|
||||
|
||||
func (a *BasicAuth) UnmarshalAuth(s string) error {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid basic authorization: %w", err)
|
||||
}
|
||||
i := bytes.IndexByte(b, ':')
|
||||
if i < 0 {
|
||||
return fmt.Errorf("invalid basic authorization: missing a colon")
|
||||
}
|
||||
a.Username = string(b[:i])
|
||||
a.Password = string(b[i+len(":"):])
|
||||
return nil
|
||||
}
|
||||
|
||||
// BearerAuth is the Bearer Token authorization scheme as defined in RFC 6750.
|
||||
type BearerAuth struct {
|
||||
Token string // usually a base64-encoded string per section 2.1
|
||||
}
|
||||
|
||||
func (BearerAuth) AuthScheme() string { return "Bearer" }
|
||||
|
||||
func (a BearerAuth) MarshalAuth() (string, error) {
|
||||
// TODO: Verify that token is valid base64?
|
||||
return a.Token, nil
|
||||
}
|
||||
|
||||
func (a *BearerAuth) UnmarshalAuth(s string) error {
|
||||
// TODO: Verify that token is valid base64?
|
||||
a.Token = s
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package httpio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/util/httphdr"
|
||||
)
|
||||
|
||||
type headerKey struct{}
|
||||
|
||||
// WithHeader specifies the HTTP header to use with a client request.
|
||||
// It only affects [Do], [Get], [Post], [Put], and [Delete].
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// ctx = httpio.WithHeader(ctx, http.Header{"DD-API-KEY": ...})
|
||||
func WithHeader(ctx context.Context, hdr http.Header) context.Context {
|
||||
return context.WithValue(ctx, headerKey{}, hdr)
|
||||
}
|
||||
|
||||
type authKey struct{}
|
||||
|
||||
// WithAuth specifies an "Authorization" header to use with a client request.
|
||||
// This takes precedence over any "Authorization" header that may be present
|
||||
// in the [http.Header] provided to [WithHeader].
|
||||
// It only affects [Do], [Get], [Post], [Put], and [Delete].
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// ctx = httpio.WithAuth(ctx, httphdr.BasicAuth{
|
||||
// Username: "admin",
|
||||
// Password: "password",
|
||||
// })
|
||||
func WithAuth(ctx context.Context, auth httphdr.AuthScheme) context.Context {
|
||||
return context.WithValue(ctx, authKey{}, auth)
|
||||
}
|
||||
|
||||
// TODO: Add extraction functionality to retrieve the original
|
||||
// *http.Request and http.ResponseWriter for use with [Handler].
|
|
@ -0,0 +1,93 @@
|
|||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package httpio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Endpoint annotates an HTTP method and path with input and output types.
|
||||
//
|
||||
// The intent is to declare this in a shared package between client and server
|
||||
// implementations as a means to structurally describe how they interact.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// package tsapi
|
||||
//
|
||||
// const BaseURL = "https://api.tailscale.com/api/v2/"
|
||||
//
|
||||
// var (
|
||||
// GetDevice = httpio.Endpoint[GetDeviceRequest, GetDeviceResponse]{Method: "GET", Pattern: "/device/{DeviceID}"}.WithHost(BaseURL)
|
||||
// DeleteDevice = httpio.Endpoint[DeleteDeviceRequest, DeleteDeviceResponse]{Method: "DELETE", Pattern: "/device/{DeviceID}"}.WithHost(BaseURL)
|
||||
// )
|
||||
//
|
||||
// type GetDeviceRequest struct {
|
||||
// ID int `urlpath:"DeviceID"`
|
||||
// Fields []string `urlquery:"fields"`
|
||||
// ...
|
||||
// }
|
||||
// type GetDeviceResponse struct {
|
||||
// ID int `json:"id"`
|
||||
// Addresses []netip.Addr `json:"addresses"`
|
||||
// ...
|
||||
// }
|
||||
// type DeleteDeviceRequest struct { ... }
|
||||
// type DeleteDeviceResponse struct { ... }
|
||||
//
|
||||
// Example usage by client code:
|
||||
//
|
||||
// ctx = httpio.WithAuth(ctx, ...)
|
||||
// device, err := tsapi.GetDevice.Do(ctx, {ID: 1234})
|
||||
//
|
||||
// Example usage by server code:
|
||||
//
|
||||
// mux := http.NewServeMux()
|
||||
// mux.Handle(tsapi.GetDevice.String(), checkAuth(httpio.Handler(getDevice)))
|
||||
// mux.Handle(tsapi.DeleteDevice.String(), checkAuth(httpio.Handler(deleteDevice)))
|
||||
//
|
||||
// func checkAuth(http.Handler) http.Handler { ... }
|
||||
// func getDevice(ctx context.Context, in GetDeviceRequest) (out GetDeviceResponse, err error) { ... }
|
||||
// func deleteDevice(ctx context.Context, in DeleteDeviceRequest) (out DeleteDeviceResponse, err error) { ... }
|
||||
type Endpoint[In Request, Out Response] struct {
|
||||
// Method is a valid HTTP method (e.g., "GET").
|
||||
Method string
|
||||
// Pattern must be a pattern that complies with [mux.ServeMux.Handle] and
|
||||
// not be preceded by a method or host (e.g., "/api/v2/device/{DeviceID}").
|
||||
// It must start with a leading "/".
|
||||
Pattern string
|
||||
}
|
||||
|
||||
// String returns a combination of the method and pattern,
|
||||
// which is a valid pattern for [mux.ServeMux.Handle].
|
||||
func (e Endpoint[In, Out]) String() string { return e.Method + " " + e.Pattern }
|
||||
|
||||
// Do performs an HTTP call to the target endpoint at the specified host.
|
||||
// The hostPrefix must be a URL prefix containing the scheme and host,
|
||||
// but not contain any URL query parameters (e.g., "https://api.tailscale.com/api/v2/").
|
||||
func (e Endpoint[In, Out]) Do(ctx context.Context, hostPrefix string, in In, opts ...Option) (out Out, err error) {
|
||||
return Do[In, Out](ctx, e.Method, strings.TrimRight(hostPrefix, "/")+e.Pattern, in, opts...)
|
||||
}
|
||||
|
||||
// TODO: Should hostPrefix be a *url.URL?
|
||||
|
||||
// WithHost constructs a [HostedEndpoint],
|
||||
// which is an HTTP endpoint hosted at a particular URL prefix.
|
||||
func (e Endpoint[In, Out]) WithHost(hostPrefix string) HostedEndpoint[In, Out] {
|
||||
return HostedEndpoint[In, Out]{Prefix: hostPrefix, Endpoint: e}
|
||||
}
|
||||
|
||||
// HostedEndpoint is an HTTP endpoint hosted under a particular URL prefix.
|
||||
type HostedEndpoint[In Request, Out Response] struct {
|
||||
// Prefix is a URL prefix containing the scheme, host, and
|
||||
// an optional path prefix (e.g., "https://api.tailscale.com/api/v2/").
|
||||
Prefix string
|
||||
Endpoint[In, Out]
|
||||
}
|
||||
|
||||
// Do performs an HTTP call to the target hosted endpoint.
|
||||
func (e HostedEndpoint[In, Out]) Do(ctx context.Context, in In, opts ...Option) (out Out, err error) {
|
||||
return Do[In, Out](ctx, e.Method, strings.TrimSuffix(e.Prefix, "/")+e.Pattern, in, opts...)
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package httpio assists in handling HTTP operations on structured
|
||||
// input and output types. It automatically handles encoding of data
|
||||
// in the URL path, URL query parameters, and the HTTP body.
|
||||
package httpio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// Request is a structured Go type that contains fields representing arguments
|
||||
// in the URL path, URL query parameters, and optionally the HTTP request body.
|
||||
//
|
||||
// Typically, this is a Go struct:
|
||||
//
|
||||
// - with fields tagged as `urlpath` to represent arguments in the URL path
|
||||
// (e.g., "/tailnet/{tailnetId}/devices/{deviceId}").
|
||||
// See [tailscale.com/util/httpio/urlpath] for details.
|
||||
//
|
||||
// - with fields tagged as `urlquery` to represent URL query parameters
|
||||
// (e.g., "?after=18635&limit=5").
|
||||
// See [tailscale.com/util/httpio/urlquery] for details.
|
||||
//
|
||||
// - with possibly other fields used to serialize as the HTTP body.
|
||||
// By default, [encoding/json] is used to marshal the entire struct value.
|
||||
// To prevent fields specific to `urlpath` or `urlquery` from being marshaled
|
||||
// as part of the body, explicitly ignore those fields with `json:"-"`.
|
||||
// An HTTP body is only populated if there are any exported fields
|
||||
// without the `urlpath` or `urlquery` struct tags.
|
||||
//
|
||||
// Since GET and DELETE methods usually have no associated body,
|
||||
// requests for such methods often only have `urlpath` and `urlquery` fields.
|
||||
//
|
||||
// Example GET request type:
|
||||
//
|
||||
// type GetDevicesRequest struct {
|
||||
// TailnetID tailcfg.TailnetID `urlpath:"tailnetId"`
|
||||
//
|
||||
// Limit uint `urlquery:"limit"`
|
||||
// After tailcfg.DeviceID `urlquery:"after"`
|
||||
// }
|
||||
//
|
||||
// Example PUT request type:
|
||||
//
|
||||
// type PutDeviceRequest struct {
|
||||
// TailnetID tailcfg.TailnetID `urlpath:"tailnetId" json:"-"`
|
||||
// DeviceID tailcfg.DeviceID `urlpath:"deviceId" json:"-"`
|
||||
//
|
||||
// Hostname string `json:"hostname,omitempty"``
|
||||
// IPv4 netip.IPAddr `json:"ipv4,omitzero"``
|
||||
// }
|
||||
//
|
||||
// By convention, request struct types are named "{Method}{Resource}Request",
|
||||
// where {Method} is the HTTP method (e.g., "Post, "Get", "Put", "Delete", etc.)
|
||||
// and {Resource} is some resource acted upon (e.g., "Device", "Routes", etc.).
|
||||
type Request = any
|
||||
|
||||
// Response is a structured Go type to represent the HTTP response body.
|
||||
//
|
||||
// By default, [encoding/json] is used to unmarshal the response value.
|
||||
// Unlike [Request], there is no support for `urlpath` and `urlquery` struct tags.
|
||||
//
|
||||
// Example response type:
|
||||
//
|
||||
// type GetDevicesResponses struct {
|
||||
// Devices []Device `json:"devices"`
|
||||
// Error ErrorResponse `json:"error"`
|
||||
// }
|
||||
//
|
||||
// By convention, response struct types are named "{Method}{Resource}Response",
|
||||
// where {Method} is the HTTP method (e.g., "Post, "Get", "Put", "Delete", etc.)
|
||||
// and {Resource} is some resource acted upon (e.g., "Device", "Routes", etc.).
|
||||
type Response = any
|
||||
|
||||
// Handler wraps a caller-provided handle function that operates on
|
||||
// concrete input and output types and returns a [http.Handler] function.
|
||||
func Handler[In Request, Out Response](handle func(ctx context.Context, in In) (out Out, err error), opts ...Option) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: How do we respond to the user if err is non-nil?
|
||||
// Do we default to status 500?
|
||||
panic("not implemented")
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Should url be a *url.URL? In the usage below, the caller should not pass query parameters.
|
||||
|
||||
// Post performs a POST call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Post[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
|
||||
return Do[In, Out](ctx, httpm.POST, url, in, opts...)
|
||||
}
|
||||
|
||||
// Get performs a GET call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Get[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
|
||||
return Do[In, Out](ctx, httpm.GET, url, in, opts...)
|
||||
}
|
||||
|
||||
// Put performs a PUT call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Put[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
|
||||
return Do[In, Out](ctx, httpm.PUT, url, in, opts...)
|
||||
}
|
||||
|
||||
// Delete performs a DELETE call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Delete[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
|
||||
return Do[In, Out](ctx, httpm.DELETE, url, in, opts...)
|
||||
}
|
||||
|
||||
// Do performs an HTTP method call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Do[In Request, Out Response](ctx context.Context, method, url string, in In, opts ...Option) (out Out, err error) {
|
||||
// TOOD: If the server returned a non-2xx code, we should report a Go error.
|
||||
panic("not implemented")
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package httpio
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Option is an option to alter the behavior of [httpio] functionality.
|
||||
type Option interface{ option() }
|
||||
|
||||
// WithClient specifies the [http.Client] to use in client-initiated requests.
|
||||
// It only affects [Do], [Get], [Post], [Put], and [Delete].
|
||||
// It has no effect on [Handler].
|
||||
func WithClient(c *http.Client) Option {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
// WithMarshaler specifies an marshaler to use for a particular "Content-Type".
|
||||
//
|
||||
// For client-side requests (e.g., [Do], [Get], [Post], [Put], and [Delete]),
|
||||
// the first specified encoder is used to specify the "Content-Type" and
|
||||
// to marshal the HTTP request body.
|
||||
//
|
||||
// For server-side responses (e.g., [Handler]), the first match between
|
||||
// the client-provided "Accept" header is used to select the encoder to use.
|
||||
// If no match is found, the first specified encoder is used regardless.
|
||||
//
|
||||
// If no encoder is specified, by default the "application/json" content type
|
||||
// is used with the [encoding/json] as the marshal implementation.
|
||||
func WithMarshaler(contentType string, marshal func(io.Writer, any) error) Option {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
// WithUnmarshaler specifies an unmarshaler to use for a particular "Content-Type".
|
||||
//
|
||||
// For both client-side responses and server-side requests,
|
||||
// the provided "Content-Type" header is used to select which decoder to use.
|
||||
// If no match is found, the first specified encoder is used regardless.
|
||||
func WithUnmarshaler(contentType string, unmarshal func(io.Reader, any) error) Option {
|
||||
panic("not implemented")
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
// Package urpath TODO
|
||||
package urlpath
|
||||
|
||||
// option is an option to alter behavior of Marshal and Unmarshal.
|
||||
// Currently, there are no defined options.
|
||||
type option interface{ option() }
|
||||
|
||||
func Marshal(pattern string, val any, opts ...option) (path string, err error)
|
||||
|
||||
func Unmarshal(pattern, path string, val any, opts ...option) (err error)
|
|
@ -0,0 +1,10 @@
|
|||
// Package urlquery TODO
|
||||
package urlquery
|
||||
|
||||
// option is an option to alter behavior of Marshal and Unmarshal.
|
||||
// Currently, there are no defined options.
|
||||
type option interface{ option() }
|
||||
|
||||
func Marshal(val any, opts ...option) (query string, err error)
|
||||
|
||||
func Unmarshal(query string, val any, opts ...option) (err error)
|
Loading…
Reference in New Issue