Implement basic Client which supports the gemini protocol

This commit is contained in:
Brian Picciano 2023-12-28 17:11:42 +01:00
parent 8007f090f2
commit e03e4037d2
5 changed files with 171 additions and 2 deletions

140
client.go Normal file
View File

@ -0,0 +1,140 @@
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)
}

View File

@ -50,6 +50,23 @@ func parseURLs(urlStrs []string) ([]URL, error) {
return res, errors.Join(errs...)
}
func (u URL) toStd() *url.URL {
uu, err := url.Parse(string(u))
if err != nil {
panic(fmt.Sprintf("parsing URL %q: %v", u, err))
}
return uu
}
// ResolveReference is equivalend to the method of the same name in `net/url`.
func (u URL) ResolveReference(u2Str string) (URL, error) {
u2, err := url.Parse(u2Str)
if err != nil {
return "", err
}
return URL(u.toStd().ResolveReference(u2).String()), nil
}
// ResourceStatus describes what state a particular Resource is in.
type ResourceStatus int

3
go.mod
View File

@ -4,6 +4,7 @@ go 1.20
require (
code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226160338-0b5bdf3dfb03 // indirect
git.sr.ht/~adnano/go-gemini v0.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/mattn/go-sqlite3 v1.14.19 // indirect
@ -11,5 +12,7 @@ require (
github.com/rubenv/sql-migrate v1.6.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
golang.org/x/text v0.3.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

9
go.sum
View File

@ -2,6 +2,8 @@ code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226155808-e8376ef26
code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226155808-e8376ef263a7/go.mod h1:GJhpoMNnN/OT6O9NmeQBV02yq9kQP8zPyY1IvsslHak=
code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226160338-0b5bdf3dfb03 h1:wJ6X1vc289RpHVGClD1P33yijPoNIdgCXbTn7DjVWYs=
code.betamike.com/mediocregopher/mediocre-go-lib v0.0.0-20231226160338-0b5bdf3dfb03/go.mod h1:GJhpoMNnN/OT6O9NmeQBV02yq9kQP8zPyY1IvsslHak=
git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=
git.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -21,6 +23,13 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -80,8 +80,8 @@ var migrations = &migrate.MemoryMigrationSource{Migrations: []*migrate.Migration
},
}}
// SQLiteSQLiteStoreOpts are optional fields which can be provided to NewSQLiteStore.
// A nil SQLiteSQLiteStoreOpts is equivalent to an empty one.
// SQLiteSQLiteStoreOpts are optional fields which can be provided to
// NewSQLiteStore. A nil SQLiteSQLiteStoreOpts is equivalent to an empty one.
type SQLiteStoreOpts struct {
// Path to the database file to use.
//