diff options
| author | Joe Tsai <joetsai@digital-static.net> | 2024-01-11 15:23:52 -0800 |
|---|---|---|
| committer | Joe Tsai <joetsai@digital-static.net> | 2024-01-11 15:30:43 -0800 |
| commit | 2e20bd2ffe2098877ad44d5eed93a1820956d1e9 (patch) | |
| tree | 7472ed2a5ca5442b870a03ea41d8fd4b835a4154 | |
| parent | b89c11336514d541ad25de564dcf0561c0b24e58 (diff) | |
| download | tailscale-dsnet/httpio.tar.xz tailscale-dsnet/httpio.zip | |
util/httpio: prototype design for handling I/O in HTTPdsnet/httpio
Signed-off-by: Joe Tsai <joetsai@digital-static.net>
| -rw-r--r-- | util/httphdr/auth.go | 81 | ||||
| -rw-r--r-- | util/httpio/context.go | 43 | ||||
| -rw-r--r-- | util/httpio/endpoint.go | 93 | ||||
| -rw-r--r-- | util/httpio/httpio.go | 121 | ||||
| -rw-r--r-- | util/httpio/options.go | 44 | ||||
| -rw-r--r-- | util/httpio/urlpath/urlpath.go | 10 | ||||
| -rw-r--r-- | util/httpio/urlquery/urlquery.go | 10 |
7 files changed, 402 insertions, 0 deletions
diff --git a/util/httphdr/auth.go b/util/httphdr/auth.go new file mode 100644 index 000000000..af81c9b3d --- /dev/null +++ b/util/httphdr/auth.go @@ -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 +} diff --git a/util/httpio/context.go b/util/httpio/context.go new file mode 100644 index 000000000..ddc5a9b51 --- /dev/null +++ b/util/httpio/context.go @@ -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]. diff --git a/util/httpio/endpoint.go b/util/httpio/endpoint.go new file mode 100644 index 000000000..bbd6f1b9d --- /dev/null +++ b/util/httpio/endpoint.go @@ -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...) +} diff --git a/util/httpio/httpio.go b/util/httpio/httpio.go new file mode 100644 index 000000000..dcdeb4eef --- /dev/null +++ b/util/httpio/httpio.go @@ -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") +} diff --git a/util/httpio/options.go b/util/httpio/options.go new file mode 100644 index 000000000..cc6598765 --- /dev/null +++ b/util/httpio/options.go @@ -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") +} diff --git a/util/httpio/urlpath/urlpath.go b/util/httpio/urlpath/urlpath.go new file mode 100644 index 000000000..e6ae7f25a --- /dev/null +++ b/util/httpio/urlpath/urlpath.go @@ -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) diff --git a/util/httpio/urlquery/urlquery.go b/util/httpio/urlquery/urlquery.go new file mode 100644 index 000000000..94a46455e --- /dev/null +++ b/util/httpio/urlquery/urlquery.go @@ -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) |
