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..d3f5cacd --- /dev/null +++ b/cmd/ledger/schemas/insert.go @@ -0,0 +1,150 @@ +package schemas + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "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" +) + +const schemaFetchTimeout = 30 * time.Second + +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, 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 == 204 + if !c.store.Success { + return nil, fmt.Errorf("unexpected status code %d while inserting schema", response.StatusCode) + } + return c, nil +} + +func (c *InsertController) Render(cmd *cobra.Command, _ []string) error { + pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Schema inserted!") + return nil +} + +func loadSchemaData(cmd *cobra.Command, source string) (*ledger.V2SchemaDataInput, error) { + raw, err := readSource(cmd, 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(cmd *cobra.Command, source string) ([]byte, error) { + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, source, nil) + if err != nil { + return nil, err + } + client := fctl.GetHttpClient(cmd) + client.Timeout = schemaFetchTimeout + resp, err := client.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..8f699466 --- /dev/null +++ b/cmd/ledger/schemas/list.go @@ -0,0 +1,117 @@ +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" + + internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ListStore struct { + Schemas []ledger.V2SchemaData `json:"schemas"` + Cursor fctl.Cursor `json:"cursor"` +} +type ListController struct { + store *ListStore +} + +var _ fctl.Controller[*ListStore] = (*ListController)(nil) + +func NewDefaultListStore() *ListStore { + return &ListStore{ + Schemas: []ledger.V2SchemaData{}, + } +} + +func NewListController() *ListController { + return &ListController{ + store: NewDefaultListStore(), + } +} + +func NewListCommand() *cobra.Command { + c := NewListController() + return fctl.NewCommand("list", + fctl.WithAliases("ls", "l"), + fctl.WithShortDescription("List all schemas for a ledger"), + fctl.WithCursorFlag(), + fctl.WithPageSizeFlag(), + 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 + } + + cursor, err := fctl.GetCursor(cmd) + if err != nil { + return nil, err + } + pageSize, err := fctl.GetPageSize(cmd) + if err != nil { + return nil, err + } + + 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 { + 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"}) + + if err := pterm.DefaultTable. + WithHasHeader(). + WithWriter(cmd.OutOrStdout()). + WithData(tableData). + Render(); err != nil { + return err + } + + return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) +} 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(), + ), + ) +}