Check declared schema locations for external resources
This commit is contained in:
@@ -350,6 +350,11 @@ anchor references, explicit/embedded IDs, recursive/dynamic references, escaped
|
|||||||
keys, and embedded-resource dialect distinctions. Keep independent valid/invalid
|
keys, and embedded-resource dialect distinctions. Keep independent valid/invalid
|
||||||
instances; do not manufacture expectations with the traversal under test.
|
instances; do not manufacture expectations with the traversal under test.
|
||||||
|
|
||||||
|
Completion: declared schema locations are compiled using the pinned compiler's
|
||||||
|
dialect-resolved schema positions and a shared cache. Focused validation tests
|
||||||
|
cover unused external references, annotations, escaped pointers, anchors,
|
||||||
|
recursive/dynamic references, embedded dialects, and independent instances.
|
||||||
|
|
||||||
## Stage 13: Verify Public Schema Wiring And Native Submission
|
## Stage 13: Verify Public Schema Wiring And Native Submission
|
||||||
|
|
||||||
**Catalog:** 6, 15, 21; schema-file half of 32.
|
**Catalog:** 6, 15, 21; schema-file half of 32.
|
||||||
|
|||||||
91
internal/validate/schema_locations.go
Normal file
91
internal/validate/schema_locations.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These schema-bearing positions mirror the inherited subschema tables in
|
||||||
|
// jsonschema/v6 v6.0.2 draft.go. Annotation objects are deliberately excluded.
|
||||||
|
func checkSchemaLocations(ctx context.Context, compiler *jsonschema.Compiler, resourceURL string, document any, pointer string, compiled *jsonschema.Schema) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
object, ok := document.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
version := compiled.DraftVersion
|
||||||
|
mapKeys := []string{"definitions", "properties", "patternProperties", "dependencies"}
|
||||||
|
singleKeys := []string{"not", "additionalProperties", "items", "additionalItems"}
|
||||||
|
arrayKeys := []string{"allOf", "anyOf", "oneOf", "items"}
|
||||||
|
if version >= 6 {
|
||||||
|
singleKeys = append(singleKeys, "propertyNames", "contains")
|
||||||
|
}
|
||||||
|
if version >= 7 {
|
||||||
|
singleKeys = append(singleKeys, "if", "then", "else")
|
||||||
|
}
|
||||||
|
if version >= 2019 {
|
||||||
|
mapKeys = append(mapKeys, "$defs", "dependentSchemas")
|
||||||
|
singleKeys = append(singleKeys, "unevaluatedProperties", "unevaluatedItems", "contentSchema")
|
||||||
|
}
|
||||||
|
if version >= 2020 {
|
||||||
|
arrayKeys = append(arrayKeys, "prefixItems")
|
||||||
|
}
|
||||||
|
visit := func(value any, segments ...string) error {
|
||||||
|
switch value.(type) {
|
||||||
|
case map[string]any, bool:
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
childPointer := pointer
|
||||||
|
for _, segment := range segments {
|
||||||
|
childPointer += "/" + strings.ReplaceAll(strings.ReplaceAll(segment, "~", "~0"), "/", "~1")
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(resourceURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed.Fragment = childPointer
|
||||||
|
child, err := compileJSONSchema(ctx, parsed.String(), compiler.Compile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compile declared schema %q: %w", childPointer, err)
|
||||||
|
}
|
||||||
|
return checkSchemaLocations(ctx, compiler, resourceURL, value, childPointer, child)
|
||||||
|
}
|
||||||
|
for _, key := range mapKeys {
|
||||||
|
entries, ok := object[key].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for name, value := range entries {
|
||||||
|
if err := visit(value, key, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range singleKeys {
|
||||||
|
if value, ok := object[key]; ok {
|
||||||
|
if err := visit(value, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range arrayKeys {
|
||||||
|
entries, ok := object[key].([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, value := range entries {
|
||||||
|
if err := visit(value, key, strconv.Itoa(i)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
60
internal/validate/schema_locations_test.go
Normal file
60
internal/validate/schema_locations_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSchemaLocationsCheckUnusedDeclarations(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, schema string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{"unused external", `{"$defs":{"unused":{"$ref":"other.json"}},"type":"string"}`, true},
|
||||||
|
{"annotation is data", `{"examples":[{"$ref":"other.json"}],"default":{"$ref":"other.json"},"type":"string"}`, false},
|
||||||
|
{"internal escaped key", `{"$defs":{"a/b~c":{"type":"string"}},"$ref":"#/$defs/a~1b~0c"}`, false},
|
||||||
|
{"URI escaped key", `{"$defs":{"a # %/雪":{"type":"string"}}}`, false},
|
||||||
|
{"internal anchor", `{"$defs":{"value":{"$anchor":"item","type":"string"}},"$ref":"#item"}`, false},
|
||||||
|
{"dynamic reference", `{"$dynamicAnchor":"node","type":"object","properties":{"child":{"$dynamicRef":"#node"}}}`, false},
|
||||||
|
{"recursive", `{"$id":"https://example.invalid/root","$defs":{"node":{"$anchor":"node","anyOf":[{"type":"null"},{"$ref":"#node"}]}},"$ref":"#/$defs/node"}`, false},
|
||||||
|
{"embedded old dialect annotation", `{"$defs":{"old":{"$id":"old","$schema":"http://json-schema.org/draft-07/schema#","$defs":{"unused":{"$ref":"other.json"}},"type":"string"}}}`, false},
|
||||||
|
{"embedded old dialect definition", `{"$defs":{"old":{"$id":"old","$schema":"http://json-schema.org/draft-07/schema#","definitions":{"unused":{"$ref":"other.json"}}}}}`, true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
doc, err := decodeJSONValue(context.Background(), []byte(test.schema))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = compileSchemaDocument(context.Background(), "promptkit-schema:///root.json", doc)
|
||||||
|
if test.wantError && (err == nil || !strings.Contains(err.Error(), "external schema resource")) {
|
||||||
|
t.Fatalf("expected external resource error, got %v", err)
|
||||||
|
}
|
||||||
|
if !test.wantError && err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaLocationsPreserveRootValidation(t *testing.T) {
|
||||||
|
doc, err := decodeJSONValue(context.Background(), []byte(`{"$defs":{"number":{"type":"integer","minimum":2}},"$ref":"#/$defs/number"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
schema, err := compileSchemaDocument(context.Background(), "promptkit-schema:///root.json", doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := schema.Validate(3); err != nil {
|
||||||
|
t.Fatalf("valid instance: %v", err)
|
||||||
|
}
|
||||||
|
if err := schema.Validate("not a number"); err == nil {
|
||||||
|
t.Fatal("invalid instance was accepted")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := compileSchemaDocument(ctx, "promptkit-schema:///root.json", doc); err != context.Canceled {
|
||||||
|
t.Fatalf("canceled compilation = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -436,7 +436,14 @@ func compileSchemaDocument(ctx context.Context, resourceURL string, document any
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("register schema: %w", err)
|
return nil, fmt.Errorf("register schema: %w", err)
|
||||||
}
|
}
|
||||||
return compileJSONSchema(ctx, resourceURL, compiler.Compile)
|
root, err := compileJSONSchema(ctx, resourceURL, compiler.Compile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := checkSchemaLocations(ctx, compiler, resourceURL, document, "", root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type schemaExecutor interface {
|
type schemaExecutor interface {
|
||||||
|
|||||||
Reference in New Issue
Block a user