util/httpio: prototype design for handling I/O in HTTP

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
This commit is contained in:
Joe Tsai 2024-01-11 15:23:52 -08:00
parent b89c113365
commit 2e20bd2ffe
7 changed files with 402 additions and 0 deletions

81
util/httphdr/auth.go Normal file
View File

@ -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
}

43
util/httpio/context.go Normal file
View File

@ -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].

93
util/httpio/endpoint.go Normal file
View File

@ -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...)
}

121
util/httpio/httpio.go Normal file
View File

@ -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")
}

44
util/httpio/options.go Normal file
View File

@ -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")
}

View File

@ -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)

View File

@ -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)