Repository: planetlabs/gpq (go)

Issue:
Error when doing describe / validate with non-geo parquet file
Trying to figure out if a parquet file at https://open.quiltdata.com/b/spatial-ucr/tree/census/administrative/counties.parquet is valid geoparquet. Run 'describe' and 'validate' and get:

`gpq: error: command.ValidateCmd.Run(): unable to parse geo metadata: json: cannot unmarshal string into Go struct field GeometryColumn.columns.crs of type geoparquet.Proj`

Note that 'convert' works fine, and then can describe it / validate it.

Pull request description:
Improved validation error handling
Previously, if the "crs" property in the geo metadata was not the expected type (an object), the `validate` command would fail with a bad error message (see #86).  This change makes it so an unexpected "crs" type is a fatal error, so additional rules that require valid metadata are not run.

In addition, this change updates the `describe` output so it includes some hints about what to do if the geo metadata is present but looks invalid.  The new `--metadata-only` flag for the `describe` command can be used to dump the geo metadata without trying to parse it.

Fixes #86.


Patch:
diff --git a/cmd/gpq/command/command.go b/cmd/gpq/command/command.go
index 1a2558f..f7cebfe 100644
--- a/cmd/gpq/command/command.go
+++ b/cmd/gpq/command/command.go
@@ -1,6 +1,9 @@
 package command
 
-import "io"
+import (
+	"fmt"
+	"io"
+)
 
 var CLI struct {
 	Convert  ConvertCmd  `cmd:"" help:"Convert data from one format to another."`
@@ -14,3 +17,19 @@ type ReaderAtSeeker interface {
 	io.ReaderAt
 	io.Seeker
 }
+
+type CommandError struct {
+	err error
+}
+
+func NewCommandError(format string, a ...any) *CommandError {
+	return &CommandError{err: fmt.Errorf(format, a...)}
+}
+
+func (e *CommandError) Error() string {
+	return e.err.Error()
+}
+
+func (e *CommandError) Unwrap() error {
+	return e.err
+}
diff --git a/cmd/gpq/command/convert.go b/cmd/gpq/command/convert.go
index 0130c9d..2f25793 100644
--- a/cmd/gpq/command/convert.go
+++ b/cmd/gpq/command/convert.go
@@ -16,8 +16,6 @@ package command
 
 import (
 	"bytes"
-	"errors"
-	"fmt"
 	"io"
 	"os"
 	"strings"
@@ -99,36 +97,36 @@ func (c *ConvertCmd) Run() error {
 	outputFormat := parseFormatType(c.To)
 	if outputFormat == AutoType {
 		if outputSource == "" {
-			return fmt.Errorf("when writing to stdout, the --to option must be provided to determine the output format")
+			return NewCommandError("when writing to stdout, the --to option must be provided to determine the output format")
 		}
 		outputFormat = getFormatType(outputSource)
 	}
 	if outputFormat == UnknownType {
-		return fmt.Errorf("could not determine output format for %s", outputSource)
+		return NewCommandError("could not determine output format for %s", outputSource)
 	}
 
 	inputFormat := parseFormatType(c.From)
 	if inputFormat == AutoType {
 		if inputSource == "" {
-			return fmt.Errorf("when reading from stdin, the --from option must be provided to determine the input format")
+			return NewCommandError("when reading from stdin, the --from option must be provided to determine the input format")
 		}
 		inputFormat = getFormatType(inputSource)
 	}
 	if inputFormat == UnknownType {
-		return fmt.Errorf("could not determine input format for %s", inputSource)
+		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 fmt.Errorf("trouble reading from stdin: %w", err)
+			return NewCommandError("trouble reading from stdin: %w", err)
 		}
 		input = bytes.NewReader(data)
 	} else {
 		i, readErr := os.Open(inputSource)
 		if readErr != nil {
-			return fmt.Errorf("failed to read from %q: %w", inputSource, readErr)
+			return NewCommandError("failed to read from %q: %w", inputSource, readErr)
 		}
 		defer i.Close()
 		input = i
@@ -140,7 +138,7 @@ func (c *ConvertCmd) Run() error {
 	} else {
 		o, createErr := os.Create(outputSource)
 		if createErr != nil {
-			return fmt.Errorf("failed to open %q for writing: %w", outputSource, createErr)
+			return NewCommandError("failed to open %q for writing: %w", outputSource, createErr)
 		}
 		defer o.Close()
 		output = o
@@ -148,7 +146,7 @@ func (c *ConvertCmd) Run() error {
 
 	if inputFormat == GeoJSONType {
 		if outputFormat != ParquetType && outputFormat != GeoParquetType {
-			return errors.New("GeoJSON input can only be converted to GeoParquet")
+			return NewCommandError("GeoJSON input can only be converted to GeoParquet")
 		}
 		convertOptions := &geojson.ConvertOptions{
 			MinFeatures:    c.Min,
@@ -156,11 +154,17 @@ func (c *ConvertCmd) Run() error {
 			Compression:    c.Compression,
 			RowGroupLength: c.RowGroupLength,
 		}
-		return geojson.ToParquet(input, output, convertOptions)
+		if err := geojson.ToParquet(input, output, convertOptions); err != nil {
+			return NewCommandError("%w", err)
+		}
+		return nil
 	}
 
 	if outputFormat == GeoJSONType {
-		return geojson.FromParquet(input, output)
+		if err := geojson.FromParquet(input, output); err != nil {
+			return NewCommandError("%w", err)
+		}
+		return nil
 	}
 
 	convertOptions := &geoparquet.ConvertOptions{
@@ -169,5 +173,8 @@ func (c *ConvertCmd) Run() error {
 		RowGroupLength:     c.RowGroupLength,
 	}
 
-	return geoparquet.FromParquet(input, output, convertOptions)
+	if err := geoparquet.FromParquet(input, output, convertOptions); err != nil {
+		return NewCommandError("%w", err)
+	}
+	return nil
 }
diff --git a/cmd/gpq/command/describe.go b/cmd/gpq/command/describe.go
index 3033535..8b80531 100644
--- a/cmd/gpq/command/describe.go
+++ b/cmd/gpq/command/describe.go
@@ -34,9 +34,10 @@ 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"`
-	Format   string `help:"Report format.  Possible values: ${enum}." enum:"text, json" default:"text"`
-	Unpretty bool   `help:"No newlines or indentation in the JSON output."`
+	Input        string `arg:"" optional:"" name:"input" help:"Path to a GeoParquet file.  If not provided, input is read from stdin." type:"existingfile"`
+	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."`
 }
 
 const (
@@ -56,13 +57,13 @@ func (c *DescribeCmd) Run() error {
 	if c.Input == "" {
 		data, err := io.ReadAll(os.Stdin)
 		if err != nil {
-			return fmt.Errorf("trouble reading from stdin: %w", err)
+			return NewCommandError("trouble reading from stdin: %w", err)
 		}
 		input = bytes.NewReader(data)
 	} else {
 		i, readErr := os.Open(c.Input)
 		if readErr != nil {
-			return fmt.Errorf("failed to read from %q: %w", c.Input, readErr)
+			return NewCommandError("failed to read from %q: %w", c.Input, readErr)
 		}
 		defer i.Close()
 		input = i
@@ -72,25 +73,50 @@ func (c *DescribeCmd) Run() error {
 	if fileErr != nil {
 		return fmt.Errorf("failed to read %q as parquet: %w", c.Input, fileErr)
 	}
+	defer fileReader.Close()
+
+	if c.MetadataOnly {
+		value, err := geoparquet.GetMetadataValue(fileReader.MetaData().KeyValueMetadata())
+		if err != nil {
+			if errors.Is(err, geoparquet.ErrNoMetadata) {
+				return NewCommandError("missing %q metadata key", geoparquet.MetadataKey)
+			}
+			return err
+		}
+		fmt.Println(value)
+		return nil
+	}
 
 	fileMetadata := fileReader.MetaData()
+
+	info := &DescribeInfo{
+		Schema:  buildSchema(fileReader, "", fileMetadata.Schema.Root()),
+		NumRows: fileMetadata.NumRows,
+	}
+
 	metadata, geoErr := geoparquet.GetMetadata(fileMetadata.KeyValueMetadata())
 	if geoErr != nil {
 		if !errors.Is(geoErr, geoparquet.ErrNoMetadata) {
-			return geoErr
+			message := fmt.Sprintf("Metadata parsing failed, try running describe with the --metadata-only flag.  Error message: %s", geoErr.Error())
+			info.Issues = append(info.Issues, message)
 		}
+	} else {
+		info.Metadata = metadata
 	}
 
-	info := &DescribeInfo{
-		Schema:   buildSchema(fileReader, "", fileMetadata.Schema.Root()),
-		Metadata: metadata,
-		NumRows:  fileMetadata.NumRows,
+	if c.Format == "json" {
+		err := c.formatJSON(info)
+		if err != nil {
+			return NewCommandError("failed to format report as json: %w", err)
+		}
+		return nil
 	}
 
-	if c.Format == "json" {
-		return c.formatJSON(info)
+	if err := c.formatText(info); err != nil {
+		return NewCommandError("failed to format report: %w", err)
 	}
-	return c.formatText(info)
+
+	return nil
 }
 
 func (c *DescribeCmd) formatText(info *DescribeInfo) error {
@@ -168,26 +194,34 @@ func (c *DescribeCmd) formatText(info *DescribeInfo) error {
 		tbl.AppendRow(row)
 	}
 
-	tbl.AppendFooter(makeFooter("Rows", info.NumRows, header), table.RowConfig{AutoMerge: true})
+	footerConfig := table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignLeft}
+	tbl.AppendFooter(makeFooter("Rows", info.NumRows, header), footerConfig)
 	if metadata != nil {
 		version := metadata.Version
 		if version == "" {
 			version = "missing"
 		}
-		tbl.AppendFooter(makeFooter("Version", version, header), table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignLeft})
+		tbl.AppendFooter(makeFooter("GeoParquet Version", version, header), footerConfig)
 	}
 
-	tbl.SetStyle(table.StyleRounded)
+	style := table.StyleRounded
+	style.Format.Footer = text.FormatDefault
+
+	tbl.SetStyle(style)
 	tbl.SetOutputMirror(out)
 	tbl.Render()
 
+	for _, issue := range info.Issues {
+		fmt.Printf(" ⚠️  %s\n", issue)
+	}
+
 	return nil
 }
 
 func makeFooter(key string, value any, header table.Row) table.Row {
 	row := table.Row{key, value}
 	for i := len(row); i < len(header); i += 1 {
-		row = append(row, "")
+		row = append(row, value)
 	}
 	return row
 }
@@ -209,6 +243,7 @@ type DescribeInfo struct {
 	Schema   *DescribeSchema      `json:"schema"`
 	Metadata *geoparquet.Metadata `json:"metadata"`
 	NumRows  int64                `json:"rows"`
+	Issues   []string             `json:"issues"`
 }
 
 type DescribeSchema struct {
diff --git a/cmd/gpq/command/validate.go b/cmd/gpq/command/validate.go
index 1ab4ed2..ce8f7fd 100644
--- a/cmd/gpq/command/validate.go
+++ b/cmd/gpq/command/validate.go
@@ -40,18 +40,18 @@ func (c *ValidateCmd) Run(ctx *kong.Context) error {
 	var input ReaderAtSeeker
 	if c.Input == "" {
 		if !hasStdin() {
-			return fmt.Errorf("input argument must be provided if there is no stdin data")
+			return NewCommandError("input argument must be provided if there is no stdin data")
 		}
 		data, err := io.ReadAll(os.Stdin)
 		if err != nil {
-			return fmt.Errorf("trouble reading from stdin: %w", err)
+			return NewCommandError("trouble reading from stdin: %w", err)
 		}
 		input = bytes.NewReader(data)
 		inputName = "<stdin>"
 	} else {
 		i, readErr := os.Open(c.Input)
 		if readErr != nil {
-			return fmt.Errorf("failed to read from %q: %w", c.Input, readErr)
+			return NewCommandError("failed to read from %q: %w", c.Input, readErr)
 		}
 		defer i.Close()
 		input = i
@@ -60,7 +60,7 @@ func (c *ValidateCmd) Run(ctx *kong.Context) error {
 	v := validator.New(c.MetadataOnly)
 	report, err := v.Validate(context.Background(), input, inputName)
 	if err != nil {
-		return err
+		return NewCommandError("validation failed: %w", err)
 	}
 
 	valid := true
@@ -73,11 +73,11 @@ func (c *ValidateCmd) Run(ctx *kong.Context) error {
 
 	if c.Format == "json" {
 		if err := c.formatJSON(report); err != nil {
-			return err
+			return NewCommandError("unable to format report as json: %w", err)
 		}
 	} else {
 		if err := c.formatText(report); err != nil {
-			return err
+			return NewCommandError("unable to format report: %w", err)
 		}
 	}
 
diff --git a/cmd/gpq/main.go b/cmd/gpq/main.go
index 51d4b39..343d983 100644
--- a/cmd/gpq/main.go
+++ b/cmd/gpq/main.go
@@ -15,6 +15,8 @@
 package main
 
 import (
+	"errors"
+
 	"github.com/alecthomas/kong"
 	"github.com/planetlabs/gpq/cmd/gpq/command"
 )
@@ -28,5 +30,12 @@ var (
 func main() {
 	ctx := kong.Parse(&command.CLI)
 	err := ctx.Run(ctx, &command.VersionInfo{Version: version, Commit: commit, Date: date})
+	if err == nil {
+		return
+	}
+	var commandError *command.CommandError
+	if errors.As(err, &commandError) {
+		err = commandError
+	}
 	ctx.FatalIfErrorf(err)
 }
diff --git a/go.mod b/go.mod
index 6278112..a1209bf 100644
--- a/go.mod
+++ b/go.mod
@@ -50,4 +50,4 @@ require (
 )
 
 // https://github.com/zeroshade/arrow/tree/arm64-assembly-funtimes
-replace github.com/apache/arrow/go/v14 => github.com/zeroshade/arrow/go/v14 v14.0.0-20230926190308-c9693c5f9b33
+replace github.com/apache/arrow/go/v14 => github.com/zeroshade/arrow/go/v14 v14.0.0-20231002231208-9d4c29b390a2
diff --git a/go.sum b/go.sum
index 866c666..8ddf149 100644
--- a/go.sum
+++ b/go.sum
@@ -97,8 +97,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
 github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
 github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
 github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
-github.com/zeroshade/arrow/go/v14 v14.0.0-20230926190308-c9693c5f9b33 h1:tCieoltG8rGY1pGbN68Ot8kJymxvDfNxyaYBsdrzUdQ=
-github.com/zeroshade/arrow/go/v14 v14.0.0-20230926190308-c9693c5f9b33/go.mod h1:/SqmdO2dsWqFHqQQeupnsr0ollL8C91n3x0I72rArY8=
+github.com/zeroshade/arrow/go/v14 v14.0.0-20231002231208-9d4c29b390a2 h1:vY4TmEPgv0IxuPxPl66uiBxKgr/bcpnBi7nlx57gxY4=
+github.com/zeroshade/arrow/go/v14 v14.0.0-20231002231208-9d4c29b390a2/go.mod h1:/SqmdO2dsWqFHqQQeupnsr0ollL8C91n3x0I72rArY8=
 go.mongodb.org/mongo-driver v1.11.4 h1:4ayjakA013OdpGyL2K3ZqylTac/rMjrJOMZ1EHizXas=
 go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
diff --git a/internal/validator/rules.go b/internal/validator/rules.go
index 60e7883..e716be7 100644
--- a/internal/validator/rules.go
+++ b/internal/validator/rules.go
@@ -119,6 +119,22 @@ func asJSON(value any) string {
 	return string(data)
 }
 
+func jsonType(value any) string {
+	switch value.(type) {
+	case bool:
+		return "boolean"
+	case float64:
+		return "number"
+	case string:
+		return "string"
+	case map[string]any:
+		return "object"
+	case []any:
+		return "list"
+	}
+	return fmt.Sprintf("%T", value)
+}
+
 func RequiredGeoKey() Rule {
 	return &GenericRule[*file.Reader]{
 		title: fmt.Sprintf("file must include a %q metadata key", geoparquet.MetadataKey),
@@ -296,7 +312,7 @@ func OptionalCRS() Rule {
 				}
 				crs, ok := meta["crs"].(map[string]any)
 				if !ok {
-					return fmt.Errorf(`expected "crs" for column %q to be an object, got %s`, name, asJSON(meta["crs"]))
+					return fatal(`expected "crs" for column %q to be an object, got a %s: %s`, name, jsonType(meta["crs"]), asJSON(meta["crs"]))
 				}
 				schemaUrl, ok := crs["$schema"].(string)
 				if !ok {
@@ -333,7 +349,7 @@ func OptionalOrientation() Rule {
 				}
 				orientation, ok := meta["orientation"].(string)
 				if !ok {
-					return fmt.Errorf(`expected "orientation" for column %q to be a string, got %s`, name, asJSON(meta["orientation"]))
+					return fatal(`expected "orientation" for column %q to be a string, got a %s: %s`, name, jsonType(meta["orientation"]), asJSON(meta["orientation"]))
 				}
 				if orientation != geoparquet.OrientationCounterClockwise {
 					return fmt.Errorf(`unsupported orientation %q for column %q, expected %q`, orientation, name, geoparquet.OrientationCounterClockwise)
@@ -355,7 +371,7 @@ func OptionalEdges() Rule {
 				}
 				edges, ok := meta["edges"].(string)
 				if !ok {
-					return fmt.Errorf(`expected "edges" for column %q to be a string, got %s`, name, asJSON(meta["edges"]))
+					return fatal(`expected "edges" for column %q to be a string, got a %s: %s`, name, jsonType(meta["edges"]), asJSON(meta["edges"]))
 				}
 				if edges != geoparquet.EdgesPlanar && edges != geoparquet.EdgesSpherical {
 					return fmt.Errorf(`unsupported edges %q for column %q, expected %q or %q`, edges, name, geoparquet.EdgesPlanar, geoparquet.EdgesSpherical)
@@ -377,7 +393,7 @@ func OptionalBbox() Rule {
 				}
 				bbox, ok := meta["bbox"].([]any)
 				if !ok {
-					return fmt.Errorf(`expected "bbox" for column %q to be a list, got %s`, name, asJSON(meta["bbox"]))
+					return fatal(`expected "bbox" for column %q to be a list, got a %s: %s`, name, jsonType(meta["bbox"]), asJSON(meta["bbox"]))
 				}
 				if len(bbox) != 4 && len(bbox) != 6 {
 					return fmt.Errorf(`expected "bbox" for column %q to be a list of 4 or 6 numbers, got %s`, name, asJSON(bbox))
@@ -405,7 +421,7 @@ func OptionalEpoch() Rule {
 				}
 				_, ok = meta["epoch"].(float64)
 				if !ok {
-					return fatal(`expected "epoch" for column %q to be a number, got %s`, name, asJSON(meta["epoch"]))
+					return fatal(`expected "epoch" for column %q to be a number, got a %s: %s`, name, jsonType(meta["epoch"]), asJSON(meta["epoch"]))
 				}
 			}
 			return nil


Test patch:
diff --git a/internal/validator/testdata/bad-bbox-item-type/expected.json b/internal/validator/testdata/bad-bbox-item-type/expected.json
new file mode 100644
index 0000000..6de45a3
--- /dev/null
+++ b/internal/validator/testdata/bad-bbox-item-type/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "run": true,
+      "passed": false,
+      "message": "expected \"bbox\" for column \"geometry\" to be a list of numbers, got [\"not\",\"a\",\"bounding\",\"box\"]"
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "run": false,
+      "passed": false
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-bbox-item-type/input.json b/internal/validator/testdata/bad-bbox-item-type/input.json
new file mode 100644
index 0000000..49216ea
--- /dev/null
+++ b/internal/validator/testdata/bad-bbox-item-type/input.json
@@ -0,0 +1,28 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "bbox": ["not", "a", "bounding", "box"]
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [0, 0]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-bbox-type/expected.json b/internal/validator/testdata/bad-bbox-type/expected.json
index 6de45a3..8a0710c 100644
--- a/internal/validator/testdata/bad-bbox-type/expected.json
+++ b/internal/validator/testdata/bad-bbox-type/expected.json
@@ -47,59 +47,59 @@
     },
     {
       "title": "optional \"orientation\" must be a valid string",
-      "run": true,
-      "passed": true
+      "passed": true,
+      "run": true
     },
     {
       "title": "optional \"edges\" must be a valid string",
-      "run": true,
-      "passed": true
+      "passed": true,
+      "run": true
     },
     {
       "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
-      "run": true,
       "passed": false,
-      "message": "expected \"bbox\" for column \"geometry\" to be a list of numbers, got [\"not\",\"a\",\"bounding\",\"box\"]"
+      "run": true,
+      "message": "expected \"bbox\" for column \"geometry\" to be a list, got a string: \"bogus\""
     },
     {
       "title": "optional \"epoch\" must be a number",
-      "run": false,
-      "passed": false
+      "passed": false,
+      "run": false
     },
     {
       "title": "geometry columns must not be grouped",
-      "run": false,
-      "passed": false
+      "passed": false,
+      "run": false
     },
     {
       "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
-      "run": false,
-      "passed": false
+      "passed": false,
+      "run": false
     },
     {
       "title": "geometry columns must be required or optional, not repeated",
-      "run": false,
-      "passed": false
+      "passed": false,
+      "run": false
     },
     {
       "title": "all geometry values match the \"encoding\" metadata",
-      "run": false,
-      "passed": false
+      "passed": false,
+      "run": false
     },
     {
       "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
-      "run": false,
-      "passed": false
+      "passed": false,
+      "run": false
     },
     {
       "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
-      "run": false,
-      "passed": false
+      "passed": false,
+      "run": false
     },
     {
       "title": "all geometries must fall within the \"bbox\" metadata (if present)",
-      "run": false,
-      "passed": false
+      "passed": false,
+      "run": false
     }
   ],
   "metadataOnly": false
diff --git a/internal/validator/testdata/bad-bbox-type/input.json b/internal/validator/testdata/bad-bbox-type/input.json
index 49216ea..ee1d63a 100644
--- a/internal/validator/testdata/bad-bbox-type/input.json
+++ b/internal/validator/testdata/bad-bbox-type/input.json
@@ -6,7 +6,7 @@
       "geometry": {
         "encoding": "WKB",
         "geometry_types": [],
-        "bbox": ["not", "a", "bounding", "box"]
+        "bbox": "bogus"
       }
     }
   },
diff --git a/internal/validator/testdata/bad-crs-type/expected.json b/internal/validator/testdata/bad-crs-type/expected.json
new file mode 100644
index 0000000..181474a
--- /dev/null
+++ b/internal/validator/testdata/bad-crs-type/expected.json
@@ -0,0 +1,106 @@
+{
+  "checks": [
+    {
+      "title": "file must include a \"geo\" metadata key",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must be a JSON object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"version\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"primary_column\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "metadata must include a \"columns\" object",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include the \"primary_column\" name",
+      "run": false,
+      "passed": false
+    },
+    {
+      "title": "column metadata must include a valid \"encoding\" string",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "column metadata must include a \"geometry_types\" list",
+      "run": true,
+      "passed": true
+    },
+    {
+      "title": "optional \"crs\" must be null or a PROJJSON object",
+      "run": true,
+      "passed": false,
+      "message": "expected \"crs\" for column \"geometry\" to be an object, got a string: \"bogus\""
+    },
+    {
+      "title": "optional \"orientation\" must be a valid string",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "optional \"edges\" must be a valid string",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "optional \"bbox\" must be an array of 4 or 6 numbers",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "optional \"epoch\" must be a number",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "geometry columns must not be grouped",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "geometry columns must be stored using the BYTE_ARRAY parquet type",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "geometry columns must be required or optional, not repeated",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "all geometry values match the \"encoding\" metadata",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "all geometry types must be included in the \"geometry_types\" metadata (if not empty)",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "all polygon geometries must follow the \"orientation\" metadata (if present)",
+      "passed": false,
+      "run": false
+    },
+    {
+      "title": "all geometries must fall within the \"bbox\" metadata (if present)",
+      "passed": false,
+      "run": false
+    }
+  ],
+  "metadataOnly": false
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-crs-type/input.json b/internal/validator/testdata/bad-crs-type/input.json
new file mode 100644
index 0000000..c57b63c
--- /dev/null
+++ b/internal/validator/testdata/bad-crs-type/input.json
@@ -0,0 +1,31 @@
+{
+  "metadata": {
+    "version": "1.0.0-beta.1",
+    "primary_column": "geometry",
+    "columns": {
+      "geometry": {
+        "encoding": "WKB",
+        "geometry_types": [],
+        "crs": "bogus"
+      }
+    }
+  },
+  "data": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "properties": {
+          "name": "Null Island"
+        },
+        "geometry": {
+          "type": "Point",
+          "coordinates": [
+            0,
+            0
+          ]
+        }
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/internal/validator/testdata/bad-epoch/expected.json b/internal/validator/testdata/bad-epoch/expected.json
index 4c7dd30..3310a90 100644
--- a/internal/validator/testdata/bad-epoch/expected.json
+++ b/internal/validator/testdata/bad-epoch/expected.json
@@ -64,7 +64,7 @@
       "title": "optional \"epoch\" must be a number",
       "run": true,
       "passed": false,
-      "message": "expected \"epoch\" for column \"geometry\" to be a number, got \"bogus\""
+      "message": "expected \"epoch\" for column \"geometry\" to be a number, got a string: \"bogus\""
     },
     {
       "title": "geometry columns must not be grouped",
diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go
index de9a620..adffb37 100644
--- a/internal/validator/validator_test.go
+++ b/internal/validator/validator_test.go
@@ -334,9 +334,11 @@ func (s *Suite) TestReport() {
 		"missing-geometry-types",
 		"bad-geometry-types",
 		"bad-crs",
+		"bad-crs-type",
 		"bad-orientation",
 		"bad-edges",
 		"bad-bbox-type",
+		"bad-bbox-item-type",
 		"bad-bbox-length",
 		"bad-epoch",
 		"geometry-type-not-in-list",