Skip to content
Draft
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
166 changes: 166 additions & 0 deletions cli/cmd/convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/xschemadev/xschema/fetcher"
"github.com/xschemadev/xschema/generator"
"github.com/xschemadev/xschema/processor"
"github.com/xschemadev/xschema/retriever"
"github.com/xschemadev/xschema/ui"
)

var (
convertAdapter string
convertLang string
convertProject string
convertVerbose bool
convertAllowFetch bool
)

var convertCmd = &cobra.Command{
Use: "convert",
Short: "Convert JSON schemas from stdin to native validators via stdout",
Long: `Read JSON schema array from stdin, process and convert via an adapter, output JSON array to stdout.

All status/error output goes to stderr. Only the JSON result goes to stdout.

Examples:
echo '[{"namespace":"test","id":"User","schema":{"type":"object","properties":{"name":{"type":"string"}}}}]' | xschema convert --adapter @xschemadev/zod

cat schemas.json | xschema convert --adapter @xschemadev/zod --allow-fetch`,
RunE: runConvert,
}

func init() {
rootCmd.AddCommand(convertCmd)

convertCmd.Flags().StringVar(&convertAdapter, "adapter", "", "adapter package ref (e.g., @xschemadev/zod)")
convertCmd.Flags().StringVar(&convertLang, "lang", "typescript", "target language")
convertCmd.Flags().StringVar(&convertProject, "project", "", "project root directory (default: current directory)")
convertCmd.Flags().BoolVarP(&convertVerbose, "verbose", "v", false, "show verbose output on stderr")
convertCmd.Flags().BoolVar(&convertAllowFetch, "allow-fetch", false, "allow fetching external $ref URIs")

_ = convertCmd.MarkFlagRequired("adapter")
}

// ConvertSchemaInput is the expected shape of each item in the stdin JSON array.
type ConvertSchemaInput struct {
Namespace string `json:"namespace"`
ID string `json:"id"`
Schema json.RawMessage `json:"schema"`
}

func runConvert(cmd *cobra.Command, args []string) error {
ui.SetVerbose(convertVerbose)
ctx := cmd.Context()

// Determine project root
root := convertProject
if root == "" {
var err error
root, err = os.Getwd()
if err != nil {
return writeJSONError(cmd, fmt.Errorf("failed to get current directory: %w", err))
}
}

// Read stdin
stdinData, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return writeJSONError(cmd, fmt.Errorf("failed to read stdin: %w", err))
}
if len(stdinData) == 0 {
return writeJSONError(cmd, fmt.Errorf("no input provided on stdin"))
}

// Parse input
var inputs []ConvertSchemaInput
if err := json.Unmarshal(stdinData, &inputs); err != nil {
return writeJSONError(cmd, fmt.Errorf("invalid JSON input: %w", err))
}

if len(inputs) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "[]")
return nil
}

// Convert to retriever.RetrievedSchema with inline source type
schemas := make([]retriever.RetrievedSchema, len(inputs))
for i, in := range inputs {
Comment on lines +90 to +97
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing SourceURI breaks refs

retriever.RetrievedSchema.SourceURI is used as the base URI for resolving relative $ref values and for bundler error context/caching. In convert, schemas are constructed without SourceURI, so any schema containing relative refs (or circular refs that rely on cache seeding) can fail or resolve incorrectly. Consider setting a stable synthetic URI per input (e.g. inline://<namespace>/<id>), or accept sourceURI in ConvertSchemaInput and plumb it through.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/cmd/convert.go
Line: 90:97

Comment:
**Missing SourceURI breaks refs**

`retriever.RetrievedSchema.SourceURI` is used as the base URI for resolving relative `$ref` values and for bundler error context/caching. In `convert`, schemas are constructed without `SourceURI`, so any schema containing relative refs (or circular refs that rely on cache seeding) can fail or resolve incorrectly. Consider setting a stable synthetic URI per input (e.g. `inline://<namespace>/<id>`), or accept `sourceURI` in `ConvertSchemaInput` and plumb it through.


How can I resolve this? If you propose a fix, please make it concise.

schemas[i] = retriever.RetrievedSchema{
Namespace: in.Namespace,
ID: in.ID,
Schema: in.Schema,
Adapter: convertAdapter,
}
}

// Process schemas (validate, crawl refs, bundle)
sharedCache := fetcher.NewSharedCache()
var f fetcher.Fetcher
if convertAllowFetch {
retOpts := retriever.DefaultOptions()
retOpts.Cache = sharedCache
f = newRetrieverFetcher(ctx, retOpts)
} else {
f = fetcher.FetchFunc(noFetchFetcher)
}

processed, err := processor.Process(ctx, schemas, processor.Options{
Fetcher: f,
OnVerbose: convertVerboseCallback(),
Cache: sharedCache,
})
if err != nil {
return writeJSONError(cmd, fmt.Errorf("processing failed: %w", err))
}

// Generate via adapter
outputs, err := generator.GenerateAll(ctx, processed, convertLang, root)
if err != nil {
return writeJSONError(cmd, fmt.Errorf("generation failed: %w", err))
}

// Write JSON result to stdout
result, err := json.Marshal(outputs)
if err != nil {
return writeJSONError(cmd, fmt.Errorf("failed to marshal output: %w", err))
}
fmt.Fprintln(cmd.OutOrStdout(), string(result))

return nil
}

