From e976a6aa39c0aa7e6aa4b82f4ecb54f592b16383 Mon Sep 17 00:00:00 2001 From: BrieucCaillot Date: Thu, 11 Jun 2026 13:06:33 +0200 Subject: [PATCH 1/2] feat(ledger): add schemas commands (insert/get/list) Wrap the ledger v2 schema endpoints in fctl. insert accepts a local file or URL containing JSON or YAML; get renders json or yaml. --- cmd/ledger/root.go | 2 + cmd/ledger/schemas/get.go | 110 +++++++++++++++++++++++++++ cmd/ledger/schemas/insert.go | 143 +++++++++++++++++++++++++++++++++++ cmd/ledger/schemas/list.go | 102 +++++++++++++++++++++++++ cmd/ledger/schemas/root.go | 19 +++++ 5 files changed, 376 insertions(+) create mode 100644 cmd/ledger/schemas/get.go create mode 100644 cmd/ledger/schemas/insert.go create mode 100644 cmd/ledger/schemas/list.go create mode 100644 cmd/ledger/schemas/root.go diff --git a/cmd/ledger/root.go b/cmd/ledger/root.go index c34be1d8..c4602e55 100644 --- a/cmd/ledger/root.go +++ b/cmd/ledger/root.go @@ -5,6 +5,7 @@ import ( "github.com/formancehq/fctl/v3/cmd/ledger/accounts" "github.com/formancehq/fctl/v3/cmd/ledger/internal" + "github.com/formancehq/fctl/v3/cmd/ledger/schemas" "github.com/formancehq/fctl/v3/cmd/ledger/transactions" "github.com/formancehq/fctl/v3/cmd/ledger/volumes" fctl "github.com/formancehq/fctl/v3/pkg" @@ -25,6 +26,7 @@ func NewCommand() *cobra.Command { NewDeleteMetadataCommand(), NewExportCommand(), NewImportCommand(), + schemas.NewLedgerSchemasCommand(), transactions.NewLedgerTransactionsCommand(), accounts.NewLedgerAccountsCommand(), volumes.NewLedgerVolumesCommand(), diff --git a/cmd/ledger/schemas/get.go b/cmd/ledger/schemas/get.go new file mode 100644 index 00000000..c29424fe --- /dev/null +++ b/cmd/ledger/schemas/get.go @@ -0,0 +1,110 @@ +package schemas + +import ( + "encoding/json" + "fmt" + + "github.com/TylerBrock/colorjson" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" + "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + + internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type GetStore struct { + Schema ledger.V2SchemaData `json:"schema"` +} +type GetController struct { + store *GetStore + formatFlag string +} + +var _ fctl.Controller[*GetStore] = (*GetController)(nil) + +func NewDefaultGetStore() *GetStore { + return &GetStore{} +} + +func NewGetController() *GetController { + return &GetController{ + store: NewDefaultGetStore(), + formatFlag: "format", + } +} + +func NewGetCommand() *cobra.Command { + c := NewGetController() + return fctl.NewCommand("get ", + fctl.WithShortDescription("Get a schema for a ledger by version"), + fctl.WithAliases("g", "show"), + fctl.WithStringFlag(c.formatFlag, "json", "Output format of the schema (json, yaml)"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + fctl.WithController[*GetStore](c), + ) +} + +func (c *GetController) GetStore() *GetStore { + return c.store +} + +func (c *GetController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) + if err != nil { + return nil, err + } + + stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) + if err != nil { + return nil, err + } + + response, err := stackClient.Ledger.V2.GetSchema(cmd.Context(), operations.V2GetSchemaRequest{ + Ledger: fctl.GetString(cmd, internal.LedgerFlag), + Version: args[0], + }) + if err != nil { + return nil, err + } + + c.store.Schema = response.V2SchemaResponse.V2SchemaData + return c, nil +} + +func (c *GetController) Render(cmd *cobra.Command, _ []string) error { + out, err := json.Marshal(c.store.Schema) + if err != nil { + return err + } + + raw := make(map[string]any) + if err := json.Unmarshal(out, &raw); err != nil { + _, err = cmd.OutOrStdout().Write(out) + return err + } + + switch format := fctl.GetString(cmd, c.formatFlag); format { + case "yaml", "yml": + yamlOut, err := yaml.Marshal(raw) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(yamlOut) + return err + case "json": + f := colorjson.NewFormatter() + f.Indent = 2 + colorized, err := f.Marshal(raw) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(append(colorized, '\n')) + return err + default: + return fmt.Errorf("unsupported format %q (expected json or yaml)", format) + } +} diff --git a/cmd/ledger/schemas/insert.go b/cmd/ledger/schemas/insert.go new file mode 100644 index 00000000..72b3499c --- /dev/null +++ b/cmd/ledger/schemas/insert.go @@ -0,0 +1,143 @@ +package schemas + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" + "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + + internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type InsertStore struct { + Success bool `json:"success"` +} +type InsertController struct { + store *InsertStore +} + +var _ fctl.Controller[*InsertStore] = (*InsertController)(nil) + +func NewDefaultInsertStore() *InsertStore { + return &InsertStore{} +} + +func NewInsertController() *InsertController { + return &InsertController{ + store: NewDefaultInsertStore(), + } +} + +func NewInsertCommand() *cobra.Command { + return fctl.NewCommand("insert ", + fctl.WithShortDescription("Insert a schema for a ledger from a JSON/YAML file or URL"), + fctl.WithAliases("i", "create"), + fctl.WithConfirmFlag(), + fctl.WithArgs(cobra.ExactArgs(2)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + fctl.WithController[*InsertStore](NewInsertController()), + ) +} + +func (c *InsertController) GetStore() *InsertStore { + return c.store +} + +func (c *InsertController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { + _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) + if err != nil { + return nil, err + } + + stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) + if err != nil { + return nil, err + } + + ledgerName := fctl.GetString(cmd, internal.LedgerFlag) + version := args[0] + + schemaData, err := loadSchemaData(cmd.Context(), args[1]) + if err != nil { + return nil, err + } + + if !fctl.CheckStackApprobation(cmd, "You are about to insert schema version %s on ledger %s", version, ledgerName) { + return nil, fctl.ErrMissingApproval + } + + response, err := stackClient.Ledger.V2.InsertSchema(cmd.Context(), operations.V2InsertSchemaRequest{ + Ledger: ledgerName, + Version: version, + V2SchemaData: *schemaData, + }) + if err != nil { + return nil, err + } + + c.store.Success = response.StatusCode == 201 + return c, nil +} + +func (c *InsertController) Render(cmd *cobra.Command, _ []string) error { + pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Schema inserted!") + return nil +} + +func loadSchemaData(ctx context.Context, source string) (*ledger.V2SchemaDataInput, error) { + raw, err := readSource(ctx, source) + if err != nil { + return nil, err + } + + var intermediate any + if err := yaml.Unmarshal(raw, &intermediate); err != nil { + return nil, fmt.Errorf("parsing schema: %w", err) + } + + normalized, err := json.Marshal(intermediate) + if err != nil { + return nil, err + } + + schemaData := &ledger.V2SchemaDataInput{} + if err := json.Unmarshal(normalized, schemaData); err != nil { + return nil, err + } + + return schemaData, nil +} + +func readSource(ctx context.Context, source string) ([]byte, error) { + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetching schema from %s: unexpected status %s", source, resp.Status) + } + return io.ReadAll(resp.Body) + } + + return os.ReadFile(filepath.Clean(source)) +} diff --git a/cmd/ledger/schemas/list.go b/cmd/ledger/schemas/list.go new file mode 100644 index 00000000..d8c4b680 --- /dev/null +++ b/cmd/ledger/schemas/list.go @@ -0,0 +1,102 @@ +package schemas + +import ( + "fmt" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" + "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + "github.com/formancehq/go-libs/v4/pointer" + + internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ListStore struct { + Schemas []ledger.V2SchemaData `json:"schemas"` +} +type ListController struct { + store *ListStore + pageSizeFlag string +} + +var _ fctl.Controller[*ListStore] = (*ListController)(nil) + +func NewDefaultListStore() *ListStore { + return &ListStore{ + Schemas: []ledger.V2SchemaData{}, + } +} + +func NewListController() *ListController { + return &ListController{ + store: NewDefaultListStore(), + pageSizeFlag: "page-size", + } +} + +func NewListCommand() *cobra.Command { + c := NewListController() + return fctl.NewCommand("list", + fctl.WithAliases("ls", "l"), + fctl.WithShortDescription("List all schemas for a ledger"), + fctl.WithIntFlag(c.pageSizeFlag, 15, "Page size"), + fctl.WithArgs(cobra.ExactArgs(0)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + fctl.WithController[*ListStore](c), + ) +} + +func (c *ListController) GetStore() *ListStore { + return c.store +} + +func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { + _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) + if err != nil { + return nil, err + } + + stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) + if err != nil { + return nil, err + } + + response, err := stackClient.Ledger.V2.ListSchemas(cmd.Context(), operations.V2ListSchemasRequest{ + Ledger: fctl.GetString(cmd, internal.LedgerFlag), + PageSize: pointer.For(int64(fctl.GetInt(cmd, c.pageSizeFlag))), + }) + if err != nil { + return nil, err + } + + c.store.Schemas = response.V2SchemasCursorResponse.V2SchemasCursor.Data + return c, nil +} + +func (c *ListController) Render(cmd *cobra.Command, _ []string) error { + if len(c.store.Schemas) == 0 { + fctl.Println("No schemas found.") + return nil + } + + tableData := fctl.Map(c.store.Schemas, func(schema ledger.V2SchemaData) []string { + return []string{ + schema.Version, + schema.CreatedAt.Format(time.RFC3339), + fmt.Sprintf("%d", len(schema.V2ChartOfAccounts)), + fmt.Sprintf("%d", len(schema.V2QueryTemplates)), + fmt.Sprintf("%d", len(schema.V2TransactionTemplates)), + } + }) + tableData = fctl.Prepend(tableData, []string{"Version", "Created at", "Chart", "Queries", "Transactions"}) + + return pterm.DefaultTable. + WithHasHeader(). + WithWriter(cmd.OutOrStdout()). + WithData(tableData). + Render() +} diff --git a/cmd/ledger/schemas/root.go b/cmd/ledger/schemas/root.go new file mode 100644 index 00000000..25236981 --- /dev/null +++ b/cmd/ledger/schemas/root.go @@ -0,0 +1,19 @@ +package schemas + +import ( + "github.com/spf13/cobra" + + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewLedgerSchemasCommand() *cobra.Command { + return fctl.NewCommand("schemas", + fctl.WithAliases("schema", "sc"), + fctl.WithShortDescription("Manage ledger schemas"), + fctl.WithChildCommands( + NewInsertCommand(), + NewGetCommand(), + NewListCommand(), + ), + ) +} From faa6293dad84b37e1683ac7da228facc715cadb9 Mon Sep 17 00:00:00 2001 From: BrieucCaillot Date: Thu, 11 Jun 2026 17:30:56 +0200 Subject: [PATCH 2/2] fix(ledger): address schemas review feedback - insert: error out unless the API returns 204 (correct success code; was 201 and masked by an unconditional success message) - insert: fetch URL sources via fctl's configured HTTP client (honors --insecure-tls) with a request timeout - list: use WithCursorFlag/WithPageSizeFlag, follow cursor pagination, validate page size, and render cursor metadata --- cmd/ledger/schemas/insert.go | 23 ++++++++++------ cmd/ledger/schemas/list.go | 51 +++++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/cmd/ledger/schemas/insert.go b/cmd/ledger/schemas/insert.go index 72b3499c..d3f5cacd 100644 --- a/cmd/ledger/schemas/insert.go +++ b/cmd/ledger/schemas/insert.go @@ -1,7 +1,6 @@ package schemas import ( - "context" "encoding/json" "fmt" "io" @@ -9,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -21,6 +21,8 @@ import ( fctl "github.com/formancehq/fctl/v3/pkg" ) +const schemaFetchTimeout = 30 * time.Second + type InsertStore struct { Success bool `json:"success"` } @@ -69,7 +71,7 @@ func (c *InsertController) Run(cmd *cobra.Command, args []string) (fctl.Renderab ledgerName := fctl.GetString(cmd, internal.LedgerFlag) version := args[0] - schemaData, err := loadSchemaData(cmd.Context(), args[1]) + schemaData, err := loadSchemaData(cmd, args[1]) if err != nil { return nil, err } @@ -87,7 +89,10 @@ func (c *InsertController) Run(cmd *cobra.Command, args []string) (fctl.Renderab return nil, err } - c.store.Success = response.StatusCode == 201 + c.store.Success = response.StatusCode == 204 + if !c.store.Success { + return nil, fmt.Errorf("unexpected status code %d while inserting schema", response.StatusCode) + } return c, nil } @@ -96,8 +101,8 @@ func (c *InsertController) Render(cmd *cobra.Command, _ []string) error { return nil } -func loadSchemaData(ctx context.Context, source string) (*ledger.V2SchemaDataInput, error) { - raw, err := readSource(ctx, source) +func loadSchemaData(cmd *cobra.Command, source string) (*ledger.V2SchemaDataInput, error) { + raw, err := readSource(cmd, source) if err != nil { return nil, err } @@ -120,13 +125,15 @@ func loadSchemaData(ctx context.Context, source string) (*ledger.V2SchemaDataInp return schemaData, nil } -func readSource(ctx context.Context, source string) ([]byte, error) { +func readSource(cmd *cobra.Command, source string) ([]byte, error) { if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) + req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, source, nil) if err != nil { return nil, err } - resp, err := http.DefaultClient.Do(req) + client := fctl.GetHttpClient(cmd) + client.Timeout = schemaFetchTimeout + resp, err := client.Do(req) if err != nil { return nil, err } diff --git a/cmd/ledger/schemas/list.go b/cmd/ledger/schemas/list.go index d8c4b680..8f699466 100644 --- a/cmd/ledger/schemas/list.go +++ b/cmd/ledger/schemas/list.go @@ -9,7 +9,6 @@ import ( "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/pointer" internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" fctl "github.com/formancehq/fctl/v3/pkg" @@ -17,10 +16,10 @@ import ( type ListStore struct { Schemas []ledger.V2SchemaData `json:"schemas"` + Cursor fctl.Cursor `json:"cursor"` } type ListController struct { - store *ListStore - pageSizeFlag string + store *ListStore } var _ fctl.Controller[*ListStore] = (*ListController)(nil) @@ -33,8 +32,7 @@ func NewDefaultListStore() *ListStore { func NewListController() *ListController { return &ListController{ - store: NewDefaultListStore(), - pageSizeFlag: "page-size", + store: NewDefaultListStore(), } } @@ -43,7 +41,8 @@ func NewListCommand() *cobra.Command { return fctl.NewCommand("list", fctl.WithAliases("ls", "l"), fctl.WithShortDescription("List all schemas for a ledger"), - fctl.WithIntFlag(c.pageSizeFlag, 15, "Page size"), + fctl.WithCursorFlag(), + fctl.WithPageSizeFlag(), fctl.WithArgs(cobra.ExactArgs(0)), fctl.WithValidArgsFunction(cobra.NoFileCompletions), fctl.WithController[*ListStore](c), @@ -65,24 +64,36 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e return nil, err } - response, err := stackClient.Ledger.V2.ListSchemas(cmd.Context(), operations.V2ListSchemasRequest{ - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - PageSize: pointer.For(int64(fctl.GetInt(cmd, c.pageSizeFlag))), - }) + cursor, err := fctl.GetCursor(cmd) + if err != nil { + return nil, err + } + pageSize, err := fctl.GetPageSize(cmd) if err != nil { return nil, err } - c.store.Schemas = response.V2SchemasCursorResponse.V2SchemasCursor.Data + req := operations.V2ListSchemasRequest{ + Ledger: fctl.GetString(cmd, internal.LedgerFlag), + } + if cursor != "" { + req.Cursor = fctl.Ptr(cursor) + } else { + req.PageSize = fctl.Ptr(int64(pageSize)) + } + + response, err := stackClient.Ledger.V2.ListSchemas(cmd.Context(), req) + if err != nil { + return nil, err + } + + cur := response.V2SchemasCursorResponse.V2SchemasCursor + c.store.Schemas = cur.Data + c.store.Cursor = fctl.Cursor{HasMore: cur.HasMore, PageSize: cur.PageSize, Next: cur.Next, Previous: cur.Previous} return c, nil } func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - if len(c.store.Schemas) == 0 { - fctl.Println("No schemas found.") - return nil - } - tableData := fctl.Map(c.store.Schemas, func(schema ledger.V2SchemaData) []string { return []string{ schema.Version, @@ -94,9 +105,13 @@ func (c *ListController) Render(cmd *cobra.Command, _ []string) error { }) tableData = fctl.Prepend(tableData, []string{"Version", "Created at", "Chart", "Queries", "Transactions"}) - return pterm.DefaultTable. + if err := pterm.DefaultTable. WithHasHeader(). WithWriter(cmd.OutOrStdout()). WithData(tableData). - Render() + Render(); err != nil { + return err + } + + return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) }