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
32 changes: 30 additions & 2 deletions cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"strings"
"time"

"github.com/xraph/forge"
Expand Down Expand Up @@ -280,6 +281,17 @@ func parseFlagsForCommand(cmd Command, args []string) (map[string]*flagValue, []
return nil, nil, fmt.Errorf("validation failed for flag %s: %w", name, err)
}

// A slice flag accumulates across repeats instead of replacing, which
// is what makes it a slice rather than a string that took the last
// word.
if flagDef.Type() == StringSliceFlagType {
if existing, ok := flagValues[flagDef.Name()]; ok && existing.IsSet() {
if added, ok := parsedValue.([]string); ok {
parsedValue = append(existing.StringSlice(), added...)
}
}
}

flagValues[flagDef.Name()] = &flagValue{
rawValue: parsedValue,
isSet: true,
Expand Down Expand Up @@ -310,12 +322,28 @@ func parseValue(value string, flagType FlagType) (any, error) {
case BoolFlagType:
return value == "true" || value == "1" || value == "yes", nil
case StringSliceFlagType:
// Support comma-separated values
// Comma-separated within one occurrence, and repeatable across
// several; the two compose. Both forms were previously claimed by the
// comments here and delivered by neither — the value was wrapped whole
// and each occurrence replaced the last, so `--flag a --flag b` kept
// only b and `--flag a,b` was a single element named "a,b".
//
// Empty segments are dropped, so a trailing comma does not become a
// pattern that silently matches nothing.
if value == "" {
return []string{}, nil
}

return []string{value}, nil // Single value, can be called multiple times
parts := strings.Split(value, ",")
values := make([]string, 0, len(parts))

for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
values = append(values, trimmed)
}
}

return values, nil
case DurationFlagType:
d, err := time.ParseDuration(value)

Expand Down
61 changes: 61 additions & 0 deletions cli/slice_flag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package cli

import "testing"

// TestStringSliceFlagParsing pins both forms the flag has always claimed to
// support and previously supported neither of: comma separation within one
// occurrence, and accumulation across several.
//
// The old behaviour wrapped the value whole and let each occurrence replace
// the last, so `--exclude /a --exclude /b` silently generated a client that
// still contained everything under /a.
func TestStringSliceFlagParsing(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{"single", "/api", []string{"/api"}},
{"comma separated", "/api,/identity", []string{"/api", "/identity"}},
{"spaces are trimmed", "/api, /identity", []string{"/api", "/identity"}},
{"empty segments dropped", "/api,,", []string{"/api"}},
{"empty", "", []string{}},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseValue(tc.in, StringSliceFlagType)
if err != nil {
t.Fatalf("parseValue: %v", err)
}

values, ok := got.([]string)
if !ok {
t.Fatalf("parseValue returned %T, want []string", got)
}

if len(values) != len(tc.want) {
t.Fatalf("parseValue(%q) = %v, want %v", tc.in, values, tc.want)
}

for i := range tc.want {
if values[i] != tc.want[i] {
t.Errorf("parseValue(%q) = %v, want %v", tc.in, values, tc.want)

break
}
}
})
}
}