// noFetchFetcher returns an error explaining that external $refs require --allow-fetch.
func noFetchFetcher(_ context.Context, uri string) (json.RawMessage, error) {
if strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://") {
return nil, fmt.Errorf("external $ref %q requires --allow-fetch flag", uri)
}
// File refs also blocked without --allow-fetch for convert (schemas should be self-contained)
return nil, fmt.Errorf("external $ref %q requires --allow-fetch flag", uri)
}

// writeJSONError writes a JSON error object to stderr and returns the error for cobra exit code handling.
func writeJSONError(cmd *cobra.Command, err error) error {
errObj := map[string]string{"error": err.Error()}
data, _ := json.Marshal(errObj)
fmt.Fprintln(cmd.ErrOrStderr(), string(data))
return err
}

func convertVerboseCallback() func(string) {
if !convertVerbose {
return nil
}
return func(msg string) {
ui.Verbosef("%s", msg)
}
}
227 changes: 227 additions & 0 deletions cli/cmd/convert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package cmd

import (
"bytes"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/xschemadev/xschema/adapter"
_ "github.com/xschemadev/xschema/language/langs"
)

// adapterCLIPath returns the absolute path to the zod adapter CLI.
// Returns empty string if the adapter isn't built.
func adapterCLIPath() string {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
return ""
}
// cli/cmd/convert_test.go -> project root is ../../
cliDir := filepath.Dir(filepath.Dir(thisFile))
p := filepath.Join(cliDir, "..", "typescript", "packages", "adapters", "zod", "dist", "cli.js")
if _, err := os.Stat(p); err != nil {
return ""
}
abs, _ := filepath.Abs(p)
return abs
}

// setupTestProject creates a temp dir that looks like a bun project with the
// adapter binary symlinked into node_modules/.bin. Returns the project dir.
func setupTestProject(t *testing.T) string {
t.Helper()

cliPath := adapterCLIPath()
if cliPath == "" {
t.Skip("zod adapter CLI not built; run bun run build from typescript/")
}
if _, err := exec.LookPath("bunx"); err != nil {
t.Skip("bunx not found in PATH")
}

dir := t.TempDir()

// Minimal package.json so runner detection works
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0644); err != nil {
t.Fatalf("write package.json: %v", err)
}
// bun.lock so detectRunnerInDir picks bunx
if err := os.WriteFile(filepath.Join(dir, "bun.lock"), []byte(`{}`), 0644); err != nil {
t.Fatalf("write bun.lock: %v", err)
}
// Symlink adapter binary
binDir := filepath.Join(dir, "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
if err := os.Symlink(cliPath, filepath.Join(binDir, "xschema-zod")); err != nil {
t.Fatalf("symlink adapter: %v", err)
}

return dir
}

// executeConvert runs the convert command with the given args and stdin, returning stdout, stderr, and error.
func executeConvert(t *testing.T, args []string, stdin string) (stdout, stderr string, err error) {
t.Helper()

var outBuf, errBuf bytes.Buffer
rootCmd.SetOut(&outBuf)
rootCmd.SetErr(&errBuf)
rootCmd.SetIn(strings.NewReader(stdin))
rootCmd.SetArgs(args)

err = rootCmd.ExecuteContext(context.Background())

// Reset io for other tests
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
rootCmd.SetIn(nil)

return outBuf.String(), errBuf.String(), err
}

