diff --git a/.gts-spec b/.gts-spec index 5e17b3a..56ca20d 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 5e17b3ac52ae8754f5cd4344608a1906359ef393 +Subproject commit 56ca20d89df2c2d8e70773da33482e4c748c446d diff --git a/README.md b/README.md index 858c493..93bfdd9 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ if result.Valid { ```go // Extract GTS ID from JSON content content := map[string]any{ - "gtsId": "gts.vendor.pkg.ns.type.v1.0", + "id": "gts.vendor.pkg.ns.type.v1.0", "name": "My Entity", } @@ -131,7 +131,7 @@ store := gts.NewGtsStore(nil) // Register an entity entity := gts.NewJsonEntity(map[string]any{ - "gtsId": "gts.vendor.pkg.ns.type.v1.0", + "id": "gts.vendor.pkg.ns.type.v1.0", "name": "My Entity", }, gts.DefaultGtsConfig()) diff --git a/cmd/gts/main.go b/cmd/gts/main.go index 8dd3813..164d128 100644 --- a/cmd/gts/main.go +++ b/cmd/gts/main.go @@ -24,7 +24,7 @@ The commands are: validate-id validate a GTS ID format parse-id parse a GTS ID into its components - match-id match a GTS ID against a pattern + match-id-pattern match a GTS ID against a pattern uuid generate UUID from a GTS ID validate validate an instance against its schema relationships resolve relationships for an entity @@ -80,7 +80,7 @@ func (c *Command) Runnable() bool { var commands = []*Command{ cmdValidateID, cmdParseID, - cmdMatchID, + cmdMatchIDPattern, cmdUUID, cmdValidate, cmdRelationships, diff --git a/cmd/gts/match_id.go b/cmd/gts/match_id.go index 02fdf4b..5b88da5 100644 --- a/cmd/gts/match_id.go +++ b/cmd/gts/match_id.go @@ -9,18 +9,18 @@ import ( "github.com/GlobalTypeSystem/gts-go/gts" ) -var cmdMatchID = &Command{ - UsageLine: "match-id -pattern -candidate ", +var cmdMatchIDPattern = &Command{ + UsageLine: "match-id-pattern -pattern -candidate ", Short: "match a GTS ID against a pattern", Long: ` -Match-id checks whether a GTS identifier matches a pattern. +Match-id-pattern checks whether a GTS identifier matches a pattern. The -pattern flag specifies the pattern (may contain wildcards). The -candidate flag specifies the GTS ID to match. Example: - gts match-id -pattern "gts.vendor.pkg.*" -candidate gts.vendor.pkg.ns.type.v1.0 + gts match-id-pattern -pattern "gts.vendor.pkg.*" -candidate gts.vendor.pkg.ns.type.v1.0 `, } @@ -30,12 +30,12 @@ var ( ) func init() { - cmdMatchID.Run = runMatchID - cmdMatchID.Flag.StringVar(&matchPattern, "pattern", "", "pattern to match against") - cmdMatchID.Flag.StringVar(&matchCandidate, "candidate", "", "candidate GTS ID") + cmdMatchIDPattern.Run = runMatchIDPattern + cmdMatchIDPattern.Flag.StringVar(&matchPattern, "pattern", "", "pattern to match against") + cmdMatchIDPattern.Flag.StringVar(&matchCandidate, "candidate", "", "candidate GTS ID") } -func runMatchID(cmd *Command, args []string) { +func runMatchIDPattern(cmd *Command, args []string) { if matchPattern == "" || matchCandidate == "" { cmd.Usage() } diff --git a/gts/cast_test.go b/gts/cast_test.go index 11de9d5..1517a37 100644 --- a/gts/cast_test.go +++ b/gts/cast_test.go @@ -14,7 +14,7 @@ func TestCast_MinorVersionUpcast(t *testing.T) { // Register base event schema baseSchema := map[string]any{ - "$id": "gts.x.core.events.type.v1~", + "$id": "gts://gts.x.core.events.type.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": []any{"id", "type", "tenantId", "occurredAt"}, @@ -110,6 +110,7 @@ func TestCast_MinorVersionUpcast(t *testing.T) { // Register v1.0 instance v10Instance := map[string]any{ + "gtsId": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~x.vendor._.inst.v1.0", "type": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~", "id": "af0e3c1b-8f1e-4a27-9a9b-b7b9b70c1f01", "tenantId": "11111111-2222-3333-4444-555555555555", @@ -135,7 +136,7 @@ func TestCast_MinorVersionUpcast(t *testing.T) { // Cast from v1.0 to v1.1 result, err := store.Cast( - "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~", + "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~x.vendor._.inst.v1.0", "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~", ) @@ -265,6 +266,7 @@ func TestCast_MinorVersionDowncast(t *testing.T) { // Register v1.1 instance v11Instance := map[string]any{ + "gtsId": "gts.x.core.events.type.v1~x.test9.cast.event.v1.1~x.vendor._.inst.v1.0", "type": "gts.x.core.events.type.v1~x.test9.cast.event.v1.1~", "id": "8b2e3f45-6789-50bc-0123-bcdef234567", "tenantId": "22222222-3333-4444-5555-666666666666", @@ -281,7 +283,7 @@ func TestCast_MinorVersionDowncast(t *testing.T) { // Cast from v1.1 to v1.0 (downcast) result, err := store.Cast( - "gts.x.core.events.type.v1~x.test9.cast.event.v1.1~", + "gts.x.core.events.type.v1~x.test9.cast.event.v1.1~x.vendor._.inst.v1.0", "gts.x.core.events.type.v1~x.test9.cast.event.v1.0~", ) @@ -373,9 +375,8 @@ func TestCast_NestedObjects(t *testing.T) { // Register v1.0 instance v10Instance := map[string]any{ - "gtsId": "gts.x.core.nested.type.v1.0", - "$schema": "gts.x.core.nested.type.v1.0~", - "id": "test-123", + "gtsId": "gts.x.core.nested.type.v1.0~a.b.c.d.v1", + "id": "test-123", "details": map[string]any{ "name": "John", }, @@ -386,7 +387,7 @@ func TestCast_NestedObjects(t *testing.T) { } // Cast from v1.0 to v1.1 - result, err := store.Cast("gts.x.core.nested.type.v1.0", "gts.x.core.nested.type.v1.1~") + result, err := store.Cast("gts.x.core.nested.type.v1.0~a.b.c.d.v1", "gts.x.core.nested.type.v1.1~") if err != nil { t.Fatalf("Cast failed: %v", err) @@ -478,8 +479,8 @@ func TestCast_ArrayOfObjects(t *testing.T) { // Register v1.0 instance with array v10Instance := map[string]any{ - "gtsId": "gts.x.core.array.type.v1.0", - "$schema": "gts.x.core.array.type.v1.0~", + "gtsId": "gts.x.core.array.type.v1.0~a.b.c.d.v1.0", + "type": "gts.x.core.array.type.v1.0~", "items": []any{ map[string]any{"id": "item1"}, map[string]any{"id": "item2"}, @@ -491,7 +492,7 @@ func TestCast_ArrayOfObjects(t *testing.T) { } // Cast from v1.0 to v1.1 - result, err := store.Cast("gts.x.core.array.type.v1.0", "gts.x.core.array.type.v1.1~") + result, err := store.Cast("gts.x.core.array.type.v1.0~a.b.c.d.v1.0", "gts.x.core.array.type.v1.1~") if err != nil { t.Fatalf("Cast failed: %v", err) @@ -524,7 +525,7 @@ func TestCast_ArrayOfObjects(t *testing.T) { func TestCast_InstanceNotFound(t *testing.T) { store := NewGtsStore(nil) - _, err := store.Cast("gts.x.nonexistent.instance.v1.0", "gts.x.nonexistent.schema.v1.1~") + _, err := store.Cast("gts.x.nonexistent.type.v1~a.b.c.d.v1.0", "gts.x.nonexistent.schema.v1.1~") if err == nil { t.Error("Expected error for non-existent instance") @@ -536,7 +537,7 @@ func TestCast_SchemaNotFound(t *testing.T) { // Register schema first schema := map[string]any{ - "$id": "gts.x.core.test.type.v1.0~", + "$id": "gts://gts.x.core.test.type.v1.0~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": []any{"id"}, @@ -551,15 +552,16 @@ func TestCast_SchemaNotFound(t *testing.T) { // Register instance with schema instance := map[string]any{ - "$schema": "gts.x.core.test.type.v1.0~", - "id": "test-123", + "gtsId": "gts.x.core.test.type.v1.0~a.b.c.d.v1.0", + "type": "gts.x.core.test.type.v1.0~", + "id": "test-123", } instanceEntity := NewJsonEntity(instance, DefaultGtsConfig()) if err := store.Register(instanceEntity); err != nil { t.Fatalf("Failed to register instance: %v", err) } - _, err := store.Cast("gts.x.core.test.type.v1.0~", "gts.x.nonexistent.schema.v1.1~") + _, err := store.Cast("gts.x.core.test.type.v1.0~a.b.c.d.v1.0", "gts.x.nonexistent.schema.v1.1~") if err == nil { t.Error("Expected error for non-existent target schema") @@ -571,7 +573,7 @@ func TestCast_MissingRequiredFieldNoDefault(t *testing.T) { // Register v1.0 schema v10Schema := map[string]any{ - "$id": "gts.x.core.required.type.v1.0~", + "$id": "gts://gts.x.core.required.type.v1.0~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": []any{"id"}, @@ -586,7 +588,7 @@ func TestCast_MissingRequiredFieldNoDefault(t *testing.T) { // Register v1.1 schema with new required field WITHOUT default v11Schema := map[string]any{ - "$id": "gts.x.core.required.type.v1.1~", + "$id": "gts://gts.x.core.required.type.v1.1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": []any{"id", "newRequired"}, @@ -602,9 +604,9 @@ func TestCast_MissingRequiredFieldNoDefault(t *testing.T) { // Register v1.0 instance v10Instance := map[string]any{ - "gtsId": "gts.x.core.required.type.v1.0", - "$schema": "gts.x.core.required.type.v1.0~", - "id": "test-123", + "gtsId": "gts.x.core.required.type.v1.0~a.b.c.d.v1.0", + "type": "gts.x.core.required.type.v1.0~", + "id": "test-123", } v10InstanceEntity := NewJsonEntity(v10Instance, DefaultGtsConfig()) if err := store.Register(v10InstanceEntity); err != nil { @@ -612,7 +614,7 @@ func TestCast_MissingRequiredFieldNoDefault(t *testing.T) { } // Cast from v1.0 to v1.1 should fail - result, err := store.Cast("gts.x.core.required.type.v1.0", "gts.x.core.required.type.v1.1~") + result, err := store.Cast("gts.x.core.required.type.v1.0~a.b.c.d.v1.0", "gts.x.core.required.type.v1.1~") if err != nil { t.Fatalf("Cast should not error at top level: %v", err) diff --git a/gts/config.go b/gts/config.go index 7962f42..7f3a2c3 100644 --- a/gts/config.go +++ b/gts/config.go @@ -16,7 +16,6 @@ func DefaultGtsConfig() *GtsConfig { return &GtsConfig{ EntityIDFields: []string{ "$id", - "$$id", "gtsId", "gtsIid", "gtsOid", @@ -27,12 +26,12 @@ func DefaultGtsConfig() *GtsConfig { "id", }, SchemaIDFields: []string{ - "$schema", - "$$schema", "gtsTid", + "gtsType", "gtsT", "gts_t", "gts_tid", + "gts_type", "type", "schema", }, diff --git a/gts/extract.go b/gts/extract.go index b8643a5..026afe1 100644 --- a/gts/extract.go +++ b/gts/extract.go @@ -34,7 +34,7 @@ type JsonEntity struct { // ExtractIDResult holds the result of extracting ID information from JSON content type ExtractIDResult struct { ID string `json:"id"` - SchemaID string `json:"schema_id"` + SchemaID *string `json:"schema_id"` SelectedEntityField *string `json:"selected_entity_field"` SelectedSchemaIDField *string `json:"selected_schema_id_field"` IsSchema bool `json:"is_schema"` @@ -64,17 +64,28 @@ func NewJsonEntityWithFile(content map[string]any, cfg *GtsConfig, file *JsonFil // Extract schema ID entity.SchemaID = entity.calcJSONSchemaID(cfg, entityIDValue) - // If no valid GTS ID found in entity fields, use schema ID as fallback - if entityIDValue == "" || !IsValidGtsID(entityIDValue) { - if entity.SchemaID != "" && IsValidGtsID(entity.SchemaID) { - entityIDValue = entity.SchemaID + // ID extraction logic based on entity type + if entity.IsSchema { + // For schemas: use entity ID (should be from $id field) + if entityIDValue != "" && IsValidGtsID(entityIDValue) { + gtsID, _ := NewGtsID(entityIDValue) + entity.GtsID = gtsID + } + } else { + // For instances: different logic based on well-known vs anonymous + if entityIDValue != "" && IsValidGtsID(entityIDValue) { + // Well-known instance: GTS ID in id field + gtsID, _ := NewGtsID(entityIDValue) + entity.GtsID = gtsID + // Schema ID should be derived from the chain if not explicitly set + if entity.SchemaID == "" && entity.SelectedEntityField != "" { + entity.SchemaID = entity.calcJSONSchemaID(cfg, entityIDValue) + } + } else { + // Anonymous instance: non-GTS ID in id field, GTS type in type field + // GtsID remains nil for anonymous instances + // entity.SchemaID should be set from type field } - } - - // Create GtsID if valid - if entityIDValue != "" && IsValidGtsID(entityIDValue) { - gtsID, _ := NewGtsID(entityIDValue) - entity.GtsID = gtsID } // Extract GTS references from content @@ -100,85 +111,20 @@ func (e *JsonEntity) setLabel() { } // isJSONSchema checks if the content represents a JSON Schema +// A JSON document is a schema if and only if it has a $schema field func isJSONSchema(content map[string]any) bool { if content == nil { return false } - schemaURL, ok := content["$schema"] - if !ok { - schemaURL, ok = content["$$schema"] - if !ok { - return false - } - } - - schemaStr, ok := schemaURL.(string) - if !ok { - return false - } - - // Check if this is a JSON Schema meta-schema reference - if strings.HasPrefix(schemaStr, "http://json-schema.org/") || - strings.HasPrefix(schemaStr, "https://json-schema.org/") { - return true - } - - // Special GTS schema protocol - if strings.HasPrefix(schemaStr, "gts://") { - return true + // Schema Detection: a JSON document is a schema if and only if it has a $schema field + _, hasSchema := content["$schema"] + if !hasSchema { + // Try alternative field name + _, hasSchema = content["$$schema"] } - // If $schema points to a GTS type ID, determine if this is a schema or instance - if strings.HasPrefix(schemaStr, "gts.") { - // Check for entity ID fields that might indicate this is a schema - entityIDFields := []string{"$id", "gtsId", "gtsIid", "gtsOid", "gtsI", "gts_id", "gts_oid", "gts_iid", "id"} - - for _, field := range entityIDFields { - if idVal, hasID := content[field]; hasID { - if idStr, ok := idVal.(string); ok && strings.HasSuffix(idStr, "~") { - // Entity ID ends with ~ - this is definitely a schema - return true - } - } - } - - // No entity ID field ending with ~ - // Check if this could be a schema without explicit entity ID based on content - if strings.HasSuffix(schemaStr, "~") { - // Additional heuristic: if it has schema-like properties, consider it a schema - if _, hasType := content["type"]; hasType { - if _, hasProps := content["properties"]; hasProps { - return true - } - if _, hasItems := content["items"]; hasItems { - return true - } - if _, hasEnum := content["enum"]; hasEnum { - return true - } - } - - // Check if it has NO entity ID fields at all (pure schema) - hasEntityID := false - for _, field := range entityIDFields { - if _, exists := content[field]; exists { - hasEntityID = true - break - } - } - - if !hasEntityID { - // No entity ID and $schema ends with ~ - likely a schema definition - return true - } - } - - // Has entity ID but doesn't end with ~, or has other characteristics of an instance - return false - } - - return false + return hasSchema } // getFieldValue retrieves a string value from content field @@ -242,27 +188,46 @@ func (e *JsonEntity) calcJSONEntityID(cfg *GtsConfig) string { // calcJSONSchemaID extracts the schema ID from JSON content func (e *JsonEntity) calcJSONSchemaID(cfg *GtsConfig, entityIDValue string) string { - field, value := e.firstNonEmptyField(cfg.SchemaIDFields) - if value != "" { - e.SelectedSchemaIDField = field - return value + if e.IsSchema { + // For derived schemas, derive parent type from chain + if entityIDValue != "" && IsValidGtsID(entityIDValue) && strings.HasSuffix(entityIDValue, "~") { + firstTilde := strings.Index(entityIDValue, "~") + if firstTilde > 0 { + secondTilde := strings.Index(entityIDValue[firstTilde+1:], "~") + if secondTilde > 0 { + // This is a derived schema, derive parent from chain + e.SelectedSchemaIDField = e.SelectedEntityField + return entityIDValue[:firstTilde+1] + } + } + } + + // For base schemas: get schema ID from $schema field + if schemaValue := e.getFieldValue("$schema"); schemaValue != "" { + e.SelectedSchemaIDField = "$schema" + return schemaValue + } + return "" } - // If no schema ID field found, try to derive from entity ID + // For instances: try entity ID chain first, then SchemaIDFields if entityIDValue != "" && IsValidGtsID(entityIDValue) { - // If entity ID ends with ~, it's already a type ID - if strings.HasSuffix(entityIDValue, "~") { - // Don't set SelectedSchemaIDField - the entity ID itself is a type - return entityIDValue + // For instances, find last ~ and return everything up to and including it + // But skip if entity ID ends with ~ (that would be a type, not an instance) + if !strings.HasSuffix(entityIDValue, "~") { + lastTilde := strings.LastIndex(entityIDValue, "~") + if lastTilde > 0 { + e.SelectedSchemaIDField = e.SelectedEntityField + return entityIDValue[:lastTilde+1] + } } + } - // Find last ~ and return everything up to and including it - lastTilde := strings.LastIndex(entityIDValue, "~") - if lastTilde > 0 { - // Set SelectedSchemaIDField to the entity field since we extracted from it - e.SelectedSchemaIDField = e.SelectedEntityField - return entityIDValue[:lastTilde+1] - } + // If no entity ID found, use SchemaIDFields to find schema reference + field, value := e.firstNonEmptyField(cfg.SchemaIDFields) + if value != "" { + e.SelectedSchemaIDField = field + return value } return "" @@ -273,10 +238,14 @@ func ExtractID(content map[string]any, cfg *GtsConfig) *ExtractIDResult { entity := NewJsonEntity(content, cfg) result := &ExtractIDResult{ - SchemaID: entity.SchemaID, IsSchema: entity.IsSchema, } + // Set SchemaID as pointer (nil if empty) + if entity.SchemaID != "" { + result.SchemaID = &entity.SchemaID + } + // Set SelectedEntityField as pointer (nil if empty) if entity.SelectedEntityField != "" { result.SelectedEntityField = &entity.SelectedEntityField @@ -287,8 +256,21 @@ func ExtractID(content map[string]any, cfg *GtsConfig) *ExtractIDResult { result.SelectedSchemaIDField = &entity.SelectedSchemaIDField } - if entity.GtsID != nil { - result.ID = entity.GtsID.ID + // Return effective_id() based on entity type + if entity.IsSchema || (entity.GtsID != nil) { + // For schemas and well-known instances: return GTS ID + if entity.GtsID != nil { + result.ID = entity.GtsID.ID + } + } else { + // For anonymous instances: return instance_id (UUID or non-GTS value from id field) + if entity.SelectedEntityField != "" { + if val, ok := content[entity.SelectedEntityField]; ok { + if strVal, ok := val.(string); ok { + result.ID = strVal + } + } + } } return result diff --git a/gts/extract_test.go b/gts/extract_test.go index d29961a..4d9f216 100644 --- a/gts/extract_test.go +++ b/gts/extract_test.go @@ -20,19 +20,19 @@ func TestExtractID_BasicEntityID(t *testing.T) { { name: "Extract from gtsId field", content: map[string]any{ - "gtsId": "gts.vendor.package.namespace.type.v0", + "gtsId": "gts.vendor.package.namespace.type.v0~a.b.c.d.v1", "name": "Test Entity", }, - expectedID: "gts.vendor.package.namespace.type.v0", + expectedID: "gts.vendor.package.namespace.type.v0~a.b.c.d.v1", expectedField: "gtsId", }, { name: "Extract from $id field", content: map[string]any{ - "$id": "gts.vendor.package.namespace.type.v1", + "$id": "gts.vendor.package.namespace.type.v1~a.b.c.d.v1", "name": "Test Entity", }, - expectedID: "gts.vendor.package.namespace.type.v1", + expectedID: "gts.vendor.package.namespace.type.v1~a.b.c.d.v1", expectedField: "$id", }, { @@ -101,18 +101,22 @@ func TestExtractID_SchemaID(t *testing.T) { { name: "Derive from entity ID with tilde", content: map[string]any{ - "gtsId": "gts.vendor.package.namespace.type.v0~", + "gtsId": "gts.vendor.package.namespace.type.v0~a.b.c.d.v1.0", }, expectedSchemaID: "gts.vendor.package.namespace.type.v0~", - expectedSchemaField: "", // Not set - entity ID itself is a type + expectedSchemaField: "gtsId", // Derived from the chained ID }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := ExtractID(tt.content, nil) - if result.SchemaID != tt.expectedSchemaID { - t.Errorf("Expected SchemaID %q, got %q", tt.expectedSchemaID, result.SchemaID) + var gotSchemaID string + if result.SchemaID != nil { + gotSchemaID = *result.SchemaID + } + if gotSchemaID != tt.expectedSchemaID { + t.Errorf("Expected SchemaID %q, got %q", tt.expectedSchemaID, gotSchemaID) } // Handle both empty string expectation and actual value @@ -208,8 +212,12 @@ func TestExtractID_CustomConfig(t *testing.T) { } t.Errorf("Expected customId field, got %q", got) } - if result.SchemaID != "gts.vendor.package.namespace.type.v0~" { - t.Errorf("Expected SchemaID from customType field, got %q", result.SchemaID) + var gotSchemaID string + if result.SchemaID != nil { + gotSchemaID = *result.SchemaID + } + if gotSchemaID != "gts.vendor.package.namespace.type.v0~" { + t.Errorf("Expected SchemaID from customType field, got %q", gotSchemaID) } if result.SelectedSchemaIDField == nil || *result.SelectedSchemaIDField != "customType" { var got string @@ -241,31 +249,37 @@ func TestExtractID_NoValidID(t *testing.T) { func TestExtractID_InvalidIDInField(t *testing.T) { content := map[string]any{ "gtsId": "not-a-valid-gts-id", - "id": "gts.vendor.package.namespace.type.v0", + "id": "gts.vendor.package.namespace.type.v0~a.b.c.d.v1", } result := ExtractID(content, nil) // Should fallback to the "id" field which has a valid GTS ID - if result.ID != "gts.vendor.package.namespace.type.v0" { + if result.ID != "gts.vendor.package.namespace.type.v0~a.b.c.d.v1" { t.Errorf("Expected fallback to valid ID, got %q", result.ID) } } -// TestExtractID_SchemaIDFallback tests schema ID extraction using entity ID as fallback +// TestExtractID_SchemaIDFallback tests schema ID extraction for schemas with $schema field func TestExtractID_SchemaIDFallback(t *testing.T) { content := map[string]any{ - "$schema": "gts.vendor.package.namespace.type.v0~", + "$id": "gts.vendor.package.namespace.type.v0~", + "$schema": "http://json-schema.org/draft-07/schema#", } result := ExtractID(content, nil) - // When no entity ID field is found, schema ID should be used as entity ID too + // For schemas, ID comes from $id field if result.ID != "gts.vendor.package.namespace.type.v0~" { - t.Errorf("Expected ID to fallback to schema ID, got %q", result.ID) + t.Errorf("Expected ID from $id field, got %q", result.ID) + } + var gotSchemaID string + if result.SchemaID != nil { + gotSchemaID = *result.SchemaID } - if result.SchemaID != "gts.vendor.package.namespace.type.v0~" { - t.Errorf("Expected SchemaID, got %q", result.SchemaID) + // For base schemas, schema_id comes from $schema field + if gotSchemaID != "http://json-schema.org/draft-07/schema#" { + t.Errorf("Expected SchemaID from $schema field, got %q", gotSchemaID) } } @@ -296,17 +310,19 @@ func TestExtractID_GtsURIPrefix_InDollarIdField(t *testing.T) { // TestExtractID_GtsURIPrefix_NotStrippedFromOtherFields tests that gts:// prefix is NOT stripped from non-$id fields func TestExtractID_GtsURIPrefix_NotStrippedFromOtherFields(t *testing.T) { - // gts:// prefix in non-$id field should NOT be stripped (and results in invalid GTS ID) + // gts:// prefix in non-$id field should NOT be stripped + // The value "gts://gts.vendor..." is not a valid GTS ID, so it's treated as an anonymous instance content := map[string]any{ - "id": "gts://gts.vendor.package.namespace.type.v1.0", + "id": "gts://gts.vendor.package.namespace.type.v1~a.b.c.d.v1.0", } result := ExtractID(content, nil) // The "id" field is not $id, so the gts:// prefix is NOT stripped - // The value "gts://gts.vendor..." is not a valid GTS ID - if result.ID != "" { - t.Errorf("Expected empty ID (gts:// prefix in 'id' field should not be stripped), got %q", result.ID) + // The raw value is returned as-is for anonymous instances (non-GTS IDs) + expectedID := "gts://gts.vendor.package.namespace.type.v1~a.b.c.d.v1.0" + if result.ID != expectedID { + t.Errorf("Expected ID %q (raw value for anonymous instance), got %q", expectedID, result.ID) } } diff --git a/gts/file_reader_test.go b/gts/file_reader_test.go index 5886cf5..d9488ad 100644 --- a/gts/file_reader_test.go +++ b/gts/file_reader_test.go @@ -20,7 +20,7 @@ func TestGtsFileReader_SingleFile(t *testing.T) { // Create a JSON file with a single entity testFile := filepath.Join(tmpDir, "test.json") content := map[string]any{ - "gtsId": "gts.vendor.package.namespace.type.v0", + "gtsId": "gts.vendor.package.namespace.type.v0~a.b.c.d.v1", "name": "Test Entity", } @@ -42,7 +42,7 @@ func TestGtsFileReader_SingleFile(t *testing.T) { t.Fatal("Expected entity, got nil") } - if entity.GtsID == nil || entity.GtsID.ID != "gts.vendor.package.namespace.type.v0" { + if entity.GtsID == nil || entity.GtsID.ID != "gts.vendor.package.namespace.type.v0~a.b.c.d.v1" { t.Errorf("Expected GtsID 'gts.vendor.package.namespace.type.v0', got %v", entity.GtsID) } @@ -59,15 +59,15 @@ func TestGtsFileReader_ArrayOfEntities(t *testing.T) { testFile := filepath.Join(tmpDir, "test.json") content := []map[string]any{ { - "gtsId": "gts.vendor.package.namespace.type1.v0", + "gtsId": "gts.vendor.package.namespace.type1.v0~", "name": "Entity 1", }, { - "gtsId": "gts.vendor.package.namespace.type2.v0", + "gtsId": "gts.vendor.package.namespace.type2.v0~", "name": "Entity 2", }, { - "gtsId": "gts.vendor.package.namespace.type3.v0", + "gtsId": "gts.vendor.package.namespace.type3.v0~", "name": "Entity 3", }, } @@ -125,19 +125,19 @@ func TestGtsFileReader_Directory(t *testing.T) { { name: "entity1.json", content: map[string]any{ - "gtsId": "gts.vendor.package.namespace.type1.v0", + "gtsId": "gts.vendor.package.namespace.type1.v0~", }, }, { name: "entity2.json", content: map[string]any{ - "gtsId": "gts.vendor.package.namespace.type2.v0", + "gtsId": "gts.vendor.package.namespace.type2.v0~", }, }, { name: "entity3.gts", content: map[string]any{ - "gtsId": "gts.vendor.package.namespace.type3.v0", + "gtsId": "gts.vendor.package.namespace.type3.v0~", }, }, } @@ -179,7 +179,7 @@ func TestGtsFileReader_ExcludeDirectories(t *testing.T) { // Create a valid file in root rootFile := filepath.Join(tmpDir, "root.json") rootContent := map[string]any{ - "gtsId": "gts.vendor.package.namespace.root.v0", + "gtsId": "gts.vendor.package.namespace.root.v0~", } data, _ := json.Marshal(rootContent) os.WriteFile(rootFile, data, 0644) @@ -189,7 +189,7 @@ func TestGtsFileReader_ExcludeDirectories(t *testing.T) { os.Mkdir(nodeModules, 0755) nmFile := filepath.Join(nodeModules, "excluded.json") nmContent := map[string]any{ - "gtsId": "gts.vendor.package.namespace.excluded.v0", + "gtsId": "gts.vendor.package.namespace.excluded.v0~", } data, _ = json.Marshal(nmContent) os.WriteFile(nmFile, data, 0644) @@ -212,7 +212,7 @@ func TestGtsFileReader_ExcludeDirectories(t *testing.T) { t.Errorf("Expected 1 entity (excluding node_modules), got %d", len(entities)) } - if len(entities) > 0 && entities[0].GtsID.ID != "gts.vendor.package.namespace.root.v0" { + if len(entities) > 0 && entities[0].GtsID.ID != "gts.vendor.package.namespace.root.v0~" { t.Errorf("Expected root entity, got %s", entities[0].GtsID.ID) } } @@ -223,7 +223,7 @@ func TestGtsFileReader_Reset(t *testing.T) { testFile := filepath.Join(tmpDir, "test.json") content := map[string]any{ - "gtsId": "gts.vendor.package.namespace.type.v0", + "gtsId": "gts.vendor.package.namespace.type.v0~", } data, _ := json.Marshal(content) @@ -267,14 +267,14 @@ func TestGtsFileReader_MultiplePaths(t *testing.T) { // Create a file in each directory file1 := filepath.Join(dir1, "entity1.json") content1 := map[string]any{ - "gtsId": "gts.vendor.package.namespace.type1.v0", + "gtsId": "gts.vendor.package.namespace.type1.v0~", } data1, _ := json.Marshal(content1) os.WriteFile(file1, data1, 0644) file2 := filepath.Join(dir2, "entity2.json") content2 := map[string]any{ - "gtsId": "gts.vendor.package.namespace.type2.v0", + "gtsId": "gts.vendor.package.namespace.type2.v0~", } data2, _ := json.Marshal(content2) os.WriteFile(file2, data2, 0644) @@ -307,7 +307,7 @@ func TestGtsFileReader_NoGtsID(t *testing.T) { "name": "No GTS ID", }, { - "gtsId": "gts.vendor.package.namespace.type.v0", + "gtsId": "gts.vendor.package.namespace.type.v0~", }, } @@ -342,7 +342,7 @@ func TestGtsFileReader_InvalidJSON(t *testing.T) { // Create a valid file validFile := filepath.Join(tmpDir, "valid.json") content := map[string]any{ - "gtsId": "gts.vendor.package.namespace.type.v0", + "gtsId": "gts.vendor.package.namespace.type.v0~", } data, _ := json.Marshal(content) os.WriteFile(validFile, data, 0644) @@ -370,7 +370,7 @@ func TestGtsFileReader_ReadByID(t *testing.T) { testFile := filepath.Join(tmpDir, "test.json") content := map[string]any{ - "gtsId": "gts.vendor.package.namespace.type.v0", + "gtsId": "gts.vendor.package.namespace.type.v0~", } data, _ := json.Marshal(content) os.WriteFile(testFile, data, 0644) @@ -378,7 +378,7 @@ func TestGtsFileReader_ReadByID(t *testing.T) { reader := NewGtsFileReaderFromPath(testFile, nil) // ReadByID should always return nil for file reader - entity := reader.ReadByID("gts.vendor.package.namespace.type.v0") + entity := reader.ReadByID("gts.vendor.package.namespace.type.v0~") if entity != nil { t.Error("ReadByID should return nil for file reader") } diff --git a/gts/gts.go b/gts/gts.go index 3df8ef2..8702528 100644 --- a/gts/gts.go +++ b/gts/gts.go @@ -134,6 +134,13 @@ func NewGtsID(id string) (*GtsID, error) { offset += len(part) } + // Single-segment instances are prohibited + // Well-known instances must be chained with at least one type segment + // This check should only apply to non-wildcard, non-type single-segment IDs + if len(gtsID.Segments) == 1 && !gtsID.IsType() && !gtsID.Segments[0].IsWildcard { + return nil, &InvalidGtsIDError{GtsID: id, Cause: "Single-segment instances are prohibited. Well-known instances must be chained with a type segment"} + } + return gtsID, nil } @@ -151,6 +158,16 @@ func (g *GtsID) IsType() bool { return strings.HasSuffix(g.ID, "~") } +// IsWildcard returns true if this identifier contains wildcard patterns +func (g *GtsID) IsWildcard() bool { + for _, segment := range g.Segments { + if segment.IsWildcard { + return true + } + } + return false +} + // ToUUID generates a deterministic UUID (v5) from the GTS identifier // The UUID is generated using uuid5(GTS_NAMESPACE, gts_id) func (g *GtsID) ToUUID() uuid.UUID { diff --git a/gts/gts_test.go b/gts/gts_test.go index 282a637..8d9f2ae 100644 --- a/gts/gts_test.go +++ b/gts/gts_test.go @@ -12,14 +12,14 @@ import ( // TestGtsID_Valid tests valid GTS identifiers func TestGtsID_Valid(t *testing.T) { validIDs := []string{ - "gts.vendor.package.namespace.type.v0", - "gts.vendor.package.namespace.type.v0.0", - "gts.vendor.package.namespace.type.v1", - "gts.vendor.package.namespace.type.v1.5", - "gts.vendor_name.package_name.namespace_name.type_name.v0", + "gts.vendor.package.namespace.type.v0~a.b.c.d.v1", + "gts.vendor.package.namespace.type.v0.0~a.b.c.d.v1", + "gts.vendor.package.namespace.type.v1~", + "gts.vendor.package.namespace.type.v1.5~", + "gts.vendor_name.package_name.namespace_name.type_name.v0~a.b.c.d.v1", "gts.vendor.package.namespace.type.v0~", "gts.vendor.package.namespace.type.v0.0~", - "gts.vendor.package.namespace.type.v10.20", + "gts.vendor.package.namespace.type.v10.20~a.b.c.d.v1", } for _, id := range validIDs { @@ -41,8 +41,8 @@ func TestGtsID_IsValid(t *testing.T) { id string expected bool }{ - {"gts.vendor.package.namespace.type.v0", true}, - {"gts.vendor.package.namespace.type.v0.0", true}, + {"gts.vendor.package.namespace.type.v0~a.b.c.d.v1", true}, + {"gts.vendor.package.namespace.type.v0.0~a.b.c.d.v1", true}, {"invalid.prefix.package.namespace.type.v0", false}, {"GTS.vendor.package.namespace.type.v0", false}, {"gts.vendor.package.namespace", false}, @@ -198,8 +198,8 @@ func TestGtsID_IsType(t *testing.T) { }{ {"gts.vendor.package.namespace.type.v0~", true}, {"gts.vendor.package.namespace.type.v0.0~", true}, - {"gts.vendor.package.namespace.type.v0", false}, - {"gts.vendor.package.namespace.type.v0.0", false}, + {"gts.vendor.package.namespace.type.v0~a.b.c.d.v1", false}, + {"gts.vendor.package.namespace.type.v0.0~a.b.c.d.v1", false}, } for _, tt := range tests { diff --git a/gts/gts_uuid_test.go b/gts/gts_uuid_test.go index c459da4..685d6a2 100644 --- a/gts/gts_uuid_test.go +++ b/gts/gts_uuid_test.go @@ -131,7 +131,7 @@ func TestToUUID_MoreExamples(t *testing.T) { }, { name: "Instance with full version", - gtsID: "gts.vendor.pkg.ns.type.v1.0", + gtsID: "gts.vendor.pkg.ns.type.v1.0~a.b.c.d.v1", }, { name: "Multiple chained segments", diff --git a/gts/match.go b/gts/match.go index fd8afc1..e550174 100644 --- a/gts/match.go +++ b/gts/match.go @@ -15,7 +15,7 @@ type MatchIDResult struct { Candidate string `json:"candidate"` Pattern string `json:"pattern"` Match bool `json:"match"` - Error string `json:"error"` + Error string `json:"error,omitempty"` } // InvalidWildcardError represents an error when a wildcard pattern is invalid @@ -35,8 +35,18 @@ func (e *InvalidWildcardError) Error() string { // Returns a MatchIDResult with Match=true if the candidate matches the pattern, // or Match=false with an optional Error message on failure or mismatch func MatchIDPattern(candidate, pattern string) MatchIDResult { - // Parse candidate - candidateID, err := NewGtsID(candidate) + // Parse candidate - it can be either a regular GTS ID or a wildcard pattern + var candidateID *GtsID + var err error + + if strings.Contains(candidate, "*") { + // Candidate contains wildcard, validate it as a wildcard pattern + candidateID, err = validateWildcard(candidate) + } else { + // Candidate is a regular GTS ID + candidateID, err = NewGtsID(candidate) + } + if err != nil { return MatchIDResult{ Candidate: candidate, @@ -99,8 +109,23 @@ func validateWildcard(pattern string) (*GtsID, error) { } } - // Try to parse as a GtsID - id, err := NewGtsID(p) + // For wildcard patterns, we need custom parsing that doesn't enforce single-segment prohibition + // Remove the wildcard token temporarily for validation + tempPattern := strings.ReplaceAll(p, ".*", "") + tempPattern = strings.ReplaceAll(tempPattern, "~*", "~") + + // Try to parse the base pattern (without wildcard) using standard validation + // but skip single-segment instance check for wildcards + _, err := validateWildcardBase(tempPattern) + if err != nil { + return nil, &InvalidWildcardError{ + Pattern: pattern, + Cause: err.Error(), + } + } + + // Now parse the full wildcard pattern with relaxed validation + id, err := parseWildcardGtsID(p) if err != nil { return nil, &InvalidWildcardError{ Pattern: pattern, @@ -111,6 +136,93 @@ func validateWildcard(pattern string) (*GtsID, error) { return id, nil } +// validateWildcardBase validates the base pattern (without wildcards) with relaxed rules +func validateWildcardBase(basePattern string) (*GtsID, error) { + if basePattern == "" { + return nil, fmt.Errorf("empty base pattern") + } + + // Allow bare "gts" base for global wildcard patterns like "gts.*" + if basePattern == strings.TrimSuffix(GtsPrefix, ".") { + return nil, nil + } + + // Basic prefix validation + if !strings.HasPrefix(basePattern, GtsPrefix) { + return nil, fmt.Errorf("does not start with '%s'", GtsPrefix) + } + + // Length validation + if len(basePattern) > MaxIDLength { + return nil, fmt.Errorf("too long") + } + + // Lowercase validation + if basePattern != strings.ToLower(basePattern) { + return nil, fmt.Errorf("must be lower case") + } + + // No hyphens validation + if strings.Contains(basePattern, "-") { + return nil, fmt.Errorf("must not contain '-'") + } + + // For wildcard base validation, we skip the single-segment instance prohibition + // since wildcards can match complete patterns + return nil, nil // We don't need to return a parsed ID, just validate +} + +// parseWildcardGtsID parses a wildcard GTS ID with relaxed validation rules +func parseWildcardGtsID(id string) (*GtsID, error) { + raw := strings.TrimSpace(id) + + // Basic validation (same as NewGtsID but skip single-segment check) + if raw != strings.ToLower(raw) { + return nil, &InvalidGtsIDError{GtsID: id, Cause: "Must be lower case"} + } + + if strings.Contains(raw, "-") { + return nil, &InvalidGtsIDError{GtsID: id, Cause: "Must not contain '-'"} + } + + if !strings.HasPrefix(raw, GtsPrefix) { + return nil, &InvalidGtsIDError{GtsID: id, Cause: fmt.Sprintf("Does not start with '%s'", GtsPrefix)} + } + + if len(raw) > MaxIDLength { + return nil, &InvalidGtsIDError{GtsID: id, Cause: "Too long"} + } + + gtsID := &GtsID{ + ID: raw, + Segments: make([]*GtsIDSegment, 0), + } + + // Split by ~ to get segments, preserving empties to detect trailing ~ + remainder := raw[len(GtsPrefix):] + parts := splitPreservingTilde(remainder) + + offset := len(GtsPrefix) + for i, part := range parts { + if part == "" { + return nil, &InvalidGtsIDError{GtsID: id, Cause: fmt.Sprintf("GTS segment #%d @ offset %d is empty", i+1, offset)} + } + + segment, err := parseSegment(i+1, offset, part) + if err != nil { + return nil, err + } + + gtsID.Segments = append(gtsID.Segments, segment) + offset += len(part) + } + + // Skip single-segment instance prohibition for wildcard patterns + // Wildcards are allowed to match patterns that would otherwise be invalid + + return gtsID, nil +} + // wildcardMatch performs the actual matching between candidate and pattern func wildcardMatch(candidate, pattern *GtsID) bool { if candidate == nil || pattern == nil { diff --git a/gts/match_test.go b/gts/match_test.go index af0e3e6..b7b031b 100644 --- a/gts/match_test.go +++ b/gts/match_test.go @@ -325,8 +325,8 @@ func TestMatchIDPattern_ExactMatch(t *testing.T) { }, { name: "Exact match with full version", - candidate: "gts.vendor.pkg.ns.type.v1.2", - pattern: "gts.vendor.pkg.ns.type.v1.2", + candidate: "gts.vendor.pkg.ns.type.v1.2~a.b.c.d.v1", + pattern: "gts.vendor.pkg.ns.type.v1.2~a.b.c.d.v1", match: true, }, { diff --git a/gts/ops.go b/gts/ops.go index f75629a..8464eae 100644 --- a/gts/ops.go +++ b/gts/ops.go @@ -5,28 +5,60 @@ Released under Apache License 2.0 package gts +import ( + "fmt" + "strings" +) + // IDValidationResult represents the result of GTS ID validation type IDValidationResult struct { - ID string `json:"id"` - Valid bool `json:"valid"` - Error string `json:"error"` + ID string `json:"id"` + Valid bool `json:"valid"` + IsSchema bool `json:"is_schema"` + IsWildcard bool `json:"is_wildcard"` + Error string `json:"error,omitempty"` } // ValidateGtsID validates a GTS identifier and returns a result func ValidateGtsID(gtsID string) *IDValidationResult { - _, err := NewGtsID(gtsID) - if err != nil { - return &IDValidationResult{ - ID: gtsID, - Valid: false, - Error: err.Error(), + // Check if it contains wildcards first + isWildcard := strings.Contains(gtsID, "*") + result := &IDValidationResult{ + ID: gtsID, + IsWildcard: isWildcard, + } + + if isWildcard { + // Validate as wildcard pattern + _, err := validateWildcard(gtsID) + if err != nil { + result.Valid = false + result.IsSchema = false + result.Error = formatValidateError(gtsID, err) + return result } + + result.Valid = true + result.IsSchema = strings.HasSuffix(gtsID, "~*") || strings.HasSuffix(gtsID, ".*") + return result } - return &IDValidationResult{ - ID: gtsID, - Valid: true, - Error: "", + + // Validate as regular GTS ID + id, err := NewGtsID(gtsID) + if err != nil { + result.Valid = false + result.IsSchema = false + result.Error = formatValidateError(gtsID, err) + return result } + + result.Valid = true + result.IsSchema = id.IsType() + return result +} + +func formatValidateError(gtsID string, err error) string { + return fmt.Sprintf("Unable to validate GTS ID '%s': %s", gtsID, err.Error()) } // ExtractGtsID extracts GTS ID from JSON content diff --git a/gts/parse.go b/gts/parse.go index f15fd13..6216916 100644 --- a/gts/parse.go +++ b/gts/parse.go @@ -5,6 +5,8 @@ Released under Apache License 2.0 package gts +import "strings" + // ParseIDSegment represents a parsed segment component from a GTS identifier type ParseIDSegment struct { Vendor string `json:"vendor"` @@ -18,23 +20,70 @@ type ParseIDSegment struct { // ParseIDResult represents the result of parsing a GTS identifier type ParseIDResult struct { - ID string `json:"id"` - OK bool `json:"ok"` - Segments []ParseIDSegment `json:"segments"` - Error string `json:"error"` + ID string `json:"id"` + OK bool `json:"ok"` + IsWildcard bool `json:"is_wildcard"` + IsSchema bool `json:"is_schema"` + Segments []ParseIDSegment `json:"segments"` + Error string `json:"error,omitempty"` } // ParseID decomposes a GTS identifier into its constituent parts // Returns a ParseIDResult with OK=true and populated Segments on success, // or OK=false with an Error message on failure func ParseID(gtsID string) ParseIDResult { + isWildcard := strings.Contains(gtsID, "*") + + if isWildcard { + // Handle wildcard patterns separately + id, err := validateWildcard(gtsID) + if err != nil { + return ParseIDResult{ + ID: gtsID, + OK: false, + IsWildcard: true, + IsSchema: false, + Segments: nil, + Error: err.Error(), + } + } + + segments := make([]ParseIDSegment, len(id.Segments)) + for i, seg := range id.Segments { + segments[i] = ParseIDSegment{ + Vendor: seg.Vendor, + Package: seg.Package, + Namespace: seg.Namespace, + Type: seg.Type, + VerMajor: seg.VerMajor, + VerMinor: seg.VerMinor, + IsType: seg.IsType, + } + } + + // Wildcard patterns ending with .* are type patterns (schemas) + isSchema := strings.HasSuffix(gtsID, ".*") || strings.HasSuffix(gtsID, "~*") + + return ParseIDResult{ + ID: gtsID, + OK: true, + IsWildcard: true, + IsSchema: isSchema, + Segments: segments, + Error: "", + } + } + + // Handle regular GTS IDs id, err := NewGtsID(gtsID) if err != nil { return ParseIDResult{ - ID: gtsID, - OK: false, - Segments: nil, - Error: err.Error(), + ID: gtsID, + OK: false, + IsWildcard: false, + IsSchema: false, + Segments: nil, + Error: err.Error(), } } @@ -52,9 +101,11 @@ func ParseID(gtsID string) ParseIDResult { } return ParseIDResult{ - ID: gtsID, - OK: true, - Segments: segments, - Error: "", + ID: gtsID, + OK: true, + IsWildcard: false, + IsSchema: id.IsType(), + Segments: segments, + Error: "", } } diff --git a/gts/parse_test.go b/gts/parse_test.go index abd7e02..9adba3c 100644 --- a/gts/parse_test.go +++ b/gts/parse_test.go @@ -225,7 +225,7 @@ func TestParseID_VersionComponents(t *testing.T) { }, { name: "Major and minor version (instance)", - id: "gts.x.pkg.ns.type.v2.5", + id: "gts.x.pkg.ns.type.v2.5~a.b.c.d.v1.0", verMajor: 2, verMinor: intPtr(5), isType: false, @@ -239,7 +239,7 @@ func TestParseID_VersionComponents(t *testing.T) { }, { name: "Version zero with minor", - id: "gts.x.pkg.ns.type.v0.0", + id: "gts.x.pkg.ns.type.v0.0~a.b.c.d.v1.0", verMajor: 0, verMinor: intPtr(0), isType: false, @@ -254,10 +254,15 @@ func TestParseID_VersionComponents(t *testing.T) { t.Fatalf("Expected OK=true, got OK=false with error: %s", result.Error) } - if len(result.Segments) != 1 { - t.Fatalf("Expected 1 segment, got %d", len(result.Segments)) + // For type IDs, expect 1 segment; for instance IDs, expect 2 segments + if tt.isType && len(result.Segments) != 1 { + t.Fatalf("Expected 1 segment for type ID, got %d", len(result.Segments)) + } + if !tt.isType && len(result.Segments) != 2 { + t.Fatalf("Expected 2 segments for instance ID, got %d", len(result.Segments)) } + // Version info is always in the first segment seg := result.Segments[0] if seg.VerMajor != tt.verMajor { @@ -272,8 +277,9 @@ func TestParseID_VersionComponents(t *testing.T) { t.Errorf("Expected ver_minor=%d, got %d", *tt.verMinor, *seg.VerMinor) } - if seg.IsType != tt.isType { - t.Errorf("Expected is_type=%v, got %v", tt.isType, seg.IsType) + // Check overall ID type (IsSchema), not individual segment's IsType + if result.IsSchema != tt.isType { + t.Errorf("Expected is_schema=%v, got %v", tt.isType, result.IsSchema) } }) } @@ -377,16 +383,17 @@ func TestParseID_InvalidIDs(t *testing.T) { // TestParseID_AllComponents tests that all component fields are extracted func TestParseID_AllComponents(t *testing.T) { - result := ParseID("gts.myvendor.mypackage.mynamespace.mytype.v3.7") + result := ParseID("gts.myvendor.mypackage.mynamespace.mytype.v3.7~a.b.c.d.v1.0") if !result.OK { t.Fatalf("Expected OK=true, got OK=false with error: %s", result.Error) } - if len(result.Segments) != 1 { - t.Fatalf("Expected 1 segment, got %d", len(result.Segments)) + if len(result.Segments) != 2 { + t.Fatalf("Expected 2 segments for instance ID, got %d", len(result.Segments)) } + // Component fields are in the first (type) segment seg := result.Segments[0] if seg.Vendor != "myvendor" { @@ -417,8 +424,11 @@ func TestParseID_AllComponents(t *testing.T) { } } - if seg.IsType { - t.Error("Expected is_type=false for instance ID") + // Check overall ID type (IsSchema), not individual segment's IsType + // The first segment is a type segment (ends with ~), so seg.IsType is true + // But the overall ID is an instance (doesn't end with ~), so result.IsSchema is false + if result.IsSchema { + t.Error("Expected is_schema=false for instance ID") } } diff --git a/gts/ref_validation.go b/gts/ref_validation.go new file mode 100644 index 0000000..f09696c --- /dev/null +++ b/gts/ref_validation.go @@ -0,0 +1,148 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package gts + +import ( + "fmt" + "strings" +) + +// RefValidationError represents a validation error for $ref values +type RefValidationError struct { + FieldPath string + RefValue string + Reason string +} + +func (e *RefValidationError) Error() string { + return fmt.Sprintf("$ref validation failed for field '%s': %s", e.FieldPath, e.Reason) +} + +// RefValidator validates $ref constraints in GTS schemas +type RefValidator struct { +} + +// NewRefValidator creates a new $ref validator +func NewRefValidator() *RefValidator { + return &RefValidator{} +} + +// ValidateSchemaRefs validates all $ref values in a schema +func (v *RefValidator) ValidateSchemaRefs(schema map[string]interface{}, schemaPath string) []*RefValidationError { + var errors []*RefValidationError + v.visitSchemaForRefs(schema, schemaPath, &errors) + return errors +} + +// visitSchemaForRefs recursively visits schema nodes to find and validate $ref values +func (v *RefValidator) visitSchemaForRefs(schema map[string]interface{}, path string, errors *[]*RefValidationError) { + if schema == nil { + return + } + + // Check for $ref field + if refValue, hasRef := schema["$ref"]; hasRef { + refPath := "$ref" + if path != "" { + refPath = path + "/$ref" + } + if err := v.validateRef(refValue, refPath); err != nil { + *errors = append(*errors, err) + } + } + + // Recurse into nested structures + for key, value := range schema { + if key == "$ref" { + continue // Already processed above + } + + nestedPath := key + if path != "" { + nestedPath = path + "/" + key + } + + switch val := value.(type) { + case map[string]interface{}: + v.visitSchemaForRefs(val, nestedPath, errors) + case []interface{}: + for idx, item := range val { + if itemMap, ok := item.(map[string]interface{}); ok { + v.visitSchemaForRefs(itemMap, fmt.Sprintf("%s[%d]", nestedPath, idx), errors) + } + } + } + } +} + +// validateRef validates a single $ref value according to GTS specification +func (v *RefValidator) validateRef(refValue interface{}, fieldPath string) *RefValidationError { + refStr, ok := refValue.(string) + if !ok { + return &RefValidationError{ + FieldPath: fieldPath, + RefValue: fmt.Sprintf("%v", refValue), + Reason: fmt.Sprintf("$ref value must be a string, got %T", refValue), + } + } + + refStr = strings.TrimSpace(refStr) + if refStr == "" { + return &RefValidationError{ + FieldPath: fieldPath, + RefValue: refStr, + Reason: "$ref value cannot be empty", + } + } + + // $ref must use gts:// URI format for GTS references + + // Case 1: Local refs (JSON Pointer) - must start with # + if strings.HasPrefix(refStr, "#") { + return nil // Valid local reference + } + + // Case 2: GTS refs - must use gts:// prefix with valid GTS ID + if strings.HasPrefix(refStr, "gts://") { + // Strip prefix and validate the GTS ID + gtsID := strings.TrimPrefix(refStr, GtsURIPrefix) + if !IsValidGtsID(gtsID) { + return &RefValidationError{ + FieldPath: fieldPath, + RefValue: refStr, + Reason: fmt.Sprintf("contains invalid GTS identifier '%s'", gtsID), + } + } + return nil // Valid GTS URI reference + } + + // Case 3: Invalid formats + + // Bare GTS ID (missing gts:// prefix) + if strings.HasPrefix(refStr, "gts.") && IsValidGtsID(refStr) { + return &RefValidationError{ + FieldPath: fieldPath, + RefValue: refStr, + Reason: "must be a local ref (starting with '#') or a GTS URI (starting with 'gts://')", + } + } + + // HTTP/HTTPS URIs + if strings.HasPrefix(refStr, "http://") || strings.HasPrefix(refStr, "https://") { + return &RefValidationError{ + FieldPath: fieldPath, + RefValue: refStr, + Reason: "must be a local ref (starting with '#') or a GTS URI (starting with 'gts://')", + } + } + + // Any other format + return &RefValidationError{ + FieldPath: fieldPath, + RefValue: refStr, + Reason: "must be a local ref (starting with '#') or a GTS URI (starting with 'gts://')", + } +} diff --git a/gts/registry_test.go b/gts/registry_test.go index 1ce138e..87bbd50 100644 --- a/gts/registry_test.go +++ b/gts/registry_test.go @@ -72,7 +72,7 @@ func TestGtsReferenceValidation(t *testing.T) { // Now register an instance that references the schema instance := NewJsonEntity(map[string]any{ - "gtsId": "gts.test.pkg.ns.user.v1.0", + "gtsId": "gts.test.pkg.ns.user.v1~a.b.c.d.v1.0", "$schema": "gts.test.pkg.ns.user.v1~", "id": "user-123", "name": "John Doe", @@ -90,7 +90,7 @@ func TestGtsReferenceValidation(t *testing.T) { // Register an instance that references non-existent schema instance := NewJsonEntity(map[string]any{ - "gtsId": "gts.test.pkg.ns.user.v1.0", + "gtsId": "gts.test.pkg.ns.user.v1~a.b.c.d.v1.0", "$schema": "gts.test.pkg.ns.nonexistent.v1~", "id": "user-123", "name": "John Doe", @@ -110,7 +110,7 @@ func TestGtsReferenceValidation(t *testing.T) { // Register an instance that references non-existent schema instance := NewJsonEntity(map[string]any{ - "gtsId": "gts.test.pkg.ns.user.v1.0", + "gtsId": "gts.test.pkg.ns.user.v1~a.b.c.d.v1.0", "$schema": "gts.test.pkg.ns.nonexistent.v1~", "id": "user-123", "name": "John Doe", @@ -197,7 +197,7 @@ func TestValidateSchema(t *testing.T) { t.Run("NonSchemaID", func(t *testing.T) { store := NewGtsStore(nil) - err := store.ValidateSchema("gts.test.pkg.ns.instance.v1.0") + err := store.ValidateSchema("gts.test.pkg.ns.instance.v1~a.b.c.d.v1.0") if err == nil { t.Fatal("Expected error for non-schema ID") } @@ -272,7 +272,7 @@ func TestRegistryIntegration(t *testing.T) { "$id": "gts.test.pkg.ns.admin.v1~", "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": []any{ - map[string]any{"$ref": "gts.test.pkg.ns.user.v1~"}, + map[string]any{"$ref": "gts://gts.test.pkg.ns.user.v1~"}, map[string]any{ "type": "object", "properties": map[string]any{ @@ -289,10 +289,9 @@ func TestRegistryIntegration(t *testing.T) { // 3. Register instances userInstance := NewJsonEntity(map[string]any{ - "gtsId": "gts.test.pkg.ns.user.v1.0", - "$schema": "gts.test.pkg.ns.user.v1~", - "id": "user-123", - "name": "John Doe", + "gtsId": "gts.test.pkg.ns.user.v1~a.b.c.d.v1.0", + "id": "user-123", + "name": "John Doe", }, DefaultGtsConfig()) err = store.Register(userInstance) @@ -301,8 +300,7 @@ func TestRegistryIntegration(t *testing.T) { } adminInstance := NewJsonEntity(map[string]any{ - "gtsId": "gts.test.pkg.ns.admin.v1.0", - "$schema": "gts.test.pkg.ns.admin.v1~", + "gtsId": "gts.test.pkg.ns.admin.v1~a.b.c.d.v1.0", "id": "admin-456", "name": "Jane Admin", "permissions": []string{"read", "write"}, diff --git a/gts/store.go b/gts/store.go index 563ad19..64c8c53 100644 --- a/gts/store.go +++ b/gts/store.go @@ -299,6 +299,17 @@ func (s *GtsStore) ValidateSchema(gtsID string) error { return fmt.Errorf("schema content is nil") } + // Validate $ref constraints in the schema + refValidator := NewRefValidator() + refErrors := refValidator.ValidateSchemaRefs(entity.Content, "") + if len(refErrors) > 0 { + var errorMsgs []string + for _, err := range refErrors { + errorMsgs = append(errorMsgs, err.Error()) + } + return fmt.Errorf("$ref validation failed: %s", strings.Join(errorMsgs, "; ")) + } + // Validate x-gts-ref constraints in the schema xGtsRefValidator := NewXGtsRefValidator(s) xGtsRefErrors := xGtsRefValidator.ValidateSchema(entity.Content, "", nil) diff --git a/gts/validate_test.go b/gts/validate_test.go index f266460..5ae3557 100644 --- a/gts/validate_test.go +++ b/gts/validate_test.go @@ -410,7 +410,7 @@ func TestValidateInstance_NoSchemaID(t *testing.T) { // Register instance without schema ID instance := map[string]any{ - "id": "gts.x.test6.noschem.item.v1", + "id": "gts.x.test6.noschem.item.v1~a.b.c.d.v1", "someField": "value", } instanceEntity := NewJsonEntity(instance, DefaultGtsConfig()) @@ -419,7 +419,7 @@ func TestValidateInstance_NoSchemaID(t *testing.T) { } // Validate instance without schema - should fail - result := store.ValidateInstance("gts.x.test6.noschem.item.v1") + result := store.ValidateInstance("gts.x.test6.noschem.item.v1~a.b.c.d.v1") if result.OK { t.Errorf("Expected validation to fail for instance without schema") diff --git a/server/handlers.go b/server/handlers.go index 3e8ddb4..f91899e 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -54,17 +54,139 @@ func (s *Server) handleAddEntity(w http.ResponseWriter, r *http.Request) { return } + validationParam := r.URL.Query().Get("validate") + if validationParam == "" { + validationParam = r.URL.Query().Get("validation") + } + + hasSchemaField := false + if schemaVal, ok := content["$schema"]; ok && schemaVal != nil { + hasSchemaField = true + } else if schemaVal, ok := content["$$schema"]; ok && schemaVal != nil { + content["$schema"] = schemaVal + hasSchemaField = true + } + if _, exists := content["$id"]; !exists { + if idVal, ok := content["$$id"]; ok { + content["$id"] = idVal + } + } + + if hasSchemaField { + idField, exists := content["$id"] + if !exists || idField == nil { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "JSON Schema $id field is required when $schema is present", + }) + return + } + idStr, ok := idField.(string) + if !ok { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "JSON Schema $id field must be a string", + }) + return + } + idStr = strings.TrimSpace(idStr) + if idStr == "" { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "JSON Schema $id field cannot be empty", + }) + return + } + if !strings.HasPrefix(idStr, gts.GtsURIPrefix) && !strings.HasPrefix(idStr, gts.GtsPrefix) { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "JSON Schema $id must be a valid GTS identifier (optionally using gts:// prefix)", + }) + return + } + normalizedID := strings.TrimPrefix(idStr, gts.GtsURIPrefix) + if strings.Contains(normalizedID, "*") { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "Wildcards are not allowed in schema IDs, only in patterns for access control", + }) + return + } + isBaseSchemaID := strings.Count(normalizedID, "~") == 1 && strings.HasSuffix(normalizedID, "~") + if isBaseSchemaID && !strings.HasPrefix(idStr, gts.GtsURIPrefix) { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "JSON Schema $id field must use gts:// URI prefix for base schemas", + }) + return + } + if !gts.IsValidGtsID(normalizedID) { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "JSON Schema $id must be a well-formed GTS identifier", + }) + return + } + } + entity := gts.NewJsonEntity(content, gts.DefaultGtsConfig()) if entity.GtsID == nil { - s.writeJSON(w, http.StatusOK, map[string]any{ + status := http.StatusOK + if validationParam == "true" { + status = http.StatusUnprocessableEntity + } + s.writeJSON(w, status, map[string]any{ "ok": false, "error": "Unable to extract GTS ID from entity", }) return } - // Always validate x-gts-ref constraints for schemas + // Always validate schema constraints for schemas if entity.IsSchema { + // Validate $id field for GTS schemas - check for specific invalid patterns + if idField, exists := entity.Content["$id"]; exists { + if idStr, ok := idField.(string); ok { + // Reject plain gts. prefix only for base schemas (single segment ending with ~) + // Derived schemas (multiple ~ segments) are allowed to use plain gts. format + if strings.HasPrefix(idStr, "gts.") && !strings.HasPrefix(idStr, "gts://") { + // Count ~ segments to determine if it's a base or derived schema + tildeParts := strings.Split(idStr, "~") + // If it's a base schema (only 2 parts: prefix and empty after ~), require gts:// + if len(tildeParts) == 2 && tildeParts[1] == "" { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "JSON Schema $id field must use gts:// URI prefix for GTS identifiers, not plain gts. prefix", + }) + return + } + } + // Check for wildcards in any GTS schema IDs + if (strings.HasPrefix(idStr, "gts://") || strings.HasPrefix(idStr, "gts.")) && strings.Contains(idStr, "*") { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": "Wildcards are not allowed in schema IDs, only in patterns for access control", + }) + return + } + } + } + + // Validate $ref constraints in the schema + refValidator := gts.NewRefValidator() + refErrors := refValidator.ValidateSchemaRefs(entity.Content, "") + if len(refErrors) > 0 { + var errorMsgs []string + for _, err := range refErrors { + errorMsgs = append(errorMsgs, err.Error()) + } + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "ok": false, + "error": fmt.Sprintf("$ref validation failed: %s", strings.Join(errorMsgs, "; ")), + }) + return + } + // Create a validator to validate x-gts-ref patterns in schema definition xGtsRefValidator := gts.NewXGtsRefValidator(s.store) xGtsRefErrors := xGtsRefValidator.ValidateSchema(entity.Content, "", nil) @@ -73,9 +195,9 @@ func (s *Server) handleAddEntity(w http.ResponseWriter, r *http.Request) { for _, err := range xGtsRefErrors { errorMsgs = append(errorMsgs, err.Error()) } - s.writeJSON(w, http.StatusOK, map[string]any{ + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ "ok": false, - "error": fmt.Sprintf("Validation failed: %s", strings.Join(errorMsgs, "; ")), + "error": fmt.Sprintf("x-gts-ref validation failed: %s", strings.Join(errorMsgs, "; ")), }) return }