Repository: planetlabs/gpq (go)

Issue:
describe and validate remote geoparquet files
It'd be awesome if I could run `gpq describe` and `gpq validate` on url's. Doing everything on https would be the default, but if s3:// and others are easy that'd be nice too. I'd like to easily check row groups and validity on big remote resources, so it'd be awesome if it supported this. I feel like Brandon mentioned some go library he was liking for that, but forget where I saw that, so thought I'd put up an issue.

Pull request description:
Accept URLs for describe, validate, and convert input
This adds support for using URLs in addition to file paths as the input for the `describe`, `validate`, and `convert` commands.
```shell
# gpq describe https://github.com/opengeospatial/geoparquet/raw/v1.0.0/examples/example.parquet
╭────────────────────┬────────┬────────────┬────────────┬─────────────┬──────────┬───────────────────────┬───────────────────────────┬──────────────────────────╮
│ COLUMN             │ TYPE   │ ANNOTATION │ REPETITION │ COMPRESSION │ ENCODING │ GEOMETRY TYPES        │ BOUNDS                    │ DETAIL                   │
├────────────────────┼────────┼────────────┼────────────┼─────────────┼──────────┼───────────────────────┼───────────────────────────┼──────────────────────────┤
│ pop_est            │ double │            │ 0..1       │ snappy      │          │                       │                           │                          │
│ continent          │ binary │ string     │ 0..1       │ snappy      │          │                       │                           │                          │
│ name               │ binary │ string     │ 0..1       │ snappy      │          │                       │                           │                          │
│ iso_a3             │ binary │ string     │ 0..1       │ snappy      │          │                       │                           │                          │
│ gdp_md_est         │ int64  │            │ 0..1       │ snappy      │          │                       │                           │                          │
│ geometry           │ binary │            │ 0..1       │ snappy      │ WKB      │ Polygon, MultiPolygon │ [-180, -90, 180, 83.6451] │  edges │ planar          │
│                    │        │            │            │             │          │                       │                           │  crs   │ WGS 84 (CRS84)  │
├────────────────────┼────────┴────────────┴────────────┴─────────────┴──────────┴───────────────────────┴───────────────────────────┴──────────────────────────┤
│ Rows               │ 5                                                                                                                                        │
│ Row Groups         │ 1                                                                                                                                        │
│ GeoParquet Version │ 1.0.0                                                                                                                                    │
╰────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

You can also validate given an input URL (with or without the `--metadata-only` flag, with the flag skips scanning all geometries):
```shell
# gpq validate https://github.com/opengeospatial/geoparquet/raw/v1.0.0/examples/example.parquet --metadata-only
Summary: Passed 16 checks.

Metadata and schema checks only.  Skipped 4 data scanning checks.

 ✓ file must include a "geo" metadata key
 ✓ metadata must be a JSON object
 ✓ metadata must include a "version" string
 ✓ metadata must include a "primary_column" string
 ✓ metadata must include a "columns" object
 ✓ column metadata must include the "primary_column" name
 ✓ column metadata must include a valid "encoding" string
 ✓ column metadata must include a "geometry_types" list
 ✓ optional "crs" must be null or a PROJJSON object
 ✓ optional "orientation" must be a valid string
 ✓ optional "edges" must be a valid string
 ✓ optional "bbox" must be an array of 4 or 6 numbers
 ✓ optional "epoch" must be a number
 ✓ geometry columns must not be grouped
 ✓ geometry columns must be stored using the BYTE_ARRAY parquet type
 ✓ geometry columns must be required or optional, not repeated