func TestConvert_ValidSingleSchema(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
projectDir := setupTestProject(t)

input := `[{"namespace":"test","id":"User","schema":{"type":"object","properties":{"name":{"type":"string"}}}}]`
args := []string{"convert", "--adapter", "@xschemadev/zod", "--project", projectDir}

stdout, _, err := executeConvert(t, args, input)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

var results []adapter.ConvertResult
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("failed to parse stdout as JSON: %v\nstdout: %s", err, stdout)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}

r := results[0]
if r.Namespace != "test" {
t.Errorf("namespace: got %q, want %q", r.Namespace, "test")
}
if r.ID != "User" {
t.Errorf("id: got %q, want %q", r.ID, "User")
}
if r.Schema == "" {
t.Error("schema should not be empty")
}
if r.Type == "" {
t.Error("type should not be empty")
}
if r.VarName == "" {
t.Error("varName should not be empty")
}
}

func TestConvert_MultipleSchemas(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
projectDir := setupTestProject(t)

input := `[
{"namespace":"users","id":"User","schema":{"type":"object","properties":{"name":{"type":"string"}}}},
{"namespace":"posts","id":"Post","schema":{"type":"object","properties":{"title":{"type":"string"}}}}
]`
args := []string{"convert", "--adapter", "@xschemadev/zod", "--project", projectDir}

stdout, _, err := executeConvert(t, args, input)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

var results []adapter.ConvertResult
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("failed to parse stdout as JSON: %v\nstdout: %s", err, stdout)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}

keys := map[string]bool{}
for _, r := range results {
keys[r.Key()] = true
if r.Schema == "" {
t.Errorf("schema empty for %s", r.Key())
}
if r.Type == "" {
t.Errorf("type empty for %s", r.Key())
}
}
if !keys["users:User"] {
t.Error("missing users:User in results")
}
if !keys["posts:Post"] {
t.Error("missing posts:Post in results")
}
}

func TestConvert_MissingAdapterFlag(t *testing.T) {
args := []string{"convert"}
_, _, err := executeConvert(t, args, `[{"namespace":"t","id":"T","schema":{"type":"string"}}]`)
if err == nil {
t.Fatal("expected error for missing --adapter flag")
}
if !strings.Contains(err.Error(), "adapter") {
t.Errorf("error should mention adapter flag, got: %v", err)
}
}

func TestConvert_InvalidJSONStdin(t *testing.T) {
args := []string{"convert", "--adapter", "@xschemadev/zod"}
_, stderr, err := executeConvert(t, args, `not valid json`)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "invalid JSON") {
t.Errorf("error should mention invalid JSON, got: %v", err)
}
// stderr should contain JSON error object
if !strings.Contains(stderr, "invalid JSON") {
t.Errorf("stderr should contain JSON error, got: %s", stderr)
}
}

func TestConvert_EmptyArrayStdin(t *testing.T) {
args := []string{"convert", "--adapter", "@xschemadev/zod"}
stdout, _, err := executeConvert(t, args, `[]`)
if err != nil {
t.Fatalf("expected no error for empty array, got: %v", err)
}
trimmed := strings.TrimSpace(stdout)
if trimmed != "[]" {
t.Errorf("expected empty array output, got: %q", trimmed)
}
}

func TestConvert_ExternalRefWithoutAllowFetch(t *testing.T) {
// Schema with an external $ref should fail without --allow-fetch
// and the error message should mention --allow-fetch
input := `[{"namespace":"test","id":"Ref","schema":{"$ref":"https://example.com/schema.json"}}]`
args := []string{"convert", "--adapter", "@xschemadev/zod"}

_, stderr, err := executeConvert(t, args, input)
if err == nil {
t.Fatal("expected error for external $ref without --allow-fetch")
}
if !strings.Contains(err.Error(), "--allow-fetch") {
t.Errorf("error should mention --allow-fetch, got: %v", err)
}
if !strings.Contains(stderr, "--allow-fetch") {
t.Errorf("stderr should mention --allow-fetch, got: %s", stderr)
}
}
Loading
Loading