summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJoe Tsai <joetsai@digital-static.net>2024-01-11 15:23:52 -0800
committerJoe Tsai <joetsai@digital-static.net>2024-01-11 15:30:43 -0800
commit2e20bd2ffe2098877ad44d5eed93a1820956d1e9 (patch)
tree7472ed2a5ca5442b870a03ea41d8fd4b835a4154
parentb89c11336514d541ad25de564dcf0561c0b24e58 (diff)
downloadtailscale-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.go81
-rw-r--r--util/httpio/context.go43
-rw-r--r--util/httpio/endpoint.go93
-rw-r--r--util/httpio/httpio.go121
-rw-r--r--util/httpio/options.go44
-rw-r--r--util/httpio/urlpath/urlpath.go10
-rw-r--r--util/httpio/urlquery/urlquery.go10
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)