Skip to content

Commit 5919747

Browse files
authored
fix: cmd structure (#14)
* feat: change cmd structure * fix: upgrade deps * fix: upgrade deps
1 parent f3a2ac5 commit 5919747

12 files changed

Lines changed: 488 additions & 317 deletions

File tree

bowtie/doc.go

Lines changed: 0 additions & 33 deletions
This file was deleted.

bowtie/main.go renamed to cmd/jsonschema/bowtie.go

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"errors"
88
"fmt"
99
"io"
10-
"os"
1110

1211
"github.com/go-rotini/jsonschema"
1312
)
@@ -37,7 +36,7 @@ var supportedDialects = []string{
3736
}
3837

3938
// errStop is the sentinel returned by the dispatch loop when the harness
40-
// sends a "stop" command. main treats it as a graceful exit.
39+
// sends a "stop" command. runBowtie treats it as a graceful exit.
4140
var errStop = errors.New("bowtie: stop")
4241

4342
// errEmptyCaseSchema is returned by compileCaseSchema when the case
@@ -146,22 +145,42 @@ type state struct {
146145
dialect jsonschema.Draft
147146
}
148147

149-
func main() {
150-
os.Exit(run(os.Stdin, os.Stdout, os.Stderr))
151-
}
152-
153-
// run is the testable core of main: it drives the dispatch loop against
154-
// the supplied I/O streams and returns the exit code main should pass to
155-
// os.Exit. Splitting the os.Exit call out keeps main itself a thin
156-
// shell while letting the package's tests cover the I/O wiring path.
157-
func run(in io.Reader, out, errOut io.Writer) int {
158-
if err := dispatch(in, out); err != nil && !errors.Is(err, errStop) {
159-
fmt.Fprintln(errOut, "bowtie:", err)
148+
// runBowtie is the "bowtie" subcommand: the stdin/stdout adapter that exposes
149+
// go-rotini/jsonschema to the Bowtie cross-implementation conformance harness
150+
// (https://bowtie.report). It takes no flags; the harness drives it over a
151+
// single-line-JSON request/response protocol on stdin/stdout. Recognized
152+
// commands are "start" (handshake → implementation descriptor), "dialect" (pin
153+
// a meta-schema URI), "run" (one case: compile + per-test results), and "stop"
154+
// (exit 0). The dispatch loop is factored out as dispatch(in, out) so tests can
155+
// drive it over bytes.Buffer pipes.
156+
func runBowtie(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
157+
// bowtie takes no flags, so any argument is either a help request or a
158+
// mistake — reject it rather than silently blocking on stdin.
159+
if len(args) > 0 {
160+
switch args[0] {
161+
case "-h", "--help", "help":
162+
bowtieUsage(stdout)
163+
return 0
164+
default:
165+
fmt.Fprintf(stderr, "bowtie: unexpected argument %q\n", args[0])
166+
bowtieUsage(stderr)
167+
return 2
168+
}
169+
}
170+
if err := dispatch(stdin, stdout); err != nil && !errors.Is(err, errStop) {
171+
fmt.Fprintln(stderr, "bowtie:", err)
160172
return 1
161173
}
162174
return 0
163175
}
164176

177+
// bowtieUsage writes the bowtie subcommand summary.
178+
func bowtieUsage(w io.Writer) {
179+
fmt.Fprintln(w, "usage: jsonschema bowtie")
180+
fmt.Fprintln(w, "Bowtie conformance-harness connector. Takes no flags; the harness drives")
181+
fmt.Fprintln(w, "it over a single-line-JSON request/response protocol on stdin/stdout.")
182+
}
183+
165184
// dispatch is the read-eval-print loop; tests drive it over bytes.Buffer
166185
// pipes instead of invoking the binary.
167186
func dispatch(in io.Reader, out io.Writer) error {
@@ -176,9 +195,6 @@ func dispatch(in io.Reader, out io.Writer) error {
176195
continue
177196
}
178197
if err := handleLine(line, st, enc); err != nil {
179-
if errors.Is(err, errStop) {
180-
return err
181-
}
182198
return err
183199
}
184200
}
Lines changed: 31 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -358,33 +358,6 @@ func TestRunEmptySchema(t *testing.T) {
358358
}
359359
}
360360

361-
// TestRunRegistryAddResourceFailure covers the registry-add failure branch
362-
// of newCaseCompiler indirectly: with the registry pre-populated with
363-
// invalid JSON bytes, the failure surfaces as an errored response from
364-
// handleRun.
365-
func TestRunRegistryAddResourceFailure(t *testing.T) {
366-
// Build the case envelope by hand so we can stuff bare text into the
367-
// registry value (json.RawMessage decodes the value as opaque bytes).
368-
// The trick: use a JSON string whose content is non-JSON text — that
369-
// then gets passed verbatim to AddResource, which fails to decode it.
370-
caseRaw := `{` +
371-
`"description":"registry fail",` +
372-
`"schema":{"type":"string"},` +
373-
`"registry":{"https://example.com/x":"not a JSON object"},` +
374-
`"tests":[{"description":"x","instance":"y"}]` +
375-
`}`
376-
var in bytes.Buffer
377-
in.WriteString(`{"cmd":"start","version":1}` + "\n")
378-
in.WriteString(`{"cmd":"run","seq":1,"case":` + caseRaw + `}` + "\n")
379-
in.WriteString(`{"cmd":"stop"}` + "\n")
380-
var out bytes.Buffer
381-
_ = dispatch(&in, &out)
382-
// The actual bytes passed to AddResource for "not a JSON object" are
383-
// `"not a JSON object"` — a valid JSON string. AddResource accepts JSON
384-
// strings as boolean schemas? Check the response: it should not panic.
385-
// Even if the registry add succeeds, the test still exercises the path.
386-
}
387-
388361
// TestEvaluateOneInstanceDecodeFailure covers the decode-error branch of
389362
// evaluateOne by calling it directly with a malformed raw message.
390363
func TestEvaluateOneInstanceDecodeFailure(t *testing.T) {
@@ -477,13 +450,10 @@ func TestDialectStateRecorded(t *testing.T) {
477450
}
478451
}
479452

480-
// TestEvaluateOnePanicRecovery synthesizes a panic mid-validation by passing
481-
// instance bytes that decode fine but a schema whose evaluator panics. There
482-
// is no public path that panics on validation; instead we exercise the
483-
// recover() defense by calling evaluateOne directly with a nil schema.
484-
func TestEvaluateOnePanicRecovery(t *testing.T) {
485-
// Calling ValidateValue on a nil schema returns an error, not a panic;
486-
// instead the test confirms the err-return branch within evaluateOne.
453+
// TestEvaluateOneValidateError covers evaluateOne's ValidateValue-error
454+
// branch: a nil schema makes ValidateValue return an error (not a panic),
455+
// which evaluateOne reports as an errored result.
456+
func TestEvaluateOneValidateError(t *testing.T) {
487457
res := evaluateOne(nil, json.RawMessage(`"x"`))
488458
if !res.Errored {
489459
t.Errorf("expected errored=true, got %+v", res)
@@ -592,74 +562,51 @@ func TestDispatchSkipsBlankLines(t *testing.T) {
592562
}
593563
}
594564

595-
// TestRunHelperHappyPath covers the run() helper's success path.
565+
// TestRunHelperHappyPath covers the runBowtie helper's success path.
596566
func TestRunHelperHappyPath(t *testing.T) {
597567
in := bytes.NewBufferString(`{"cmd":"stop"}` + "\n")
598568
var out, errOut bytes.Buffer
599-
if code := run(in, &out, &errOut); code != 0 {
569+
if code := runBowtie(nil, in, &out, &errOut); code != 0 {
600570
t.Errorf("run = %d, want 0; err=%q", code, errOut.String())
601571
}
602572
}
603573

604-
// TestRunHelperErrorPath covers the run() helper's non-stop-error path.
574+
// TestRunHelperErrorPath covers the runBowtie helper's non-stop-error path.
605575
func TestRunHelperErrorPath(t *testing.T) {
606576
in := bytes.NewBufferString("not json\n")
607577
var out, errOut bytes.Buffer
608-
if code := run(in, &out, &errOut); code != 1 {
578+
if code := runBowtie(nil, in, &out, &errOut); code != 1 {
609579
t.Errorf("run = %d, want 1", code)
610580
}
611581
if !strings.Contains(errOut.String(), "bowtie:") {
612582
t.Errorf("expected 'bowtie:' prefix in errOut: %q", errOut.String())
613583
}
614584
}
615585

616-
// TestHandleRunNewCaseCompilerError covers the newCaseCompiler-failure
617-
// branch of handleRun by directly invoking it with a case envelope whose
618-
// registry contains malformed JSON.
619-
func TestHandleRunNewCaseCompilerError(t *testing.T) {
620-
// Construct a case envelope with a registry value that's a JSON string
621-
// (valid in outer parse) — but stored in the case's Registry field as
622-
// a json.RawMessage holding bare-text bytes the schema decoder can
623-
// reject.
624-
tc := testCase{
625-
Description: "x",
626-
Schema: json.RawMessage(`{"type":"string"}`),
627-
Registry: map[string]json.RawMessage{
628-
"https://example.com/x": json.RawMessage(`not json`),
629-
},
630-
Tests: []testInstance{{Description: "x", Instance: json.RawMessage(`"y"`)}},
586+
// TestRunBowtie_help confirms -h/--help/help print the bowtie usage to stdout
587+
// and exit 0 without blocking on stdin.
588+
func TestRunBowtie_help(t *testing.T) {
589+
for _, arg := range []string{"-h", "--help", "help"} {
590+
var out, errOut bytes.Buffer
591+
// errReader guarantees the protocol loop is never entered: help must
592+
// short-circuit before any stdin read.
593+
if code := runBowtie([]string{arg}, errReader{}, &out, &errOut); code != 0 {
594+
t.Fatalf("%s: exit=%d, want 0", arg, code)
595+
}
596+
if !strings.Contains(out.String(), "usage: jsonschema bowtie") {
597+
t.Errorf("%s: stdout missing the bowtie usage:\n%s", arg, out.String())
598+
}
631599
}
632-
tcRaw, err := json.Marshal(tc)
633-
if err != nil {
634-
// json.Marshal will fail for invalid RawMessage; fall back to
635-
// constructing the bytes manually.
636-
t.Logf("Marshal failed (expected for invalid RawMessage): %v", err)
637-
// Construct case bytes by hand: outer JSON is fine; the registry
638-
// value is a JSON string (valid).
639-
raw := `{"description":"x","schema":{"type":"string"},"registry":{"https://example.com/x":"not json"},"tests":[{"description":"x","instance":"y"}]}`
640-
tcRaw = []byte(raw)
641-
}
642-
var enc bytes.Buffer
643-
cmd := command{Cmd: "run", Seq: json.RawMessage(`1`), Case: tcRaw}
644-
st := &state{}
645-
encoder := json.NewEncoder(&enc)
646-
if err := handleRun(cmd, st, encoder); err != nil {
647-
t.Logf("handleRun: %v", err)
648-
}
649-
// We don't assert specific output; the goal is to drive the
650-
// newCaseCompiler-failure branch. With a "not json" registry value
651-
// stored as a JSON string, AddResource sees `"not json"` (valid JSON
652-
// string), which DECODES OK. So instead try a registry value that's
653-
// not-quite-valid: e.g. a duplicated value through a custom envelope.
654600
}
655601

656-
// TestEvaluateOnePanicViaInjectedSchema covers the panic-recovery branch.
657-
// We directly invoke evaluateOne with a *jsonschema.Schema constructed in
658-
// a way that triggers a runtime panic. Without source modification, this
659-
// branch is hard to hit; we accept that coverage on the recovery path may
660-
// remain uncovered when no public API path provokes a panic.
661-
func TestEvaluateOnePanicCoverageBestEffort(t *testing.T) {
662-
// Best-effort: call with a recursive schema and a deeply nested value
663-
// to provoke a stack overflow if any. This rarely panics in practice.
664-
t.Skip("evaluateOne panic recovery requires runtime panic to trigger; not reachable from public API")
602+
// TestRunBowtie_unexpectedArg confirms an unrecognized argument is rejected
603+
// (exit 2) instead of being ignored and blocking on stdin.
604+
func TestRunBowtie_unexpectedArg(t *testing.T) {
605+
var out, errOut bytes.Buffer
606+
if code := runBowtie([]string{"--nope"}, errReader{}, &out, &errOut); code != 2 {
607+
t.Fatalf("unexpected arg: exit=%d, want 2", code)
608+
}
609+
if !strings.Contains(errOut.String(), `unexpected argument "--nope"`) {
610+
t.Errorf("unexpected arg: stderr missing the rejection:\n%s", errOut.String())
611+
}
665612
}

cmd/jsonschema/generate.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"flag"
6+
"fmt"
7+
"io"
8+
"os"
9+
10+
"github.com/go-rotini/jsonschema"
11+
)
12+
13+
// runGenerate parses the generate flags, generates Go types from a JSON Schema,
14+
// and returns the exit code. Each flag has a short and a long form bound to the
15+
// same variable: -p/--package, -r/--root, -o/--output.
16+
func runGenerate(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
17+
flags := flag.NewFlagSet("jsonschema generate", flag.ContinueOnError)
18+
flags.SetOutput(stderr)
19+
var pkg, root, outPath string
20+
flags.StringVar(&pkg, "package", "models", "package name for the generated file")
21+
flags.StringVar(&pkg, "p", "models", "")
22+
flags.StringVar(&root, "root", "", `name of the root type (default: schema "title", else "Root")`)
23+
flags.StringVar(&root, "r", "", "")
24+
flags.StringVar(&outPath, "output", "", "write output to this file instead of stdout")
25+
flags.StringVar(&outPath, "o", "", "")
26+
flags.Usage = func() {
27+
fmt.Fprintln(stderr, "usage: jsonschema generate [flags] [schema-file]")
28+
fmt.Fprintln(stderr, "reads a JSON Schema (from schema-file or stdin) and writes Go types")
29+
fmt.Fprintln(stderr, ` -p, --package string package name for the generated file (default "models")`)
30+
fmt.Fprintln(stderr, ` -r, --root string name of the root type (default: schema "title", else "Root")`)
31+
fmt.Fprintln(stderr, ` -o, --output string write output to this file instead of stdout`)
32+
}
33+
if err := flags.Parse(args); err != nil {
34+
if errors.Is(err, flag.ErrHelp) {
35+
return 0
36+
}
37+
return 2
38+
}
39+
40+
schemaJSON, err := readSchema(flags.Arg(0), stdin)
41+
if err != nil {
42+
fmt.Fprintln(stderr, "jsonschema:", err)
43+
return 1
44+
}
45+
46+
opts := []jsonschema.GoOption{jsonschema.WithGoPackage(pkg)}
47+
if root != "" {
48+
opts = append(opts, jsonschema.WithGoRootType(root))
49+
}
50+
src, err := jsonschema.GenerateGo(schemaJSON, opts...)
51+
if err != nil {
52+
fmt.Fprintln(stderr, "jsonschema:", err)
53+
return 1
54+
}
55+
56+
if err := writeOutput(outPath, src, stdout); err != nil {
57+
fmt.Fprintln(stderr, "jsonschema:", err)
58+
return 1
59+
}
60+
return 0
61+
}
62+
63+
// readSchema reads schema bytes from path, or from stdin when path is empty.
64+
func readSchema(path string, stdin io.Reader) ([]byte, error) {
65+
if path == "" {
66+
data, err := io.ReadAll(stdin)
67+
if err != nil {
68+
return nil, fmt.Errorf("read stdin: %w", err)
69+
}
70+
return data, nil
71+
}
72+
data, err := os.ReadFile(path)
73+
if err != nil {
74+
return nil, fmt.Errorf("read schema file: %w", err)
75+
}
76+
return data, nil
77+
}
78+
79+
// writeOutput writes src to path, or to stdout when path is empty.
80+
func writeOutput(path string, src []byte, stdout io.Writer) error {
81+
if path == "" {
82+
if _, err := stdout.Write(src); err != nil {
83+
return fmt.Errorf("write stdout: %w", err)
84+
}
85+
return nil
86+
}
87+
if err := os.WriteFile(path, src, 0o600); err != nil {
88+
return fmt.Errorf("write output file: %w", err)
89+
}
90+
return nil
91+
}

0 commit comments

Comments
 (0)