Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
.vscode/
vendor/
coverage.out
bin/
logs/
e2e.log
64 changes: 64 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# CLAUDE.md

Guidance for Claude Code when working in this repository.

## Project Overview

`gts-go` is the Go reference implementation of [GTS](https://github.com/GlobalTypeSystem/gts-spec) — library (`gts/`), CLI (`cmd/gts`), and an HTTP server (`cmd/gts-server` / `gts server`) that answers the REST API exercised by the shared gts-spec conformance suite.

`.gts-spec/` is the spec vendored as a git submodule; `tests/` inside it is the conformance suite. See `README.md` for API/CLI details and `make help` for all targets.

## Running the gts-spec Test Suite

The short form is `make e2e PORT=8001` (PORT defaults to 8000; override if busy — the target fails fast if the port is already in use). It bootstraps the venv, rebuilds, starts the server, runs pytest, and shuts everything down.

### Bootstrap the venv (first time only)

Tests depend on `httprunner`. Installed into `.gts-spec/.venv/` (gitignored by the submodule).

```bash
make e2e-venv # uses python3 by default
make e2e-venv PYTHON=python3.11 # Python 3.11 is the safest (httprunner still pins pydantic<2)
```

### Run pytest manually against a running server

Useful when iterating on a single test without the full `make e2e` cycle.

```bash
make build
./bin/gts server --port 8001 &

PYTEST=".gts-spec/.venv/bin/python -m pytest"

# Whole suite
$PYTEST .gts-spec/tests --gts-base-url http://127.0.0.1:8001

# One file / one class
$PYTEST .gts-spec/tests/test_refimpl_x_gts_final_abstract.py --gts-base-url http://127.0.0.1:8001
$PYTEST .gts-spec/tests/test_op12_schema_vs_schema_validation.py::TestCaseOp12_FinalBase_RejectDerived --gts-base-url http://127.0.0.1:8001
```

`GTS_BASE_URL` env var works too. The server holds state in memory with no reset endpoint — restart between full-suite runs.

### Open-files limit on macOS

Before running the full suite, raise the FD limit in the calling shell:

```bash
ulimit -n 4096
```

`httprunner` leaks a keep-alive socket per test class (the underlying
`requests.Session` is never closed), so FDs grow linearly. macOS's
default 256 soft cap is hit around test ~240 with `EMFILE: Too many
open files`. Linux defaults (1024+) are usually fine. `make e2e` runs
its commands in a subshell, so the `ulimit` must be set in the parent
shell that invokes `make`. Don't bake `ulimit` into the Makefile —
it's an environment concern, not a build step.

## Working in This Repo

- `.gts-spec` is a submodule. Bump with `make update-spec`, then rerun `make e2e` to pick up new spec tests.
- Handlers in `server/` stay thin — logic goes in `gts/` where it is unit-testable. New REST behavior usually already has coverage in `.gts-spec/tests/`; run the relevant file before and after to confirm.
- `make check` is the full local gate: fmt + vet + lint + test + e2e.
44 changes: 32 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
CI := 1

.PHONY: help build dev-fmt all check fmt vet lint test security coverage update-spec e2e
.PHONY: help build dev-fmt all check fmt vet lint test security coverage update-spec e2e-venv e2e

# Python interpreter used to bootstrap the venv; override with `make e2e PYTHON=python3.11`
PYTHON ?= python3
VENV_DIR := .gts-spec/.venv
VENV_PY := $(VENV_DIR)/bin/python

# Port for the reference server during `make e2e`; override with `make e2e PORT=8001`
PORT ?= 8000

# Default target - show help
.DEFAULT_GOAL := help
Expand Down Expand Up @@ -62,17 +70,29 @@ coverage:
update-spec:
git submodule update --remote .gts-spec

# Run end-to-end tests against gts-spec
e2e: build
@echo "Starting server in background..."
@./bin/gts server --port 8000 & echo $$! > .server.pid
@sleep 2
@echo "Running e2e tests..."
@PYTHONDONTWRITEBYTECODE=1 pytest -p no:cacheprovider --log-file=e2e.log ./.gts-spec/tests || (kill `cat .server.pid` 2>/dev/null; rm -f .server.pid; exit 1)
@echo "Stopping server..."
@kill `cat .server.pid` 2>/dev/null || true
@rm -f .server.pid
@echo "E2E tests completed successfully"
# Create/refresh the Python venv used by the gts-spec e2e test suite
e2e-venv: $(VENV_PY)

$(VENV_PY):
@if [ ! -x "$(VENV_PY)" ]; then \
echo "Creating venv at $(VENV_DIR) using $(PYTHON)..."; \
$(PYTHON) -m venv $(VENV_DIR); \
fi
@echo "Installing gts-spec e2e test dependencies..."
@$(VENV_PY) -m pip install --quiet --upgrade pip setuptools wheel
@$(VENV_PY) -m pip install --quiet httprunner

# Run end-to-end tests against gts-spec.
e2e: build e2e-venv
@rm -rf logs e2e.log
@set -e; \
trap 'kill `cat .server.pid 2>/dev/null` 2>/dev/null || true; rm -f .server.pid' INT TERM EXIT; \
echo "Starting server in background on port $(PORT)..."; \
./bin/gts server --port $(PORT) & echo $$! > .server.pid; \
sleep 2; \
echo "Running e2e tests..."; \
PYTHONDONTWRITEBYTECODE=1 $(VENV_PY) -m pytest -p no:cacheprovider --log-file=e2e.log --gts-base-url http://127.0.0.1:$(PORT) ./.gts-spec/tests; \
echo "E2E tests completed successfully"

# Run all quality checks
check: fmt vet lint test e2e
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,23 @@ export GTS_BASE_URL=http://127.0.0.1:8001
pytest
```

#### Raising the open-files limit (macOS)

The gts-spec suite uses `httprunner`, which leaks a keep-alive socket per
test class (the underlying `requests.Session` is never closed). File
descriptors grow linearly and hit macOS's default 256 soft cap around
test ~240 with `EMFILE: Too many open files`. Before running the full
suite, raise the limit in your shell:

```bash
ulimit -n 4096
pytest
```

Linux defaults (typically 1024+) are usually high enough, but bumping
the limit there is harmless. `make e2e` runs in a subshell, so set
`ulimit` in the shell that invokes it.

## License

Apache License 2.0
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ require github.com/google/uuid v1.6.0

require github.com/santhosh-tekuri/jsonschema/v6 v6.0.2

require golang.org/x/text v0.14.0 // indirect
require golang.org/x/text v0.36.0
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
54 changes: 40 additions & 14 deletions gts/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ func NewJsonEntityWithFile(content map[string]any, cfg *GtsConfig, file *JsonFil
return entity
}

// EffectiveID returns the identifier used to key this entity in a registry
// and echo back to clients. Resolution order:
// 1. Parsed GTS ID (schemas and well-known instances).
// 2. Raw id field value for non-schemas (anonymous instances, spec §3.7) —
// used even when the schema reference cannot be resolved; schema presence
// is enforced at validation time, not at registration time.
// 3. File path (+ list sequence for multi-entity files) when the entity
// originated from a file. Mirrors gts-rust.
//
// Returns "" if none of the above apply.
func (e *JsonEntity) EffectiveID() string {
if e.GtsID != nil && e.GtsID.ID != "" {
return e.GtsID.ID
}
if !e.IsSchema && e.SelectedEntityField != "" {
if val, ok := e.Content[e.SelectedEntityField].(string); ok {
if id := strings.TrimSpace(val); id != "" {
return id
}
}
}
if e.File != nil {
if e.ListSequence != nil {
return fmt.Sprintf("%s#%d", e.File.Path, *e.ListSequence)
}
return e.File.Path
}
return ""
}

// setLabel sets the entity's label based on file, sequence, or GTS ID
func (e *JsonEntity) setLabel() {
if e.File != nil && e.ListSequence != nil {
Expand Down Expand Up @@ -256,20 +286,16 @@ func ExtractID(content map[string]any, cfg *GtsConfig) *ExtractIDResult {
result.SelectedSchemaIDField = &entity.SelectedSchemaIDField
}

// 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
}
}
// Diagnostic: for schemas and well-known instances, return the parsed GTS ID.
// For non-schema entities without a valid GTS ID (anonymous or malformed),
// fall back to the raw value of the selected id field. This differs from
// EffectiveID (registrable), which requires a resolvable schema.
switch {
case entity.GtsID != nil:
result.ID = entity.GtsID.ID
case !entity.IsSchema && entity.SelectedEntityField != "":
if val, ok := content[entity.SelectedEntityField].(string); ok {
result.ID = val
}
}

Expand Down
14 changes: 14 additions & 0 deletions gts/schema_compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ func (s *GtsStore) ValidateSchemaChain(schemaID string) *ValidateSchemaChainResu
baseID := buildIDFromSegments(segments[:i+1])
derivedID := buildIDFromSegments(segments[:i+2])

// Check x-gts-final: if the base type is final, derivation is not allowed.
baseEntity := s.Get(baseID)
if baseEntity != nil {
if isFinal, ok := baseEntity.Content[KeyXGtsFinal]; ok {
if final, isBool := isFinal.(bool); isBool && final {
return &ValidateSchemaChainResult{
SchemaID: schemaID,
OK: false,
Error: fmt.Sprintf("base type '%s' is final and cannot be extended", baseID),
}
}
}
}

baseContent, err := s.resolveSchemaRefsChecked(baseID)
if err != nil {
return &ValidateSchemaChainResult{
Expand Down
77 changes: 77 additions & 0 deletions gts/schema_modifiers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
Copyright © 2025 Global Type System
Released under Apache License 2.0
*/

package gts

import "fmt"

const (
KeyXGtsFinal = "x-gts-final"
KeyXGtsAbstract = "x-gts-abstract"
)

// ValidateSchemaModifiers checks that x-gts-final and x-gts-abstract are well-formed:
// boolean type, not both true, and not placed inside allOf entries at any depth.
func ValidateSchemaModifiers(content map[string]any) error {
final, err := readBoolModifier(content, KeyXGtsFinal)
if err != nil {
return err
}
abstract, err := readBoolModifier(content, KeyXGtsAbstract)
if err != nil {
return err
}
if final && abstract {
return fmt.Errorf("schema cannot declare both %s and %s as true", KeyXGtsFinal, KeyXGtsAbstract)
}
return validateModifierPlacement(content)
}

func readBoolModifier(content map[string]any, key string) (bool, error) {
val, ok := content[key]
if !ok {
return false, nil
}
b, isBool := val.(bool)
if !isBool {
return false, fmt.Errorf("%s must be a boolean, got %T", key, val)
}
return b, nil
}

func validateModifierPlacement(content map[string]any) error {
allOf, ok := content["allOf"].([]any)
if !ok {
return nil
}
for _, item := range allOf {
entry, ok := item.(map[string]any)
if !ok {
continue
}
if _, has := entry[KeyXGtsFinal]; has {
return fmt.Errorf("%s must be at the schema top level, not inside allOf", KeyXGtsFinal)
}
if _, has := entry[KeyXGtsAbstract]; has {
return fmt.Errorf("%s must be at the schema top level, not inside allOf", KeyXGtsAbstract)
}
if err := validateModifierPlacement(entry); err != nil {
return err
}
}
return nil
}

// ValidateInstanceModifiers checks that schema-only keywords (x-gts-final, x-gts-abstract)
// do not appear in instance content.
func ValidateInstanceModifiers(content map[string]any) error {
if _, ok := content[KeyXGtsFinal]; ok {
return fmt.Errorf("%s is a schema-only keyword and must not appear in instances", KeyXGtsFinal)
}
if _, ok := content[KeyXGtsAbstract]; ok {
return fmt.Errorf("%s is a schema-only keyword and must not appear in instances", KeyXGtsAbstract)
}
return nil
}
Loading
Loading