// TestStringSliceFlagValueSplitsRawString covers the accessor's own fallback,
// which splits a raw string that never went through parseValue.
func TestStringSliceFlagValueSplitsRawString(t *testing.T) {
fv := &flagValue{rawValue: "/api,/identity", isSet: true}

got := fv.StringSlice()
if len(got) != 2 || got[0] != "/api" || got[1] != "/identity" {
t.Errorf("StringSlice() = %v, want [/api /identity]", got)
}
}
56 changes: 52 additions & 4 deletions cmd/forge/plugins/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ func (p *ClientPlugin) Commands() []cli.Command {
// own per-language default applies (camel for typescript, preserve
// otherwise) so omitting this flag changes nothing for existing users.
cli.WithFlag(cli.NewStringFlag("field-naming", "", "Client-side field naming strategy: camel, pascal, snake, or preserve (default: camel for typescript, preserve otherwise)", "")),
cli.WithFlag(cli.NewBoolFlag("react-query", "", "Generate TanStack Query hooks over the client", false)),
cli.WithFlag(cli.NewStringSliceFlag("include", "", "Only generate endpoints whose path matches a pattern (repeatable; prefix, glob or `/**`)", nil)),
cli.WithFlag(cli.NewStringSliceFlag("exclude", "", "Skip endpoints whose path matches a pattern; applied after --include (repeatable)", nil)),
cli.WithFlag(cli.NewStringFlag("field-overrides", "", "Comma-separated field name overrides, e.g. 'User.user_id=userIdentifier,api_key=apiKey' (schema-scoped keys use \"Schema.wire_name\"; a bare \"wire_name\" applies globally)", "")),

// Authentication and streaming (optional, defaults from config)
Expand Down Expand Up @@ -117,8 +120,15 @@ func (p *ClientPlugin) generateClient(ctx cli.CommandContext) error {
err error
)

workDir, _ := os.Getwd()
if p.config != nil {
// Resolved from the working directory, not the project root.
//
// LoadClientConfig already walks upward, so starting here finds a config
// beside the package being generated *and* one at the project root.
// Starting at the root instead finds only the root's, which in a workspace
// is the one place the file usually is not — a package that carries its own
// .forge-client.yaml was silently generated with defaults.
workDir, err := os.Getwd()
if err != nil && p.config != nil {
workDir = p.config.RootDir
}

Expand All @@ -137,6 +147,9 @@ func (p *ClientPlugin) generateClient(ctx cli.CommandContext) error {
outputDir := ctx.String("output")
packageName := ctx.String("package")
baseURL := ctx.String("base-url")
reactQuery := ctx.Bool("react-query") || clientConfig.Defaults.ReactQuery
includePaths := ctx.StringSlice("include")
excludePaths := ctx.StringSlice("exclude")
module := ctx.String("module")

// Use config defaults if flags not provided
Expand Down Expand Up @@ -382,6 +395,32 @@ func (p *ClientPlugin) generateClient(ctx cli.CommandContext) error {
enableHistory = true
}

// Path filter: flags win, config fills in. Reported below rather than
// applied silently — a client that is quietly missing half its endpoints
// looks identical to one whose server never had them.
pathFilter := client.PathFilter{
Include: includePaths,
Exclude: excludePaths,
}

if len(pathFilter.Include) == 0 {
pathFilter.Include = clientConfig.Defaults.Include
}

if len(pathFilter.Exclude) == 0 {
pathFilter.Exclude = clientConfig.Defaults.Exclude
}

if !pathFilter.Empty() {
if len(pathFilter.Include) > 0 {
ctx.Info("Including paths: " + strings.Join(pathFilter.Include, ", "))
}

if len(pathFilter.Exclude) > 0 {
ctx.Info("Excluding paths: " + strings.Join(pathFilter.Exclude, ", "))
}
}

// Create config
genConfig := client.GeneratorConfig{
Language: language,
Expand All @@ -395,6 +434,8 @@ func (p *ClientPlugin) generateClient(ctx cli.CommandContext) error {
Version: "1.0.0",
FieldNaming: fieldNaming,
FieldOverrides: fieldOverrides,
PathFilter: pathFilter,
ReactQuery: reactQuery,
Features: client.Features{
Reconnection: reconnection,
Heartbeat: heartbeat,
Expand Down Expand Up @@ -536,8 +577,15 @@ func (p *ClientPlugin) listEndpoints(ctx cli.CommandContext) error {
err error
)

workDir, _ := os.Getwd()
if p.config != nil {
// Resolved from the working directory, not the project root.
//
// LoadClientConfig already walks upward, so starting here finds a config
// beside the package being generated *and* one at the project root.
// Starting at the root instead finds only the root's, which in a workspace
// is the one place the file usually is not — a package that carries its own
// .forge-client.yaml was silently generated with defaults.
workDir, err := os.Getwd()
if err != nil && p.config != nil {
workDir = p.config.RootDir
}

Expand Down
10 changes: 10 additions & 0 deletions cmd/forge/plugins/client_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ type GenerationDefaults struct {
BaseURL string `yaml:"base_url,omitempty"`
Module string `yaml:"module,omitempty"`

// ReactQuery emits TanStack Query hooks over the generated client.
ReactQuery bool `yaml:"react_query"`

// Include keeps only endpoints whose path matches a pattern; Exclude drops
// matches and is applied second. Both accept a path prefix, a glob, or a
// trailing "/**". A specification usually describes more than any one
// consumer talks to, and these are how a client binds only its own surface.
Include []string `yaml:"include,omitempty"`
Exclude []string `yaml:"exclude,omitempty"`

// Feature flags
Auth bool `yaml:"auth"`
Streaming bool `yaml:"streaming"`
Expand Down
7 changes: 7 additions & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,12 @@
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3"
},
"pnpm": {
"overrides": {
"postcss": "^8.5.18",
"sharp": "^0.35.0",
"esbuild": "^0.28.1"
}
}
}
Loading
Loading