```

And the same works for the `convert` command:
```shell
gpq convert https://github.com/opengeospatial/geoparquet/raw/v1.0.0/examples/example.parquet example.geojson
```

This doesn't yet add support for reading from blob storage.  I'll add that separately.

Fixes #93.

Patch:
diff --git a/cmd/gpq/command/command.go b/cmd/gpq/command/command.go
index f7cebfe..5786b73 100644
--- a/cmd/gpq/command/command.go
+++ b/cmd/gpq/command/command.go
@@ -1,8 +1,13 @@
 package command
 
 import (
+	"bytes"
 	"fmt"
 	"io"
+	"os"
+	"strings"
+
+	"github.com/planetlabs/gpq/internal/storage"
 )
 
 var CLI struct {
@@ -12,12 +17,6 @@ var CLI struct {
 	Version  VersionCmd  `cmd:"" help:"Print the version of this program."`
 }
 
-type ReaderAtSeeker interface {
-	io.Reader
-	io.ReaderAt
-	io.Seeker
-}
-
 type CommandError struct {
 	err error
 }
@@ -33,3 +32,19 @@ func (e *CommandError) Error() string {
 func (e *CommandError) Unwrap() error {
 	return e.err
 }
+
+func readerFromInput(input string) (storage.ReaderAtSeeker, error) {
+	if input == "" {
+		data, err := io.ReadAll(os.Stdin)
+		if err != nil {
+			return nil, fmt.Errorf("trouble reading from stdin: %w", err)
+		}
+		return bytes.NewReader(data), nil
+	}
+
+	if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
+		return storage.NewHttpReader(input)
+	}
+
+	return os.Open(input)
+}
diff --git a/cmd/gpq/command/convert.go b/cmd/gpq/command/convert.go
index 2f25793..57c8045 100644
--- a/cmd/gpq/command/convert.go
+++ b/cmd/gpq/command/convert.go
@@ -15,8 +15,7 @@
 package command
 
 import (
-	"bytes"
-	"io"
+	"net/url"
 	"os"
 	"strings"
 
@@ -25,7 +24,7 @@ import (
 )
 
 type ConvertCmd struct {
-	Input              string `arg:"" optional:"" name:"input" help:"Input file.  If not provided, input is read from stdin." type:"path"`
+	Input              string `arg:"" optional:"" name:"input" help:"Input file path or URL.  If not provided, input is read from stdin."`
 	From               string `help:"Input file format.  Possible values: ${enum}." enum:"auto, geojson, geoparquet, parquet" default:"auto"`
 	Output             string `arg:"" optional:"" name:"output" help:"Output file.  If not provided, output is written to stdout." type:"path"`
 	To                 string `help:"Output file format.  Possible values: ${enum}." enum:"auto, geojson, geoparquet" default:"auto"`
@@ -64,14 +63,17 @@ func parseFormatType(format string) FormatType {
 	return ft
 }
 
-func getFormatType(filename string) FormatType {
-	if strings.HasSuffix(filename, ".json") || strings.HasSuffix(filename, ".geojson") {
+func getFormatType(resource string) FormatType {
+	if u, err := url.Parse(resource); err == nil {
+		resource = u.Path
+	}
+	if strings.HasSuffix(resource, ".json") || strings.HasSuffix(resource, ".geojson") {
 		return GeoJSONType
 	}
-	if strings.HasSuffix(filename, ".gpq") || strings.HasSuffix(filename, ".geoparquet") {
+	if strings.HasSuffix(resource, ".gpq") || strings.HasSuffix(resource, ".geoparquet") {
 		return GeoParquetType
 	}
-	if strings.HasSuffix(filename, ".pq") || strings.HasSuffix(filename, ".parquet") {
+	if strings.HasSuffix(resource, ".pq") || strings.HasSuffix(resource, ".parquet") {
 		return ParquetType
 	}
 	return UnknownType
@@ -116,20 +118,9 @@ func (c *ConvertCmd) Run() error {
 		return NewCommandError("could not determine input format for %s", inputSource)
 	}
 
-	var input ReaderAtSeeker
-	if inputSource == "" {
-		data, err := io.ReadAll(os.Stdin)
-		if err != nil {
-			return NewCommandError("trouble reading from stdin: %w", err)
-		}
-		input = bytes.NewReader(data)
-	} else {
-		i, readErr := os.Open(inputSource)
-		if readErr != nil {
-			return NewCommandError("failed to read from %q: %w", inputSource, readErr)
-		}
-		defer i.Close()
-		input = i
+	input, inputErr := readerFromInput(inputSource)
+	if inputErr != nil {
+		return NewCommandError("trouble getting a reader from %q: %w", c.Input, inputErr)
 	}
 
 	var output *os.File
diff --git a/cmd/gpq/command/describe.go b/cmd/gpq/command/describe.go
index 13847e4..db6ca02 100644
--- a/cmd/gpq/command/describe.go
+++ b/cmd/gpq/command/describe.go
@@ -15,11 +15,9 @@
 package command
 
 import (
-	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
 	"os"
 	"strconv"
 	"strings"
@@ -34,7 +32,7 @@ import (
 )
 
 type DescribeCmd struct {
-	Input        string `arg:"" optional:"" name:"input" help:"Path to a GeoParquet file.  If not provided, input is read from stdin." type:"existingfile"`
+	Input        string `arg:"" optional:"" name:"input" help:"Path or URL for a GeoParquet file.  If not provided, input is read from stdin."`
 	Format       string `help:"Report format.  Possible values: ${enum}." enum:"text, json" default:"text"`
 	MetadataOnly bool   `help:"Print the unformatted geo metadata only (other arguments will be ignored)."`
 	Unpretty     bool   `help:"No newlines or indentation in the JSON output."`
@@ -53,20 +51,9 @@ const (
 )
 
 func (c *DescribeCmd) Run() error {
-	var input ReaderAtSeeker
-	if c.Input == "" {
-		data, err := io.ReadAll(os.Stdin)
-		if err != nil {
-			return NewCommandError("trouble reading from stdin: %w", err)
-		}
-		input = bytes.NewReader(data)
-	} else {
-		i, readErr := os.Open(c.Input)
-		if readErr != nil {
-			return NewCommandError("failed to read from %q: %w", c.Input, readErr)
-		}
-		defer i.Close()
-		input = i
+	input, inputErr := readerFromInput(c.Input)
+	if inputErr != nil {
+		return NewCommandError("trouble getting a reader from %q: %w", c.Input, inputErr)
 	}
 
 	fileReader, fileErr := file.NewParquetReader(input)
diff --git a/cmd/gpq/command/validate.go b/cmd/gpq/command/validate.go
index ce8f7fd..6877199 100644
--- a/cmd/gpq/command/validate.go
+++ b/cmd/gpq/command/validate.go
@@ -15,11 +15,9 @@
 package command
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
-	"io"
 	"os"
 	"strings"
 
@@ -29,34 +27,22 @@ import (
 )
 
 type ValidateCmd struct {
-	Input        string `arg:"" optional:"" name:"input" help:"Path to a GeoParquet file.  If not provided, input is read from stdin." type:"existingfile"`
+	Input        string `arg:"" optional:"" name:"input" help:"Path or URL for a GeoParquet file.  If not provided, input is read from stdin."`
 	MetadataOnly bool   `help:"Only run rules that apply to file metadata and schema (no data will be scanned)."`
 	Unpretty     bool   `help:"No colors in text output, no newlines and indentation in JSON output."`
 	Format       string `help:"Report format.  Possible values: ${enum}." enum:"text, json" default:"text"`
 }
 
 func (c *ValidateCmd) Run(ctx *kong.Context) error {
+	input, inputErr := readerFromInput(c.Input)
+	if inputErr != nil {
+		return NewCommandError("trouble getting a reader from %q: %w", c.Input, inputErr)
+	}
+
 	inputName := c.Input
-	var input ReaderAtSeeker
-	if c.Input == "" {
-		if !hasStdin() {
-			return NewCommandError("input argument must be provided if there is no stdin data")
-		}
-		data, err := io.ReadAll(os.Stdin)
-		if err != nil {
-			return NewCommandError("trouble reading from stdin: %w", err)
-		}
-		input = bytes.NewReader(data)
+	if inputName == "" {
 		inputName = "<stdin>"
-	} else {
-		i, readErr := os.Open(c.Input)
-		if readErr != nil {
-			return NewCommandError("failed to read from %q: %w", c.Input, readErr)
-		}
-		defer i.Close()
-		input = i
 	}
-
 	v := validator.New(c.MetadataOnly)
 	report, err := v.Validate(context.Background(), input, inputName)
 	if err != nil {
diff --git a/go.mod b/go.mod
index 8e67833..a1209bf 100644
--- a/go.mod
+++ b/go.mod
@@ -6,11 +6,11 @@ require (
 	github.com/alecthomas/kong v0.8.0
 	github.com/apache/arrow/go/v14 v14.0.0-20230922164031-772a01c080ad
 	github.com/fatih/color v1.15.0
-	github.com/jedib0t/go-pretty/v6 v6.4.8
+	github.com/jedib0t/go-pretty/v6 v6.4.7
 	github.com/paulmach/orb v0.10.0
 	github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
 	github.com/stretchr/testify v1.8.4
-	golang.org/x/term v0.13.0
+	golang.org/x/term v0.12.0
 )
 
 require (
@@ -39,7 +39,7 @@ require (
 	golang.org/x/mod v0.12.0 // indirect
 	golang.org/x/net v0.15.0 // indirect
 	golang.org/x/sync v0.3.0 // indirect
-	golang.org/x/sys v0.13.0 // indirect
+	golang.org/x/sys v0.12.0 // indirect
 	golang.org/x/text v0.13.0 // indirect
 	golang.org/x/tools v0.13.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
diff --git a/go.sum b/go.sum
index b45f70a..8ddf149 100644
--- a/go.sum
+++ b/go.sum
@@ -34,8 +34,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
-github.com/jedib0t/go-pretty/v6 v6.4.8 h1:HiNzyMSEpsBaduKhmK+CwcpulEeBrTmxutz4oX/oWkg=
-github.com/jedib0t/go-pretty/v6 v6.4.8/go.mod h1:Ndk3ase2CkQbXLLNf5QDHoYb6J9WtVfmHZu9n8rk2xs=
+github.com/jedib0t/go-pretty/v6 v6.4.7 h1:lwiTJr1DEkAgzljsUsORmWsVn5MQjt1BPJdPCtJ6KXE=
+github.com/jedib0t/go-pretty/v6 v6.4.7/go.mod h1:Ndk3ase2CkQbXLLNf5QDHoYb6J9WtVfmHZu9n8rk2xs=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
@@ -134,11 +134,11 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
-golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
-golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
diff --git a/internal/storage/http.go b/internal/storage/http.go
new file mode 100644
index 0000000..b12dda7
--- /dev/null
+++ b/internal/storage/http.go
@@ -0,0 +1,185 @@
+package storage
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"strings"
+)
+
+const (
+	initialHttpRequestSize = 512
+	minHttpRequestSize     = 1024
+)
+
+type HttpReader struct {
+	url          string
+	offset       int64
+	size         int64
+	client       *http.Client
+	buffer       ReaderAtSeeker
+	bufferOffset int64
+	bufferSize   int64
+	validator    string
+}
+
+var _ ReaderAtSeeker = (*HttpReader)(nil)
+
+func NewHttpReader(url string) (*HttpReader, error) {
+	reader := &HttpReader{
+		url:    url,
+		client: &http.Client{},
+	}
+	if err := reader.init(); err != nil {
+		return nil, err
+	}
+	return reader, nil
+}
+
+func (r *HttpReader) init() error {
+	req, err := http.NewRequest(http.MethodGet, r.url, nil)
+	if err != nil {
+		return err
+	}
+
+	// make an initial range request to determine size
+	req.Header.Add("Range", fmt.Sprintf("bytes=0-%d", initialHttpRequestSize-1))
+	resp, err := r.client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if !success(resp) {
+		return fmt.Errorf("unexpected response from %s: %d", r.url, resp.StatusCode)
+	}
+
+	data, readErr := io.ReadAll(resp.Body)
+	if readErr != nil {
+		return fmt.Errorf("failed to read response from %s: %w", r.url, readErr)
+	}
+
+	r.buffer = bytes.NewReader(data)
+	r.bufferSize = int64(len(data))
+
+	str := resp.Header.Get("Content-Range")
+	if strings.Contains(str, "/") {
+		size, err := strconv.ParseInt(strings.Split(str, "/")[1], 10, 64)
+		if err != nil {
+			return fmt.Errorf("invalid content-range header from %s: %w", r.url, err)
+		}
+		r.size = size
+		r.validator = validatorFromResponse(resp)
+	} else {
+		r.size = int64(len(data))
+	}
+	return nil
+}
+
+func success(response *http.Response) bool {
+	return response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices
+}
+
+func validatorFromResponse(resp *http.Response) string {
+	etag := resp.Header.Get("ETag")
+	if etag != "" && etag[0] == '"' {
+		return etag
+	}
+
+	return resp.Header.Get("Last-Modified")
+}
+
+func (r *HttpReader) ReadAt(data []byte, offset int64) (int, error) {
+	_, err := r.Seek(offset, io.SeekStart)
+	if err != nil {
+		return 0, err
+	}
+
+	total := 0
+	for {
+		if total >= len(data) {
+			break
+		}
+		n, err := r.Read(data[total:])
+		if err != nil {
+			return total + n, err
+		}
+		total = total + n
+	}
+	return total, nil
+}
+
+func (r *HttpReader) Seek(offset int64, whence int) (int64, error) {
+	switch whence {
+	case io.SeekCurrent:
+		offset = r.offset + offset
+	case io.SeekEnd:
+		offset = r.size + offset
+	}
+
+	if offset < 0 {
+		return 0, fmt.Errorf("attempt to seek to a negative offset: %d", offset)
+	}
+	r.offset = offset
+	return offset, nil
+}
+
+func (r *HttpReader) Read(data []byte) (n int, err error) {
+	if r.offset > r.size {
+		return 0, io.EOF
+	}
+	if r.buffer == nil || r.offset < r.bufferOffset || r.offset > r.bufferOffset+r.bufferSize {
+		if err := r.request(int64(len(data))); err != nil {
+			return 0, err
+		}
+	}
+	read, err := r.buffer.ReadAt(data, r.offset-r.bufferOffset)
+	r.offset = r.offset + int64(read)
+	if err == io.EOF && r.offset < r.size {
+		r.buffer = nil
+		return read, nil
+	}
+	return read, err
+}
+
+func (r *HttpReader) request(size int64) error {
+	req, err := http.NewRequest(http.MethodGet, r.url, nil)
+	if err != nil {
+		return err
+	}
+	requestSize := size
+	if requestSize < minHttpRequestSize {
+		requestSize = minHttpRequestSize
+	}
+	req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", r.offset, r.offset+requestSize))
+	if r.validator != "" {
+		req.Header.Set("If-Range", r.validator)
+	}
+
+	resp, err := r.client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if !success(resp) {
+		return fmt.Errorf("unexpected response from %s: %d", r.url, resp.StatusCode)
+	}
+
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+
+	r.buffer = bytes.NewReader(data)
+	r.bufferOffset = r.offset
+	r.bufferSize = int64(len(data))
+	return nil
+}
+
+func (r *HttpReader) Close() {
+	if r.buffer != nil {
+		r.buffer = nil
+	}
+	r.client.CloseIdleConnections()
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
new file mode 100644
index 0000000..1fe0b6b
--- /dev/null
+++ b/internal/storage/storage.go
@@ -0,0 +1,9 @@
+package storage
+
+import "io"
+
+type ReaderAtSeeker interface {
+	io.Reader
+	io.ReaderAt
+	io.Seeker
+}
diff --git a/web/package-lock.json b/web/package-lock.json
index d287182..faa0fd0 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -420,9 +420,9 @@
       }
     },
     "node_modules/@eslint/js": {
-      "version": "8.51.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz",
-      "integrity": "sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.50.0.tgz",
+      "integrity": "sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -894,15 +894,15 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.51.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.51.0.tgz",
-      "integrity": "sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.50.0.tgz",
+      "integrity": "sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
         "@eslint/eslintrc": "^2.1.2",
-        "@eslint/js": "8.51.0",
+        "@eslint/js": "8.50.0",
         "@humanwhocodes/config-array": "^0.11.11",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
@@ -2632,9 +2632,9 @@
       }
     },
     "node_modules/vite": {
-      "version": "4.4.11",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz",
-      "integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==",
+      "version": "4.4.9",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz",
+      "integrity": "sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==",
       "dev": true,
       "dependencies": {
         "esbuild": "^0.18.10",
@@ -2930,9 +2930,9 @@
       }
     },
     "@eslint/js": {
-      "version": "8.51.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz",
-      "integrity": "sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.50.0.tgz",
+      "integrity": "sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==",
       "dev": true
     },
     "@humanwhocodes/config-array": {
@@ -3283,15 +3283,15 @@
       "dev": true
     },
     "eslint": {
-      "version": "8.51.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.51.0.tgz",
-      "integrity": "sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.50.0.tgz",
+      "integrity": "sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==",
       "dev": true,
       "requires": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
         "@eslint/eslintrc": "^2.1.2",
-        "@eslint/js": "8.51.0",
+        "@eslint/js": "8.50.0",
         "@humanwhocodes/config-array": "^0.11.11",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
@@ -4519,9 +4519,9 @@
       }
     },
     "vite": {
-      "version": "4.4.11",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz",
-      "integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==",
+      "version": "4.4.9",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz",
+      "integrity": "sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==",
       "dev": true,
       "requires": {
         "esbuild": "^0.18.10",


Test patch:
diff --git a/cmd/gpq/command/command_test.go b/cmd/gpq/command/command_test.go
index 716c0aa..aee9e9f 100644
--- a/cmd/gpq/command/command_test.go
+++ b/cmd/gpq/command/command_test.go
@@ -2,6 +2,8 @@ package command_test
 
 import (
 	"io"
+	"net/http"
+	"net/http/httptest"
 	"os"
 	"testing"
 
@@ -14,6 +16,7 @@ type Suite struct {
 	mockStdin      *os.File
 	originalStdout *os.File
 	mockStdout     *os.File
+	server         *httptest.Server
 }
 
 func (s *Suite) SetupTest() {
@@ -28,6 +31,9 @@ func (s *Suite) SetupTest() {
 	s.originalStdout = os.Stdout
 	s.mockStdout = stdout
 	os.Stdout = stdout
+
+	handler := http.FileServer(http.Dir("../../../internal"))
+	s.server = httptest.NewServer(handler)
 }
 
 func (s *Suite) writeStdin(data []byte) {
@@ -58,6 +64,8 @@ func (s *Suite) TearDownTest() {
 
 	_ = s.mockStdout.Close()
 	s.NoError(os.Remove(s.mockStdout.Name()))
+
+	s.server.Close()
 }
 
 func TestSuite(t *testing.T) {
diff --git a/cmd/gpq/command/convert_test.go b/cmd/gpq/command/convert_test.go
index 48b5d24..467ecb8 100644
--- a/cmd/gpq/command/convert_test.go
+++ b/cmd/gpq/command/convert_test.go
@@ -120,3 +120,17 @@ func (s *Suite) TestConvertUnknownStdinToGeoParquetStdout() {
 
 	s.ErrorContains(cmd.Run(), "when reading from stdin, the --from option must be provided")
 }
+
+func (s *Suite) TestConvertGeoParquetUrlToGeoJSONStdout() {
+	cmd := &command.ConvertCmd{
+		Input: s.server.URL + "/testdata/cases/example-v1.0.0.parquet",
+		To:    "geojson",
+	}
+
+	s.Require().NoError(cmd.Run())
+	data := s.readStdout()
+
+	collection := &geo.FeatureCollection{}
+	s.Require().NoError(json.Unmarshal(data, collection))
+	s.Len(collection.Features, 5)
+}
diff --git a/cmd/gpq/command/describe_test.go b/cmd/gpq/command/describe_test.go
index c1682d4..1f8e946 100644
--- a/cmd/gpq/command/describe_test.go
+++ b/cmd/gpq/command/describe_test.go
@@ -172,3 +172,28 @@ func (s *Suite) TestDescribeMissingMetadata() {
 	s.Require().Len(info.Issues, 1)
 	s.Contains(info.Issues[0], "Not a valid GeoParquet file (missing the \"geo\" metadata key).")
 }
+
+func (s *Suite) TestDescribeFromUrl() {
+	cmd := &command.DescribeCmd{
+		Format: "json",
+		Input:  s.server.URL + "/testdata/cases/example-v1.0.0.parquet",
+	}
+
+	s.Require().NoError(cmd.Run())
+
+	output := s.readStdout()
+	info := &command.DescribeInfo{}
+	err := json.Unmarshal(output, info)
+	s.Require().NoError(err)
+
+	s.Equal(int64(5), info.NumRows)
+	s.Equal(int64(1), info.NumRowGroups)
+	s.Require().Len(info.Schema.Fields, 6)
+
+	s.Equal("geometry", info.Schema.Fields[0].Name)
+	s.Equal("binary", info.Schema.Fields[0].Type)
+	s.Equal("gzip", info.Schema.Fields[0].Compression)
+	s.True(info.Schema.Fields[0].Optional)
+
+	s.Len(info.Issues, 0)
+}
diff --git a/internal/storage/http_test.go b/internal/storage/http_test.go
new file mode 100644
index 0000000..d24d7c2
--- /dev/null
+++ b/internal/storage/http_test.go
@@ -0,0 +1,200 @@
+package storage_test
+
+import (
+	"bytes"
+	"crypto/rand"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/planetlabs/gpq/internal/storage"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func randBytes(t *testing.T, size int) []byte {
+	data := make([]byte, size)
+	n, err := rand.Read(data)
+	require.NoError(t, err)
+	require.Equal(t, n, size)
+	return data
+}
+
+func contentUrl(t *testing.T, content []byte) string {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodGet {
+			w.WriteHeader(http.StatusMethodNotAllowed)
+			return
+		}
+		http.ServeContent(w, r, "content.txt", time.Time{}, bytes.NewReader([]byte(content)))
+	}))
+	return server.URL
+}
+
+func TestHttpReaderReadAll(t *testing.T) {
+	content := randBytes(t, 1000)
+	url := contentUrl(t, content)
+
+	reader, err := storage.NewHttpReader(url)
+	require.NoError(t, err)
+	defer reader.Close()
+
+	data, err := io.ReadAll(reader)
+	require.NoError(t, err)
+	assert.Equal(t, len(content), len(data))
+	assert.Equal(t, content, data)
+}
+
+func TestHttpReaderReadAt(t *testing.T) {
+	content := randBytes(t, 1000)
+	url := contentUrl(t, content)
+
+	httpReader, err := storage.NewHttpReader(url)
+	require.NoError(t, err)
+	defer httpReader.Close()
+
+	byteReader := bytes.NewReader(content)
+
+	cases := []struct {
+		name   string
+		offset int
+		size   int
+		err    string
+	}{
+		{
+			name:   "first read",
+			offset: 700,
+			size:   50,
+		},
+		{
+			name:   "second read",
+			offset: 10,
+			size:   10,
+		},
+		{
+			name:   "offset after end",
+			offset: len(content) + 10,
+			size:   10,
+			err:    io.EOF.Error(),
+		},
+		{
+			name:   "offset near end",
+			offset: len(content) - 10,
+			size:   20,
+			err:    io.EOF.Error(),
+		},
+		{
+			name:   "offset before start",
+			offset: -1,
+			size:   10,
+			err:    "attempt to seek to a negative offset: -1",
+		},
+	}
+
+	for i, c := range cases {
+		t.Run(fmt.Sprintf("%s (case %d)", c.name, i), func(t *testing.T) {
+			data := make([]byte, c.size)
+			read, err := httpReader.ReadAt(data, int64(c.offset))
+			if c.err == "" {
+				require.NoError(t, err)
+			}
+			if err != nil {
+				assert.ErrorContains(t, err, c.err)
+			}
+			expected := make([]byte, c.size)
+			expectedRead, _ := byteReader.ReadAt(expected, int64(c.offset))
+			require.Equal(t, expectedRead, read)
+			assert.Equal(t, expected[:read], data[:read])
+		})
+	}
+}
+
+func TestHttpReaderSeek(t *testing.T) {
+	content := randBytes(t, 1000)
+	url := contentUrl(t, content)
+
+	httpReader, err := storage.NewHttpReader(url)
+	require.NoError(t, err)
+	defer httpReader.Close()
+
+	byteReader := bytes.NewReader(content)
+
+	cases := []struct {
+		name   string
+		offset int
+		whence int
+		err    string
+	}{
+		{
+			name:   "seek start",
+			offset: 700,
+			whence: io.SeekStart,
+		},
+		{
+			name:   "seek current",
+			offset: 10,
+			whence: io.SeekCurrent,
+		},
+		{
+			name:   "seek end",
+			offset: -10,
+			whence: io.SeekEnd,
+		},
+		{
+			name:   "offset beyond end",
+			offset: 10,
+			whence: io.SeekEnd,
+		},
+		{
+			name:   "offset before start",
+			offset: -1,
+			whence: io.SeekStart,
+			err:    "attempt to seek to a negative offset: -1",
+		},
+	}
+
+	for i, c := range cases {
+		t.Run(fmt.Sprintf("%s (case %d)", c.name, i), func(t *testing.T) {
+			data := make([]byte, 10)
+			offset, seekErr := httpReader.Seek(int64(c.offset), c.whence)
+			if c.err == "" {
+				require.NoError(t, seekErr)
+				return
+			}
+			if seekErr != nil {
+				require.ErrorContains(t, seekErr, c.err)
+				return
+			}
+
+			total := 0
+			for {
+				read, readErr := httpReader.Read(data[total:])
+				total += read
+				if readErr == io.EOF {
+					break
+				}
+				require.NoError(t, readErr)
+			}
+
+			expectedOffset, _ := byteReader.Seek(int64(c.offset), c.whence)
+			assert.Equal(t, expectedOffset, offset)
+
+			expected := make([]byte, len(data))
+			expectedTotal := 0
+			for {
+				read, err := byteReader.Read(expected[expectedTotal:])
+				expectedTotal += read
+				if err == io.EOF {
+					break
+				}
+				require.NoError(t, err)
+			}
+
+			assert.Equal(t, expectedTotal, total)
+			assert.Equal(t, expected[:total], data[:total])
+		})
+	}
+}