deadlinks/client.go

141 lines
3.1 KiB
Go

package deadlinks
import (
"context"
"errors"
"fmt"
"io"
"git.sr.ht/~adnano/go-gemini"
)
// Client is a thread-safe type which fetches a resource at the given URL,
// returning its MIME type and body. If the MIME type is not known then empty
// string should be returned.
type Client interface {
Get(context.Context, URL) (string, io.ReadCloser, error)
}
// ClientOpts are optional fields which can be provided to NewClient. A nil
// ClientOpts is equivalent to an empty one.
type ClientOpts struct {
// GeminiClient will be used for retrieving resources via the gemini
// protocol.
//
// Defaults to `new(gemini.Client)`.
GeminiClient interface {
Do(context.Context, *gemini.Request) (*gemini.Response, error)
}
// MaxRedirects indicates the maximum number of redirects which will be
// allowed when resolving a resource. A negative value indicates no
// redirects are allowed.
//
// Default: 10.
MaxRedirects int
}
func (o *ClientOpts) withDefaults() *ClientOpts {
if o == nil {
o = new(ClientOpts)
}
if o.GeminiClient == nil {
o.GeminiClient = new(gemini.Client)
}
if o.MaxRedirects == 0 {
o.MaxRedirects = 10
}
return o
}
type client struct {
opts ClientOpts
}
// NewClient initializes and returns a Client which supports commonly used
// transport protocols. The returned Client will error when it encounters an
// unfamiliar protocol.
//
// Supported URL schemas:
// - gemini
// - http/https (TODO)
func NewClient(opts *ClientOpts) Client {
return &client{*opts.withDefaults()}
}
func (c *client) getGemini(
ctx context.Context, url URL, redirectDepth int,
) (
string, io.ReadCloser, error,
) {
req, err := gemini.NewRequest(string(url))
if err != nil {
return "", nil, fmt.Errorf("building request: %w", err)
}
// TODO allow specifying client cert
res, err := c.opts.GeminiClient.Do(ctx, req)
if err != nil {
return "", nil, fmt.Errorf("performing request: %w", err)
}
// all status numbers are grouped by their first digit, and actions taken
// can be entirely based on that.
switch res.Status / 10 {
case 1: // input required
// Assume that input required is fine, even though we don't know the
// MIME type.
return "", res.Body, nil
case 2: // success
return res.Meta, res.Body, nil
case 3: // redirect
defer res.Body.Close()
if redirectDepth >= c.opts.MaxRedirects {
return "", nil, errors.New("too many redirects")
}
newURL, err := url.ResolveReference(res.Meta)
if err != nil {
return "", nil, fmt.Errorf("resolving redirect URL %q: %w", res.Meta, err)
}
return c.get(ctx, newURL, redirectDepth+1)
default:
defer res.Body.Close()
return "", nil, fmt.Errorf(
"response code %d (%v): %q", res.Status, res.Status, res.Meta,
)
}
}
func (c *client) get(
ctx context.Context, url URL, redirectDepth int,
) (
string, io.ReadCloser, error,
) {
scheme := url.toStd().Scheme
switch scheme {
case "gemini":
return c.getGemini(ctx, url, redirectDepth)
default:
return "", nil, fmt.Errorf("unsupported scheme %q", scheme)
}
}
func (c *client) Get(
ctx context.Context, url URL,
) (
string, io.ReadCloser, error,
) {
return c.get(ctx, url, 0)
}